SEM-06: semantische-such-/chat-oberflaeche #242
No files matched your search
@@ -18,6 +18,8 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/docchat"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/docchatapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/extract"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/extractapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/extractconfirmapi"
|
||||
@@ -118,8 +120,12 @@ func build(pool *pgxpool.Pool, cfg config, prov provider.Provider, workerID stri
|
||||
cfg.serviceToken,
|
||||
).RegisterRoutes(mux)
|
||||
|
||||
semanticsearchapi.NewServer(
|
||||
semanticsearch.New(prov, vectorindex.New(pool), cfg.model),
|
||||
searchSvc := semanticsearch.New(prov, vectorindex.New(pool), cfg.model)
|
||||
semanticsearchapi.NewServer(searchSvc, cfg.serviceToken).RegisterRoutes(mux)
|
||||
|
||||
chunkStore := vectorindex.New(pool)
|
||||
docchatapi.NewServer(
|
||||
docchat.New(searchSvc, prov, cfg.model, chunkStore),
|
||||
cfg.serviceToken,
|
||||
).RegisterRoutes(mux)
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# SEM-06 – Prüfprotokoll
|
||||
|
||||
Ticket: `ai-kanban/tickets/SEM-06.md` – Oberfläche für semantische Suche und Dokumenten-Chat.
|
||||
|
||||
## Umgebung
|
||||
|
||||
- Live-Testhost: `root@192.168.1.204` (SSH, Host-Key vertraut).
|
||||
- Wegwerf-Postgres-Datenbank `ai_sem06_dev` (eigene Rolle `ai_sem06`), Migrationen `ai/migrations/0001_*.up.sql` … `0009_*.up.sql` angewendet, nach Testende gelöscht.
|
||||
- `ai-app` (Go, dieser Branch) lief real gegen diese DB, Service-Token `sem06-test-token`.
|
||||
- Deterministischer Fake-Ollama-Server (`/api/embed`, `/api/chat`, `/api/tags`) auf `127.0.0.1:11499`, da der externe Ollama-Server vom Testhost aus im Testzeitraum nicht erreichbar war. Er ersetzt nur die Modellqualität, nicht die Schnittstelle — SEM-03/04/05 haben die eigentliche RAG-Qualität bereits geprüft; diese Kachel prüft die Oberfläche.
|
||||
- Ein Beispiel-Chunk (`dms_document`/`beispiel-dokument-1`) wurde in `content_embeddings` eingefügt.
|
||||
- Next.js-App `web/ai-search-chat` per `npm run build` + `next start -p 3406`, `AI_SEARCH_CHAT_API_URL=http://127.0.0.1:9601`, `AI_SEARCH_CHAT_SERVICE_TOKEN=sem06-test-token`.
|
||||
- Browser: Google Chrome (`/usr/bin/google-chrome`) via Puppeteer/pa11y, `--no-sandbox`.
|
||||
|
||||
## Automatisierte Tests
|
||||
|
||||
- Go: `go test ./internal/docchatapi/... ./internal/docchat/... ./cmd/ai-app/...` (Modul `ai/`) — alle grün.
|
||||
- Vitest: `npm test` in `web/ai-search-chat` — 3 Dateien, 5 Tests, alle grün (`SearchPanel.test.tsx`, `ChatPanel.test.tsx`, `page.test.tsx`).
|
||||
- `npx tsc --noEmit` — keine Fehler.
|
||||
- `npm run build` (Next.js) — erfolgreich.
|
||||
|
||||
## Prüfungen (Akzeptanzkriterien)
|
||||
|
||||
### 1. Bedienfluss Suche und Chat im Test durchlaufen
|
||||
|
||||
Ziel: Suche und Chat lassen sich aus derselben Oberfläche bedienen, echter Roundtrip zur echten `ai-app`-API.
|
||||
|
||||
Ergebnis: Erfüllt. Puppeteer-Skript hat live gegen `http://127.0.0.1:3406/` (echte Next.js-Instanz, echte `ai-app`-Instanz) folgendes durchlaufen:
|
||||
- Suchbegriff "rechnung" eingegeben, Treffer aus dem echten `/search`-Endpunkt erhalten und angezeigt.
|
||||
- Über den Reiter "Chat" zur Chat-Ansicht gewechselt, Frage gestellt, echte Antwort inkl. Zitat aus dem echten `/chat`-Endpunkt erhalten.
|
||||
|
||||
Konsolenausgabe des Skripts (Auszug):
|
||||
```
|
||||
Suche zeigt Quellenverweis: true
|
||||
Chat-Antwort sichtbar: true
|
||||
Chat-Quellenverweis sichtbar: true
|
||||
```
|
||||
|
||||
### 2. Quellenanzeige im Test sichtbar bestätigt
|
||||
|
||||
Ziel: Sowohl Suchtreffer als auch Chat-Antworten zeigen ihre Quelle.
|
||||
|
||||
Ergebnis: Erfüllt.
|
||||
- Suchtreffer zeigen "Quelle: dms_document beispiel-dokument-1, Abschnitt 0" (im DOM bestätigt).
|
||||
- Chat-Antwort zeigt die Zitatliste "[1] dms_document beispiel-dokument-1, Abschnitt 0" mit Schaltfläche "Quelle anzeigen", die den tatsächlich im Index gespeicherten Abschnitt live nachlädt (`POST /chat/citation` → `docchat.ResolveCitation`, real getestet: Antwort `"originaler abschnitt"`/echter Dokumenttext je nach Aufruf).
|
||||
|
||||
### 3. Verlauf nach Navigation im Test erhalten
|
||||
|
||||
Ziel: Der Chat-Verlauf bleibt innerhalb der Sitzung erhalten, auch nach Wechsel zwischen Suche und Chat.
|
||||
|
||||
Ergebnis: Erfüllt. Nach der Chat-Antwort wurde live zum Reiter "Suche" und zurück zu "Chat" gewechselt; Frage und Antwort waren weiterhin im DOM sichtbar.
|
||||
```
|
||||
Verlauf nach Navigation erhalten: true
|
||||
```
|
||||
Zusätzlich durch `page.test.tsx` automatisiert abgesichert (Reiterwechsel, Verlauf bleibt erhalten).
|
||||
|
||||
## WCAG2AA (pa11y)
|
||||
|
||||
- `pa11y --standard WCAG2AA` gegen `http://127.0.0.1:3406/` im Ausgangszustand: **No issues found!**
|
||||
- `pa11y` mit Actions (Reiterwechsel zu Chat, Frage stellen, auf Antwort warten) gegen denselben Zustand wie in Prüfung 1/2 beschrieben: **No issues found!**
|
||||
|
||||
## Notwendige Ergänzung (kein Scope-Creep)
|
||||
|
||||
`SEM-05`s Implementierungsnotiz bestätigte sich: `ai/internal/docchat` hatte keine HTTP-Bindung, nur die Bibliothek. Für eine Oberfläche wird ein HTTP-Endpunkt benötigt. Ergänzt wurde das kleinstmögliche Paket `ai/internal/docchatapi` (Muster identisch zu `summaryapi`/`semanticsearchapi`: Service-Token-Pflicht, zwei Routen `POST /chat` und `POST /chat/citation`), in `ai/cmd/ai-app/main.go` verdrahtet. Keine eigene Geschäftslogik — reine HTTP-Bindung der bestehenden `docchat.Service`-Methoden `Ask`/`ResolveCitation`.
|
||||
|
||||
## Entscheidungen
|
||||
|
||||
- Suche und Chat als zwei Reiter auf einer Seite (`app/page.tsx`), Zustand (Suchfeld/Ergebnisse, Chat-Frage/-Verlauf) im Elternelement gehalten, damit ein Reiterwechsel den Zustand nicht verwirft (AK3).
|
||||
- Mandant/erlaubte Referenzen für diese Kachel fest im BFF hinterlegt (`lib/examples.ts`), analog zum bestehenden `ai-summaries`-Muster — echte Fall-/Rechteauflösung ist Sache des aufrufenden Moduls (DMS/Mail), nicht dieser Kachel.
|
||||
- Deterministischer Fake-Ollama-Server nur für den Live-Test verwendet (echter Ollama-Server war vom Testhost aus nicht erreichbar); die Schnittstelle (`ai-app` HTTP-API) ist real, nur das Sprachmodell ist ein Test-Double.
|
||||
|
||||
## Offene Punkte
|
||||
|
||||
Keine. `QA-04` (Prüfgate) ist ausdrücklich nicht Bestandteil dieser Kachel.
|
||||
@@ -0,0 +1,122 @@
|
||||
// Package docchatapi ist die HTTP-Bindung des Dokumenten-Chats (SEM-04/SEM-05),
|
||||
// damit eine Oberflaeche (SEM-06) ihn abrufen kann. Aufrufer ist das anfragende
|
||||
// Modul (DMS/Mail) bzw. dessen eigene Backend-for-Frontend-Route, nicht der
|
||||
// Endnutzer direkt — Service-Token-Pflicht wie semanticsearchapi/summaryapi,
|
||||
// fail closed.
|
||||
package docchatapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/docchat"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/semanticsearch"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/svcauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/vectorindex"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
svc *docchat.Service
|
||||
serviceToken string
|
||||
}
|
||||
|
||||
func NewServer(svc *docchat.Service, serviceToken string) *Server {
|
||||
return &Server{svc: svc, serviceToken: serviceToken}
|
||||
}
|
||||
|
||||
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /chat", s.requireAuth(s.handleAsk))
|
||||
mux.HandleFunc("POST /chat/citation", s.requireAuth(s.handleResolveCitation))
|
||||
}
|
||||
|
||||
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
got := r.Header.Get("X-Service-Token")
|
||||
if !svcauth.Equal(got, s.serviceToken) {
|
||||
writeError(w, http.StatusUnauthorized, "service-token fehlt oder ist ungueltig")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
type refRequest struct {
|
||||
SourceType string `json:"source_type"`
|
||||
SourceID string `json:"source_id"`
|
||||
}
|
||||
|
||||
type askRequest struct {
|
||||
TenantSlug string `json:"tenant_slug"`
|
||||
Question string `json:"question"`
|
||||
TopK int `json:"top_k"`
|
||||
AllowedRefs []refRequest `json:"allowed_refs"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAsk(w http.ResponseWriter, r *http.Request) {
|
||||
var req askRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
|
||||
return
|
||||
}
|
||||
|
||||
refs := make([]semanticsearch.Ref, 0, len(req.AllowedRefs))
|
||||
for _, ref := range req.AllowedRefs {
|
||||
refs = append(refs, semanticsearch.Ref{SourceType: ref.SourceType, SourceID: ref.SourceID})
|
||||
}
|
||||
|
||||
answer, err := s.svc.Ask(r.Context(), req.TenantSlug, req.Question, req.TopK, refs)
|
||||
switch {
|
||||
case err == nil:
|
||||
writeJSON(w, http.StatusOK, answer)
|
||||
case errors.Is(err, semanticsearch.ErrNoAllowedRefs):
|
||||
writeError(w, http.StatusBadRequest, "keine freigegebenen referenzen")
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, "chat-antwort fehlgeschlagen")
|
||||
}
|
||||
}
|
||||
|
||||
type citationRequest struct {
|
||||
TenantSlug string `json:"tenant_slug"`
|
||||
Index int `json:"index"`
|
||||
SourceType string `json:"source_type"`
|
||||
SourceID string `json:"source_id"`
|
||||
ChunkIndex int `json:"chunk_index"`
|
||||
}
|
||||
|
||||
type citationResponse struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func (s *Server) handleResolveCitation(w http.ResponseWriter, r *http.Request) {
|
||||
var req citationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
|
||||
return
|
||||
}
|
||||
|
||||
text, err := s.svc.ResolveCitation(r.Context(), req.TenantSlug, docchat.Citation{
|
||||
Index: req.Index,
|
||||
SourceType: req.SourceType,
|
||||
SourceID: req.SourceID,
|
||||
ChunkIndex: req.ChunkIndex,
|
||||
})
|
||||
switch {
|
||||
case err == nil:
|
||||
writeJSON(w, http.StatusOK, citationResponse{Text: text})
|
||||
case errors.Is(err, vectorindex.ErrChunkNotFound):
|
||||
writeError(w, http.StatusNotFound, "abschnitt nicht gefunden")
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, "zitat konnte nicht aufgeloest werden")
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package docchatapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/docchat"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/docchatapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/semanticsearch"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/vectorindex"
|
||||
)
|
||||
|
||||
type stubSearcher struct {
|
||||
hits []semanticsearch.Hit
|
||||
err error
|
||||
}
|
||||
|
||||
func (s stubSearcher) Search(context.Context, string, string, int, []semanticsearch.Ref) ([]semanticsearch.Hit, error) {
|
||||
return s.hits, s.err
|
||||
}
|
||||
|
||||
type stubProvider struct{ response provider.ChatResponse }
|
||||
|
||||
func (p *stubProvider) Chat(context.Context, provider.ChatRequest) (provider.ChatResponse, error) {
|
||||
return p.response, nil
|
||||
}
|
||||
func (p *stubProvider) Complete(context.Context, provider.CompletionRequest) (provider.CompletionResponse, error) {
|
||||
return provider.CompletionResponse{}, errors.New("nicht implementiert")
|
||||
}
|
||||
func (p *stubProvider) Embed(context.Context, provider.EmbeddingRequest) (provider.EmbeddingResponse, error) {
|
||||
return provider.EmbeddingResponse{}, errors.New("nicht implementiert")
|
||||
}
|
||||
|
||||
type stubLocator struct {
|
||||
chunkText string
|
||||
err error
|
||||
}
|
||||
|
||||
func (l *stubLocator) GetChunk(context.Context, string, string, string, int) (string, error) {
|
||||
return l.chunkText, l.err
|
||||
}
|
||||
|
||||
func newTestServer(searcher stubSearcher, locator *stubLocator) *docchatapi.Server {
|
||||
svc := docchat.New(searcher, &stubProvider{response: provider.ChatResponse{
|
||||
Message: provider.Message{Role: provider.RoleAssistant, Content: "Antwort laut Beleg [1]."},
|
||||
}}, "test-model", locator)
|
||||
return docchatapi.NewServer(svc, "geheimes-token")
|
||||
}
|
||||
|
||||
func TestHandleAsk_RequiresServiceToken(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
newTestServer(stubSearcher{}, &stubLocator{}).RegisterRoutes(mux)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"tenant_slug": "acme", "question": "test", "top_k": 5})
|
||||
req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("erwartet 401 ohne service-token, bekommen %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAsk_ValidRequest_ReturnsAnswerWithCitations(t *testing.T) {
|
||||
hits := []semanticsearch.Hit{{SourceType: "dms_document", SourceID: "doc-1", ChunkIndex: 0, ChunkText: "beleg"}}
|
||||
mux := http.NewServeMux()
|
||||
newTestServer(stubSearcher{hits: hits}, &stubLocator{}).RegisterRoutes(mux)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"tenant_slug": "acme",
|
||||
"question": "worum geht es?",
|
||||
"top_k": 5,
|
||||
"allowed_refs": []map[string]string{
|
||||
{"source_type": "dms_document", "source_id": "doc-1"},
|
||||
},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader(body))
|
||||
req.Header.Set("X-Service-Token", "geheimes-token")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("erwartet 200, bekommen %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp docchat.Answer
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("antwort dekodieren: %v", err)
|
||||
}
|
||||
if len(resp.Citations) != 1 || resp.Citations[0].SourceID != "doc-1" {
|
||||
t.Fatalf("erwartet eine zitat-quelle, bekommen %+v", resp.Citations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAsk_NoHits_ReturnsNoEvidenceAnswer(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
newTestServer(stubSearcher{hits: nil}, &stubLocator{}).RegisterRoutes(mux)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"tenant_slug": "acme",
|
||||
"question": "worum geht es?",
|
||||
"top_k": 5,
|
||||
"allowed_refs": []map[string]string{
|
||||
{"source_type": "dms_document", "source_id": "doc-1"},
|
||||
},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader(body))
|
||||
req.Header.Set("X-Service-Token", "geheimes-token")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
var resp docchat.Answer
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("antwort dekodieren: %v", err)
|
||||
}
|
||||
if !resp.NoEvidence || resp.Text != docchat.NoEvidenceAnswer {
|
||||
t.Fatalf("erwartet no-evidence-antwort, bekommen %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResolveCitation_ReturnsChunkText(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
newTestServer(stubSearcher{}, &stubLocator{chunkText: "originaler abschnitt"}).RegisterRoutes(mux)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"tenant_slug": "acme",
|
||||
"index": 1,
|
||||
"source_type": "dms_document",
|
||||
"source_id": "doc-1",
|
||||
"chunk_index": 0,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/chat/citation", bytes.NewReader(body))
|
||||
req.Header.Set("X-Service-Token", "geheimes-token")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("erwartet 200, bekommen %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("antwort dekodieren: %v", err)
|
||||
}
|
||||
if resp.Text != "originaler abschnitt" {
|
||||
t.Fatalf("erwartet originalen abschnitt, bekommen %q", resp.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResolveCitation_UnknownChunk_ReturnsNotFound(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
newTestServer(stubSearcher{}, &stubLocator{err: vectorindex.ErrChunkNotFound}).RegisterRoutes(mux)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"tenant_slug": "acme",
|
||||
"index": 1,
|
||||
"source_type": "dms_document",
|
||||
"source_id": "doc-1",
|
||||
"chunk_index": 9,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/chat/citation", bytes.NewReader(body))
|
||||
req.Header.Set("X-Service-Token", "geheimes-token")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("erwartet 404, bekommen %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
.next
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ChatPanel } from "./ChatPanel";
|
||||
|
||||
vi.mock("../lib/api", () => ({
|
||||
ask: vi.fn(async (question: string) => ({
|
||||
text: `Antwort auf: ${question} [1]`,
|
||||
sources: [{ sourceType: "dms_document", sourceId: "doc-1", chunkIndex: 0, chunkText: "beleg", score: 0.9 }],
|
||||
citations: [{ index: 1, sourceType: "dms_document", sourceId: "doc-1", chunkIndex: 0 }],
|
||||
noEvidence: false,
|
||||
})),
|
||||
resolveCitation: vi.fn(async () => "originaler abschnitt"),
|
||||
}));
|
||||
|
||||
describe("ChatPanel", () => {
|
||||
// Pruefung 1: Bedienfluss Chat, Pruefung 2: Quellenanzeige sichtbar
|
||||
it("zeigt Antwort und Quellenverweise nach dem Absenden einer Frage", async () => {
|
||||
const user = userEvent.setup();
|
||||
let history: any[] = [];
|
||||
function Wrapper() {
|
||||
const [q, setQ] = useState("worum geht es?");
|
||||
const [h, setH] = useState(history);
|
||||
return (
|
||||
<ChatPanel
|
||||
question={q}
|
||||
onQuestionChange={setQ}
|
||||
history={h}
|
||||
onHistoryChange={(next: any[]) => {
|
||||
history = next;
|
||||
setH(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
render(<Wrapper />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Fragen" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Antwort auf: worum geht es\?/)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/dms_document doc-1, Abschnitt 0/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// AK3 / Pruefung 3: der uebergebene Verlauf bleibt erhalten (Zustand im
|
||||
// Elternelement, hier direkt geprueft ueber die history-Prop).
|
||||
it("zeigt bereits vorhandenen Verlauf sofort an", () => {
|
||||
const history = [
|
||||
{
|
||||
question: "alte frage",
|
||||
answer: { text: "alte antwort", sources: [], citations: [], noEvidence: false },
|
||||
},
|
||||
];
|
||||
render(<ChatPanel question="" onQuestionChange={() => {}} history={history} onHistoryChange={() => {}} />);
|
||||
|
||||
expect(screen.getByText("alte frage", { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText("alte antwort")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ask, Citation, ChatAnswer, resolveCitation } from "../lib/api";
|
||||
|
||||
export interface ChatTurn {
|
||||
question: string;
|
||||
answer: ChatAnswer;
|
||||
}
|
||||
|
||||
interface ChatPanelProps {
|
||||
question: string;
|
||||
onQuestionChange: (question: string) => void;
|
||||
history: ChatTurn[];
|
||||
onHistoryChange: (history: ChatTurn[]) => void;
|
||||
}
|
||||
|
||||
export function ChatPanel({ question, onQuestionChange, history, onHistoryChange }: ChatPanelProps) {
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (question.trim() === "") return;
|
||||
setStatus("loading");
|
||||
try {
|
||||
const answer = await ask(question);
|
||||
onHistoryChange([...history, { question, answer }]);
|
||||
onQuestionChange("");
|
||||
setStatus("idle");
|
||||
} catch (err) {
|
||||
setStatus("error");
|
||||
setErrorMessage((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label="Dokumenten-Chat">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label htmlFor="chat-question">Frage</label>
|
||||
<br />
|
||||
<input
|
||||
id="chat-question"
|
||||
type="text"
|
||||
value={question}
|
||||
onChange={(e) => onQuestionChange(e.target.value)}
|
||||
style={{ width: "100%", maxWidth: 480, marginTop: 4, marginBottom: 8 }}
|
||||
/>
|
||||
<button id="chat-submit" type="submit" disabled={status === "loading"}>
|
||||
Fragen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{status === "loading" && <p role="status">Antwort wird erzeugt…</p>}
|
||||
{status === "error" && <p role="alert">Antwort konnte nicht erzeugt werden: {errorMessage}</p>}
|
||||
|
||||
<ol aria-label="Chat-Verlauf">
|
||||
{history.map((turn, i) => (
|
||||
<li key={i} style={{ marginBottom: 16 }}>
|
||||
<p>
|
||||
<strong>Frage:</strong> {turn.question}
|
||||
</p>
|
||||
<p>{turn.answer.text}</p>
|
||||
{turn.answer.citations.length > 0 && (
|
||||
<div>
|
||||
<strong>Quellen:</strong>
|
||||
<ul>
|
||||
{turn.answer.citations.map((c) => (
|
||||
<CitationItem key={c.index} citation={c} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CitationItem({ citation }: { citation: Citation }) {
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleShow() {
|
||||
setError(null);
|
||||
try {
|
||||
setText(await resolveCitation(citation));
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li>
|
||||
[{citation.index}] {citation.sourceType} {citation.sourceId}, Abschnitt {citation.chunkIndex}{" "}
|
||||
<button type="button" onClick={handleShow}>
|
||||
Quelle anzeigen
|
||||
</button>
|
||||
{text !== null && <p>{text}</p>}
|
||||
{error !== null && <p role="alert">{error}</p>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SearchPanel } from "./SearchPanel";
|
||||
|
||||
vi.mock("../lib/api", () => ({
|
||||
search: vi.fn(async (query: string) => [
|
||||
{ sourceType: "dms_document", sourceId: "doc-1", chunkIndex: 0, chunkText: `Treffer zu ${query}`, score: 0.9 },
|
||||
]),
|
||||
}));
|
||||
|
||||
function Wrapper() {
|
||||
return <SearchPanel query="" onQueryChange={() => {}} results={[]} onResultsChange={() => {}} />;
|
||||
}
|
||||
|
||||
describe("SearchPanel", () => {
|
||||
// Pruefung 1: Bedienfluss Suche
|
||||
it("laedt Treffer nach Eingabe und Absenden", async () => {
|
||||
const user = userEvent.setup();
|
||||
let results: any[] = [];
|
||||
const onResultsChange = (r: any[]) => {
|
||||
results = r;
|
||||
};
|
||||
const { rerender } = render(
|
||||
<SearchPanel query="rechnung" onQueryChange={() => {}} results={results} onResultsChange={onResultsChange} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Suchen" }));
|
||||
|
||||
await waitFor(() => expect(results.length).toBe(1));
|
||||
rerender(
|
||||
<SearchPanel query="rechnung" onQueryChange={() => {}} results={results} onResultsChange={onResultsChange} />,
|
||||
);
|
||||
|
||||
// Pruefung 2: Quellenanzeige sichtbar
|
||||
expect(screen.getByText(/Quelle: dms_document doc-1/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Hit, search } from "../lib/api";
|
||||
|
||||
interface SearchPanelProps {
|
||||
query: string;
|
||||
onQueryChange: (query: string) => void;
|
||||
results: Hit[];
|
||||
onResultsChange: (results: Hit[]) => void;
|
||||
}
|
||||
|
||||
export function SearchPanel({ query, onQueryChange, results, onResultsChange }: SearchPanelProps) {
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (query.trim() === "") return;
|
||||
setStatus("loading");
|
||||
try {
|
||||
const hits = await search(query);
|
||||
onResultsChange(hits);
|
||||
setStatus("idle");
|
||||
} catch (err) {
|
||||
setStatus("error");
|
||||
setErrorMessage((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label="Semantische Suche">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label htmlFor="search-query">Suchbegriff</label>
|
||||
<br />
|
||||
<input
|
||||
id="search-query"
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
style={{ width: "100%", maxWidth: 480, marginTop: 4, marginBottom: 8 }}
|
||||
/>
|
||||
<button type="submit" disabled={status === "loading"}>
|
||||
Suchen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{status === "loading" && <p role="status">Suche läuft…</p>}
|
||||
{status === "error" && <p role="alert">Suche fehlgeschlagen: {errorMessage}</p>}
|
||||
|
||||
{status !== "loading" && results.length > 0 && (
|
||||
<ul>
|
||||
{results.map((hit, i) => (
|
||||
<li key={`${hit.sourceType}-${hit.sourceId}-${hit.chunkIndex}-${i}`} style={{ marginBottom: 12 }}>
|
||||
<p style={{ margin: 0 }}>{hit.chunkText}</p>
|
||||
<small>
|
||||
Quelle: {hit.sourceType} {hit.sourceId}, Abschnitt {hit.chunkIndex}
|
||||
</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{status === "idle" && results.length === 0 && query.trim() !== "" && <p>Keine Treffer.</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { EXAMPLE_TENANT_SLUG } from "../../../../lib/examples";
|
||||
|
||||
function backendURL(): string {
|
||||
const base = process.env.AI_SEARCH_CHAT_API_URL;
|
||||
if (!base) {
|
||||
throw new Error("AI_SEARCH_CHAT_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)");
|
||||
}
|
||||
return base.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function serviceToken(): string {
|
||||
const token = process.env.AI_SEARCH_CHAT_SERVICE_TOKEN;
|
||||
if (!token) {
|
||||
throw new Error("AI_SEARCH_CHAT_SERVICE_TOKEN ist nicht gesetzt (Umgebungsvariable erforderlich)");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const citation = await request.json().catch(() => null);
|
||||
if (!citation || typeof citation.sourceType !== "string" || typeof citation.sourceId !== "string") {
|
||||
return NextResponse.json({ error: "Zitat-Angaben fehlen" }, { status: 400 });
|
||||
}
|
||||
|
||||
let backendResponse: Response;
|
||||
try {
|
||||
backendResponse = await fetch(`${backendURL()}/chat/citation`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Service-Token": serviceToken() },
|
||||
body: JSON.stringify({
|
||||
tenant_slug: EXAMPLE_TENANT_SLUG,
|
||||
index: citation.index,
|
||||
source_type: citation.sourceType,
|
||||
source_id: citation.sourceId,
|
||||
chunk_index: citation.chunkIndex,
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: `KI-Dienst nicht erreichbar: ${(err as Error).message}` }, { status: 502 });
|
||||
}
|
||||
const body = await backendResponse.json().catch(() => null);
|
||||
if (!backendResponse.ok) {
|
||||
return NextResponse.json(body ?? { error: "Abschnitt konnte nicht geladen werden" }, { status: backendResponse.status });
|
||||
}
|
||||
return NextResponse.json({ text: body.text });
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { EXAMPLE_ALLOWED_REFS, EXAMPLE_TENANT_SLUG } from "../../../lib/examples";
|
||||
|
||||
function backendURL(): string {
|
||||
const base = process.env.AI_SEARCH_CHAT_API_URL;
|
||||
if (!base) {
|
||||
throw new Error("AI_SEARCH_CHAT_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)");
|
||||
}
|
||||
return base.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function serviceToken(): string {
|
||||
const token = process.env.AI_SEARCH_CHAT_SERVICE_TOKEN;
|
||||
if (!token) {
|
||||
throw new Error("AI_SEARCH_CHAT_SERVICE_TOKEN ist nicht gesetzt (Umgebungsvariable erforderlich)");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { question } = await request.json().catch(() => ({ question: "" }));
|
||||
if (typeof question !== "string" || question.trim() === "") {
|
||||
return NextResponse.json({ error: "Frage fehlt" }, { status: 400 });
|
||||
}
|
||||
|
||||
let backendResponse: Response;
|
||||
try {
|
||||
backendResponse = await fetch(`${backendURL()}/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Service-Token": serviceToken() },
|
||||
body: JSON.stringify({
|
||||
tenant_slug: EXAMPLE_TENANT_SLUG,
|
||||
question,
|
||||
top_k: 5,
|
||||
allowed_refs: EXAMPLE_ALLOWED_REFS.map((r) => ({ source_type: r.sourceType, source_id: r.sourceId })),
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: `KI-Dienst nicht erreichbar: ${(err as Error).message}` }, { status: 502 });
|
||||
}
|
||||
const body = await backendResponse.json().catch(() => null);
|
||||
if (!backendResponse.ok) {
|
||||
return NextResponse.json(body ?? { error: "Antwort konnte nicht erzeugt werden" }, { status: backendResponse.status });
|
||||
}
|
||||
return NextResponse.json({
|
||||
text: body.text,
|
||||
sources: (body.sources ?? []).map((h: any) => ({
|
||||
sourceType: h.source_type,
|
||||
sourceId: h.source_id,
|
||||
chunkIndex: h.chunk_index,
|
||||
chunkText: h.chunk_text,
|
||||
score: h.score,
|
||||
})),
|
||||
citations: (body.citations ?? []).map((c: any) => ({
|
||||
index: c.index,
|
||||
sourceType: c.source_type,
|
||||
sourceId: c.source_id,
|
||||
chunkIndex: c.chunk_index,
|
||||
})),
|
||||
noEvidence: !!body.no_evidence,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { EXAMPLE_ALLOWED_REFS, EXAMPLE_TENANT_SLUG } from "../../../lib/examples";
|
||||
|
||||
function backendURL(): string {
|
||||
const base = process.env.AI_SEARCH_CHAT_API_URL;
|
||||
if (!base) {
|
||||
throw new Error("AI_SEARCH_CHAT_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)");
|
||||
}
|
||||
return base.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function serviceToken(): string {
|
||||
const token = process.env.AI_SEARCH_CHAT_SERVICE_TOKEN;
|
||||
if (!token) {
|
||||
throw new Error("AI_SEARCH_CHAT_SERVICE_TOKEN ist nicht gesetzt (Umgebungsvariable erforderlich)");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { query } = await request.json().catch(() => ({ query: "" }));
|
||||
if (typeof query !== "string" || query.trim() === "") {
|
||||
return NextResponse.json({ error: "Suchbegriff fehlt" }, { status: 400 });
|
||||
}
|
||||
|
||||
let backendResponse: Response;
|
||||
try {
|
||||
backendResponse = await fetch(`${backendURL()}/search`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Service-Token": serviceToken() },
|
||||
body: JSON.stringify({
|
||||
tenant_slug: EXAMPLE_TENANT_SLUG,
|
||||
query,
|
||||
top_k: 5,
|
||||
allowed_refs: EXAMPLE_ALLOWED_REFS.map((r) => ({ source_type: r.sourceType, source_id: r.sourceId })),
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: `KI-Dienst nicht erreichbar: ${(err as Error).message}` }, { status: 502 });
|
||||
}
|
||||
const body = await backendResponse.json().catch(() => null);
|
||||
if (!backendResponse.ok) {
|
||||
return NextResponse.json(body ?? { error: "Suche konnte nicht ausgefuehrt werden" }, { status: backendResponse.status });
|
||||
}
|
||||
const hits = (body.hits ?? []).map((h: any) => ({
|
||||
sourceType: h.source_type,
|
||||
sourceId: h.source_id,
|
||||
chunkIndex: h.chunk_index,
|
||||
chunkText: h.chunk_text,
|
||||
score: h.score,
|
||||
}));
|
||||
return NextResponse.json({ hits });
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Script from "next/script";
|
||||
import { ThemeProvider, I18nProvider, ToastProvider, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
|
||||
|
||||
export const metadata = {
|
||||
title: "NEXARCH Suche & Chat",
|
||||
};
|
||||
|
||||
const focusVisibleStyle = `
|
||||
:focus-visible {
|
||||
outline: 3px solid var(--shl-color-focus-ring, #1D4ED8);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<style>{focusVisibleStyles}</style>
|
||||
<Script id="shl-theme-init" strategy="beforeInteractive">
|
||||
{themeInitScript()}
|
||||
</Script>
|
||||
</head>
|
||||
<body
|
||||
style={{
|
||||
fontFamily: typography.fontFamily,
|
||||
margin: 0,
|
||||
background: "var(--shl-color-background, #ffffff)",
|
||||
color: "var(--shl-color-text-primary, #14181f)",
|
||||
}}
|
||||
>
|
||||
<style>{focusVisibleStyle}</style>
|
||||
<ThemeProvider>
|
||||
<I18nProvider initialLocale="de">
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import SearchChatPage from "./page";
|
||||
|
||||
vi.mock("../lib/api", () => ({
|
||||
search: vi.fn(async () => [
|
||||
{ sourceType: "dms_document", sourceId: "doc-1", chunkIndex: 0, chunkText: "treffer", score: 0.9 },
|
||||
]),
|
||||
ask: vi.fn(async (question: string) => ({
|
||||
text: `Antwort auf: ${question} [1]`,
|
||||
sources: [],
|
||||
citations: [{ index: 1, sourceType: "dms_document", sourceId: "doc-1", chunkIndex: 0 }],
|
||||
noEvidence: false,
|
||||
})),
|
||||
resolveCitation: vi.fn(async () => "abschnitt"),
|
||||
}));
|
||||
|
||||
describe("SearchChatPage", () => {
|
||||
// AK1 / Pruefung 1: Suche und Chat sind aus einer Oberflaeche erreichbar.
|
||||
it("bietet Suche und Chat ueber Reiter auf derselben Seite an", () => {
|
||||
render(<SearchChatPage />);
|
||||
expect(screen.getByRole("tab", { name: "Suche" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Chat" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// AK3 / Pruefung 3: Verlauf bleibt nach Navigation (Reiterwechsel) erhalten.
|
||||
it("behaelt den Chat-Verlauf beim Wechsel zwischen Suche und Chat", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SearchChatPage />);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Chat" }));
|
||||
await user.type(screen.getByLabelText("Frage"), "worum geht es?");
|
||||
await user.click(screen.getByRole("button", { name: "Fragen" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Antwort auf: worum geht es\?/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Suche" }));
|
||||
await user.click(screen.getByRole("tab", { name: "Chat" }));
|
||||
|
||||
expect(screen.getByText(/Antwort auf: worum geht es\?/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { SearchPanel } from "./SearchPanel";
|
||||
import { ChatPanel, ChatTurn } from "./ChatPanel";
|
||||
import { Hit } from "../lib/api";
|
||||
|
||||
type Tab = "search" | "chat";
|
||||
|
||||
export default function SearchChatPage() {
|
||||
const [tab, setTab] = useState<Tab>("search");
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<Hit[]>([]);
|
||||
|
||||
const [chatQuestion, setChatQuestion] = useState("");
|
||||
const [chatHistory, setChatHistory] = useState<ChatTurn[]>([]);
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 800, margin: "0 auto", padding: 24 }}>
|
||||
<h1>Suche und Chat</h1>
|
||||
|
||||
<div role="tablist" aria-label="Ansicht" style={{ marginBottom: 16 }}>
|
||||
<button
|
||||
id="tab-search"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === "search"}
|
||||
onClick={() => setTab("search")}
|
||||
style={{ fontWeight: tab === "search" ? "bold" : "normal", marginRight: 8 }}
|
||||
>
|
||||
Suche
|
||||
</button>
|
||||
<button
|
||||
id="tab-chat"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === "chat"}
|
||||
onClick={() => setTab("chat")}
|
||||
style={{ fontWeight: tab === "chat" ? "bold" : "normal" }}
|
||||
>
|
||||
Chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div hidden={tab !== "search"}>
|
||||
<SearchPanel
|
||||
query={searchQuery}
|
||||
onQueryChange={setSearchQuery}
|
||||
results={searchResults}
|
||||
onResultsChange={setSearchResults}
|
||||
/>
|
||||
</div>
|
||||
<div hidden={tab !== "chat"}>
|
||||
<ChatPanel
|
||||
question={chatQuestion}
|
||||
onQuestionChange={setChatQuestion}
|
||||
history={chatHistory}
|
||||
onHistoryChange={setChatHistory}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Duenner Client fuer die eigenen Backend-for-Frontend-Routen unter
|
||||
// app/api — kein direkter Zugriff auf die AI-API vom Client aus (Rolle:
|
||||
// "Ruft ausschliesslich die vorhandene Schnittstelle auf"), gleiches
|
||||
// Muster wie web/ai-summaries.
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Hit {
|
||||
sourceType: string;
|
||||
sourceId: string;
|
||||
chunkIndex: number;
|
||||
chunkText: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface Citation {
|
||||
index: number;
|
||||
sourceType: string;
|
||||
sourceId: string;
|
||||
chunkIndex: number;
|
||||
}
|
||||
|
||||
export interface ChatAnswer {
|
||||
text: string;
|
||||
sources: Hit[];
|
||||
citations: Citation[];
|
||||
noEvidence: boolean;
|
||||
}
|
||||
|
||||
async function parse<T>(res: Response, fallback: string): Promise<T> {
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
throw new ApiError(body?.error ?? `${fallback} (${res.status})`, res.status);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export async function search(query: string): Promise<Hit[]> {
|
||||
const res = await fetch("/api/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query }),
|
||||
cache: "no-store",
|
||||
});
|
||||
const body = await parse<{ hits: Hit[] }>(res, "Suche fehlgeschlagen");
|
||||
return body.hits;
|
||||
}
|
||||
|
||||
export async function ask(question: string): Promise<ChatAnswer> {
|
||||
const res = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ question }),
|
||||
cache: "no-store",
|
||||
});
|
||||
return parse<ChatAnswer>(res, "Antwort konnte nicht erzeugt werden");
|
||||
}
|
||||
|
||||
export async function resolveCitation(citation: Citation): Promise<string> {
|
||||
const res = await fetch("/api/chat/citation", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(citation),
|
||||
cache: "no-store",
|
||||
});
|
||||
const body = await parse<{ text: string }>(res, "Abschnitt konnte nicht geladen werden");
|
||||
return body.text;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Fester Beispiel-Mandant und feste freigegebene Referenzen fuer diese
|
||||
// Kachel — Zugriffssteuerung je Nutzer/Fall ist Sache des aufrufenden Moduls
|
||||
// (DMS/Mail) und nicht Bestandteil von SEM-06 ("keine eigene Geschaeftslogik").
|
||||
|
||||
export const EXAMPLE_TENANT_SLUG = "beispiel-mandant";
|
||||
|
||||
export const EXAMPLE_ALLOWED_REFS = [{ sourceType: "dms_document", sourceId: "beispiel-dokument-1" }];
|
||||
@@ -0,0 +1,5 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ["@nexarch/shl"],
|
||||
};
|
||||
export default nextConfig;
|
||||
Generated
+3319
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "nexarch-ai-search-chat",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nexarch/shl": "file:../shl",
|
||||
"next": "16.3.5",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "16.3.3",
|
||||
"@testing-library/user-event": "14.5.2",
|
||||
"@types/node": "^22.20.4",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"jsdom": "24.1.0",
|
||||
"typescript": "5.5.3",
|
||||
"vitest": "^5.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
react: path.resolve(__dirname, "node_modules/react"),
|
||||
"react-dom": path.resolve(__dirname, "node_modules/react-dom"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(cleanup);
|
||||
Reference in new issue
Block a user