SEM-06: semantische-such-/chat-oberflaeche #242

Merged
patrick merged 1 commits from feature/sem-06-semantische-such-chat-oberflaeche into main 2026-09-28 04:08:04 +02:00
23 changed files with 4442 additions and 2 deletions

No files matched your search

+8 -2
View File
@@ -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)
+74
View File
@@ -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.
+122
View File
@@ -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})
}
+175
View File
@@ -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)
}
}
+2
View File
@@ -0,0 +1,2 @@
node_modules
.next
+61
View File
@@ -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();
});
});
+104
View File
@@ -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();
});
});
+66
View File
@@ -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 });
}
+63
View File
@@ -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 });
}
+45
View File
@@ -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>
);
}
+45
View File
@@ -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();
});
});
+64
View File
@@ -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>
);
}
+74
View File
@@ -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;
}
+7
View File
@@ -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" }];
+5
View File
@@ -0,0 +1,5 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@nexarch/shl"],
};
export default nextConfig;
File diff suppressed because it is too large. Load diff
+27
View File
@@ -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"
}
}
+21
View File
@@ -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"]
}
+15
View File
@@ -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"],
},
});
+5
View File
@@ -0,0 +1,5 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(cleanup);