diff --git a/README.md b/README.md index c31aa4f..d87387c 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ En plattform för att utforska riksdagsdebatter och metadata. Backenden bygger p └──────────────┘ ``` -- **ArangoDB**: primär databas. Sök görs via ArangoSearch-vyer och AQL; inga relationsfrågor används. +- **ArangoDB**: primär databas. Sök görs via ArangoSearch-vyer i stället för SQL. - **ChromaDB**: håller embeddings för chatten och mer avancerad semantisk sökning. - **LLM**: genererar chattsvar med hjälp av `_llm.LLM` (kopplad till t.ex. vLLM/OpenAI API). @@ -101,6 +101,7 @@ riksdagen/ - `search(...)`: bygger en ArangoSearch AQL-query med filter (parti, år, talare) samt scoring via `BM25()` eller `TFIDF()`. - `get_metadata(...)`: hämtar distinkta värden till frontendens filter. - `get_document_by_id(...)`: används för detaljerad visning/snippets. +- **Viktigt:** ingen SQL används. AQL byggs dynamiskt och exekveras mot en ArangoSearch-vy (se `_arango/queries`). ### `backend/chat.py` @@ -148,30 +149,26 @@ riksdagen/ ### Sökning via ArangoSearch -1. **Frontend (`SearchPanel.tsx`)** samlar användarens filter och anropar `searchTalks` i `frontend/src/api.ts`. -2. **FastAPI (`backend/search.py`)** tar emot POST `/api/search`, instansierar `SearchService` och skickar vidare parametrar. -3. **`SearchService.search`** bygger en AQL-fråga mot ArangoSearch-vyn (t.ex. `talks_view`) där filter och scoring läggs till. Se `_arango/queries/search_view.py` för den faktiska queryn. - ```aql - FOR doc IN talks_view - SEARCH ANALYZER(doc.anforande_text, "text_sv") %PHRASE(@query)% - FILTER (@party == null OR doc.party == @party) - FILTER (@year == null OR doc.year == @year) - SORT BM25(doc) DESC - LIMIT @offset, @limit - RETURN MERGE(doc, { snippet: doc.preview }) - ``` -4. **ArangoDB** returnerar matchande dokument. Backend formaterar svaren (färger, snippet) och skickar JSON tillbaka. -5. **Frontend** renderar resultatet i `ResultsTable.tsx`, uppdaterar statistikpaneler och hanterar paginering. +1. **Frontend** samlar filtervärden (t.ex. `query="skola"`, `party="S"`, `year=2023`). +2. `api.searchTalks` gör POST till `/api/search`. +3. **FastAPI** tar emot request → skapar `SearchService`. +4. `SearchService.search`: + - Bygger AQL med `SEARCH`-klasuler mot en ArangoSearch-vy (exempel: `SEARCH ANALYZER(doc.anforande_text, "text_sv") LIKE "%skola%"`). + - Använder `SORT BM25(doc)` eller `SORT doc.datum` beroende på begärda sorteringsparametrar. + - Begränsar resultat (`LIMIT offset, limit`) och returnerar träffar + total count. +5. Backend serialiserar dokumenten till JSON (inklusive metadata som färger, snippet, etc.). +6. **Frontend** renderar resultat och uppdaterar paginering/statistikpaneler. -### Chattflöde med LLM och Chroma +### Chatt med LLM -1. **Frontend (`ChatPanel.tsx`)** skickar konversationen till `POST /api/chat`. -2. **`backend/chat.py`**: - - Extraherar senaste användarfrågan. - - Hämtar relevanta chunkar via Chroma (`_chromadb.collections.get_collection(...).query(...)`). - - Optionellt kompletterar kontexten med `SearchService.search` för ArangoSearch-träffar. - - Bygger prompten, anropar `_llm.LLM(model="vllm")` och returnerar svaret. -3. **Frontend** lägger till AI-svaret i konversationen och visar källor/metadata för transparens. +1. Användaren skriver fråga i chat-panelen. +2. `api.chat` skickar en lista av meddelanden (`[{ role: 'user', content: ... }, ...]`). +3. `backend/chat.py`: + - Tar ut senaste användarfrågan. + - `vector_search(...)` i samma modul eller via `_chromadb` hämtar semantiskt relevanta avsnitt. + - Kan komplettera svar med `SearchService`-resultat för att säkerställa faktabas. + - Lägger samman kontext och anropar `_llm.LLM`. +4. AI-svaret skickas tillbaka till frontenden och läggs på konversationen. --- @@ -179,65 +176,47 @@ riksdagen/ | Fil | Funktion | |-----|----------| -| `arango_client.py` | Initierar Arango-klienten med anslutningsuppgifter från miljön. | -| `_arango/_arango.py` | Wrapper som kapslar uppkoppling och hjälpfunktioner mot ArangoDB och views. | -| `_arango/queries/*` | Samling återskapbara AQL-snippets för sök, metadata och detaljhämtning. | -| `_chromadb/client.py` | Skapar ChromaDB-klienten (lokal DuckDB + Parquet som standard). | -| `_chromadb/collections.py` | Hjälper till att skapa/hämta Chroma-samlingar, lägga in chunkar och göra `query`. | -| `_llm/llm.py` | OpenAI-kompatibel klient. Använd `LLM(model='vllm')` och `chat(...)` för generering. | -| `backend/search.py` | Innehåller `SearchService` och API-routes för sök, bygger AQL mot ArangoSearch. | -| `backend/chat.py` | Chat- och vector-endpoints. Kopplar ihop Chroma-resultat med LLM-svar. | -| `frontend/src/api.ts` | Axios-klient för att prata med backenden (`searchTalks`, `chat`, `vectorSearch`, `getMeta`). | -| `frontend/src/components/*` | UI-komponenter i React/TypeScript för sök, resultat, chat, statistik. | +| `arango_client.py` | Central Arango-klient. Återanvänds i backend och scripts. | +| `_arango/_arango.py` | Wrapper som läser miljövariabler, öppnar anslutningar och ger helpers för views/collections. | +| `_arango/queries/*` | Samlade AQL-snippets för sökning, metadata, detaljer. Läs dessa för att förstå exakta fält och filtreringslogik. | +| `_chromadb/client.py` | Skapar en Chroma-klient (kan vara persistent eller in-memory beroende på config). | +| `_chromadb/collections.py` | Hjälpfunktioner för att skapa/hämta Chroma-samlingar och göra `query`/`add`. | +| `_llm/llm.py` | LLM-wrapper. `LLM(model='vllm')` returnerar en klient med `.chat(messages=...)`. | +| `backend/search.py` | `SearchService` med AQL/ArangoSearch-hantering och responsformatering. | +| `backend/chat.py` | Chat-endpoints, orchestrerar Chroma + LLM + (valfritt) Arango-sök. | +| `frontend/src/api.ts` | Samlar alla HTTP-anrop till backend. Lätt att mocka i tester. | +| `frontend/src/components/*` | React-komponenter. Kommentera gärna i koden var data kommer ifrån och vad som skickas tillbaka till backend. | --- ## 🗄️ ArangoDB och ArangoSearch -- **Kollektioner**: rådata om tal, talare och debatter lagras i Arango-kollektioner. -- **Views**: exempelvis `talks_view` indexerar textfält med analysatorn `text_sv`. Alla fritextfrågor går via vyn. -- **AQL-byggstenar**: +- **Data** ligger i ArangoDB-kollektioner, ofta `talks`, `speakers`, `debates`, etc. +- **Views**: en ArangoSearch-vy (t.ex. `talks_view`) indexerar textfält med svenska analyser (`text_sv`). Detta gör att `SEARCH`-frågor kan använda ranking (BM25/TFIDF) och filtrera på fält. +- **AQL-byggstenar** (se `_arango/queries`): - `FOR doc IN talks_view` - - `SEARCH` med `ANALYZER`, `PHRASE` eller token-matchning. - - `FILTER` för parti, år, kategori och talare. - - `SORT BM25(doc)` eller `SORT doc.datum` beroende på begärd sortering. - - `LIMIT @offset, @limit` för paginering. -- **Metadata**: hämtas via dedikerade queries i `_arango/queries/meta.py` (t.ex. `COLLECT party = doc.party RETURN DISTINCT party`). - ---- + - `SEARCH ANALYZER(doc.anforande_text, "text_sv") LIKE "%..."%` + - `FILTER doc.party == @party` + - `SORT BM25(doc) DESC` + - `LIMIT @offset, @limit` +- **Metadata**: Distinkta listor (ex. partier, år) hämtas via AQL `COLLECT`. -## 🧠 ChromaDB för vektorsök - -- Embeddings genereras via `scripts/build_embeddings.py` som: - 1. Läser dokument från Arango via `arango_client`. - 2. Delar text i chunkar (`_chromadb/embeddings.py`). - 3. Beräknar embeddings (vanligen med `_llm` eller annan embeddingsgenerator). - 4. Sparar chunkar + metadata i en Chroma-samling. -- Chatten hämtar liknande chunkar med `collection.query(query_texts=[...], n_results=top_k)` och använder resultaten i prompten. +> **Ny i ArangoSearch?** Läs igenom `_arango/queries` och Arangos officiella exempel. Notera att ArangoSearch skiljer sig från vanliga AQL-index genom att all textindexering sker i vyn, inte kollektionen. --- -## 🔧 Utvecklingsflöde - -| Steg | Befallning | Kommentar | -|------|------------|-----------| -| Starta backend i utvecklingsläge | `uvicorn backend.app:app --reload --port 8000` | Kör i projektroten. | -| Starta frontend i dev-läge | `cd frontend && npm run dev` | Öppna http://localhost:5173. | -| Bygg frontend för produktion | `make frontend` | Skapar `frontend/dist`. | -| Ladda om nginx (om du serverar statiskt) | `sudo systemctl reload nginx` | Efter ny frontend-build. | -| Kör embeddings-script | `python -m scripts.build_embeddings --limit 500` | Uppdaterar Chroma-samlingen. | -| Kör Arango-inmatning | `python -m scripts.ingest_arguments` | Läser in tal/metadata i Arango. | - ---- +## 🧠 ChromaDB för vektorsök -## 🧪 Felsökning och tips +- `_chromadb/client.py` konfigurerar anslutningen (lokal fil, DuckDB + Parquet som standard). +- Embeddings genereras i `scripts/build_embeddings.py`: + 1. Hämtar tal från ArangoDB med `arango_client`. + 2. Delar upp text i chunkar (se `_chromadb/embeddings.py` för heuristiken). + 3. Kör embeddingsgenerator (vanligen via `_llm` eller egen modul). + 4. Lagrar chunkar + metadata (`talk_id`, `speaker`, `party`, `source`) i Chroma-samlingen. -- **Arango-anslutning**: kör `python arango_client.py` eller `arangosh --server.endpoint tcp://127.0.0.1:8529`. -- **Kontrollera AQL**: använd ArangoDB Web UI eller `arangosh` för att testa samma query som `SearchService` byggde. -- **Chroma-status**: kontrollera `persist_directory` i `_chromadb/client.py`; ta bort katalogen om du behöver återskapa samlingen. -- **Embeddings**: kör `python -m scripts.build_embeddings --limit 20` för att uppdatera Chroma när nytt material importeras. -- **LLM-svar**: logga prompt och kontext i `backend/chat.py` om svaren känns irrelevanta. -- **React/TypeScript**: använd `useQuery` för GET, `useMutation` för POST och typsätt API-kontrakt via `frontend/src/types.ts`. +- Vid chat: + - `vector_search` skickar användarfrågan som embedding till Chroma (`collection.query(query_texts=[...], n_results=top_k)`). + - Resultatet innehåller `ids`, `documents`, `metadatas`, `distances` som matas in i prompten. --- @@ -263,3 +242,1163 @@ riksdagen/ > Undvik onödiga `try/except`. Fel bubbla upp så att du ser den faktiska orsaken (t.ex. nätverksproblem, fel API-nyckel). +--- + +## 🛠️ Utvecklingsflöde + +| Steg | Befallning | Kommentar | +|------|------------|-----------| +| Starta backend i utvecklingsläge | `uvicorn backend.app:app --reload --port 8000` | Körs i projektroten. Automatiskt reload vid filändring. | +| Starta frontend i dev-läge | `cd frontend && npm run dev` | Öppna http://localhost:5173 | +| Bygg frontend för produktion | `make frontend` | Skapar `frontend/dist`. | +| Ladda om nginx (om du serverar statiskt) | `sudo systemctl reload nginx` | Efter ny frontend-build. | +| Kör embeddings-script | `python -m scripts.build_embeddings --limit 500` | Uppdaterar Chroma-samlingen. | +| Kör anpassade Arango-script | `python -m scripts.ingest_arguments` | Exempel, kontrollera scriptens docstrings. | + +--- + +## 🧪 Felsökning och tips + +- **Arango-anslutning**: kör `python arango_client.py` för att testa att lösenord och URL stämmer. +- **Chroma-data**: öppna `_chromadb/client.py` och kontrollera `persist_directory`. Rensa katalogen om du behöver resetta. +- **Chat-svar saknar fakta**: kontrollera att `build_embeddings.py` har körts nyligen och att Chroma-samlingen innehåller rätt metadata. +- **React/TypeScript**: + - Använd `useQuery` (för GET) och `useMutation` (för POST). + - Håll komponenter små och bryt ut delkomponenter vid behov (t.ex. `FilterControls`, `ChatMessageList`). + - Typsätt props, state och API-svar med `types.ts` för bättre editorstöd. +- **Python-stil**: + - Lägg till type hints på funktioner (ex: `def search(self, params: SearchRequest) -> SearchResponse:`). + - Skriv docstrings som beskriver syfte, parametrar och returvärden. + - Undvik generella `try/except`; analysera felen vid körning och fixa orsaken. + +--- + +## 🤔 Nästa steg + +1. **Kommentera kod**: lägg till docstrings i `SearchService` och chat-hjälpare. +2. **Testa ArangoQueries**: skapa enhetstester som kör små AQL-scenarier med testdata. +3. **Frontend-guidning**: överväg inline-kommentarer i React-komponenterna för att förklara state-hantering. +4. **Observabilitet**: logga vilka ArangoSearch-queries som körs vid olika filterkombinationer. + +--- + +## 📎 Referenser + +- [ArangoDB Documentation – ArangoSearch](https://www.arangodb.com/docs/stable/arangosearch.html) +- [ChromaDB Python API](https://docs.trychroma.com/) +- [FastAPI – Depends och Pydantic](https://fastapi.tiangolo.com/) +- [React + TypeScript Cheatsheets](https://react-typescript-cheatsheet.netlify.app/) +- `batch_embed()` – Processar flera texter samtidigt + +**Exempel**: +```python +def get_embedding(text: str, model: str = "embeddinggemma") -> List[float]: + """ + Genererar en embedding-vektor för given text. + + Args: + text: Texten att embedda + model: Ollama-modell (default: embeddinggemma) + + Returns: + Lista med floats (3072 dimensioner för embeddinggemma) + """ + # Anropar http://192.168.1.11:11434/api/embeddings + # Returnerar vektor +``` + +#### `backend/services/vector_store.py` + +Hanterar pgvector-operationer: +- `create_embeddings_table()` – Skapar `talk_embeddings`-tabell +- `store_embedding()` – Sparar embedding i databas +- `query_similar_talks()` – Semantisk sökning + +--- + +### Database: `backend/database.py` + +**Viktigt**: Denna fil skapar databaskopplingen. + +```python +# Skapar engine (kopplingen till PostgreSQL) +engine = create_engine(DATABASE_URL) + +# Aktiverar pgvector-extension +with engine.connect() as conn: + conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) + +# Session factory – används för att skapa databas-sessioner +SessionLocal = sessionmaker(bind=engine) + +# Dependency för FastAPI – ger varje endpoint en databas-session +def get_db(): + db = SessionLocal() + try: + yield db # Ger sessionen till endpointen + finally: + db.close() # Stänger sessionen efter request +``` + +**Hur används det**: +```python +@router.post("/api/search") +def search_endpoint(request: SearchRequest, db: Session = Depends(get_db)): + # db är nu en aktiv databas-session + results = search_talks(db, ...) +``` + +--- + +## ⚛️ Frontend-arkitektur + +### Huvudfil: `frontend/src/App.tsx` + +Detta är root-komponenten som renderas i `index.html`. + +```typescript +function App() { + const [mode, setMode] = useState<'search' | 'chat' | 'vector'>('search'); + + // mode styr vilket läge användaren är i: + // - 'search': Vanlig nyckelordssökning + // - 'chat': AI-chattläge + // - 'vector': Semantisk vektorsökning + + return ( +
+ {/* Knappar för att växla läge */} + + + + {/* Visar olika komponenter baserat på mode */} + {mode === 'search' && } + {mode === 'chat' && } + {mode === 'vector' && } +
+ ); +} +``` + +**Viktiga React-koncept**: +- `useState`: Skapar state-variabel som kan ändras. När `mode` ändras, re-renderar React komponenten. +- `{ }`: JavaScript-uttryck inuti JSX (React's HTML-liknande syntax) +- `&&`: Villkorlig rendering – `condition && ` renderar bara om condition är true + +--- + +### API-klient: `frontend/src/api.ts` + +Detta är hur frontend pratar med backend. + +```typescript +import axios from 'axios'; + +// Base URL – i produktion går detta via nginx reverse proxy +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'; + +// axios-instans med konfigurerad base URL +const api = axios.create({ + baseURL: API_BASE_URL, + headers: { 'Content-Type': 'application/json' } +}); + +// Funktion för att söka +export async function searchTalks(params: SearchParams): Promise { + const response = await api.post('/search', params); + return response.data; // Backend returnerar JSON +} + +// Funktion för att chatta +export async function chat(messages: ChatMessage[]): Promise { + const response = await api.post('/chat', { messages }); + return response.data; +} +``` + +**TypeScript-koncept**: +- `async/await`: Hanterar asynkrona operationer (API-anrop) +- `Promise`: Representerar ett framtida värde av typ T +- `interface`: Definierar shape av objekt (se `types.ts`) + +--- + +### Komponenter + +#### `frontend/src/components/SearchPanel.tsx` + +Sökformulär med filter. + +**State-hantering**: +```typescript +const [query, setQuery] = useState(''); // Fritextsök +const [party, setParty] = useState(''); // Valt parti +const [year, setYear] = useState(''); // Valt år +// ...fler filter + +// react-query mutation för att söka +const searchMutation = useMutation({ + mutationFn: searchTalks, + onSuccess: (data) => { + // data innehåller SearchResponse från backend + // Skickas till ResultsTable via props + } +}); + +// När användaren klickar "Sök" +const handleSearch = () => { + searchMutation.mutate({ + query, + party, + year, + // ...andra filter + }); +}; +``` + +**React Query (`useMutation`)**: +- Hanterar API-anrop automatiskt +- Ger tillgång till `isLoading`, `error`, `data` +- Cachas resultat för snabbare återanvändning + +**JSX-exempel**: +```tsx + setQuery(e.target.value)} + placeholder="Sök efter innehåll..." +/> +``` + +**Hur det fungerar**: +1. `value={query}` – Input-fältet visar värdet av `query` state +2. `onChange` – När användaren skriver, uppdateras `query` med `setQuery()` +3. Detta är "controlled component" pattern i React + +--- + +#### `frontend/src/components/ResultsTable.tsx` + +Visar sökresultat i tabell. + +```typescript +interface ResultsTableProps { + results: TalkResult[]; // Array av anföranden från backend + total: int; // Totalt antal träffar + isLoading: boolean; // Om data laddas +} + +function ResultsTable({ results, total, isLoading }: ResultsTableProps) { + if (isLoading) return
Laddar...
; + + if (results.length === 0) return
Inga resultat
; + + return ( + + + + + + + + + + + {results.map((talk) => ( + + + + + + + ))} + +
DatumTalarePartiInnehåll
{talk.datum}{talk.talare} + {talk.parti} + {talk.snippet}
+ ); +} +``` + +**Viktigt**: +- `.map()`: Loopar över array och returnerar JSX för varje element +- `key={talk.id}`: React behöver unika keys för att effektivt uppdatera listan +- Props nedåt: Parent (SearchPanel) skickar data till child (ResultsTable) + +--- + +#### `frontend/src/components/ChatPanel.tsx` + +AI-chattgränssnitt. + +**State**: +```typescript +const [messages, setMessages] = useState([ + { role: 'assistant', content: 'Hej! Vad vill du veta om riksdagsdebatter?' } +]); +const [input, setInput] = useState(''); // Användarens input + +const chatMutation = useMutation({ + mutationFn: (msgs: ChatMessage[]) => chat(msgs), + onSuccess: (response) => { + // Lägg till AI-svaret i messages + setMessages(prev => [...prev, { role: 'assistant', content: response.reply }]); + } +}); + +const handleSend = () => { + // Lägg till användarens meddelande + const newMessages = [...messages, { role: 'user', content: input }]; + setMessages(newMessages); + setInput(''); // Töm input-fält + + // Skicka till backend + chatMutation.mutate(newMessages); +}; +``` + +**Rendering**: +```tsx +
+ {messages.map((msg, idx) => ( +
+ {msg.role === 'user' ? 'Du' : 'AI'}: +

{msg.content}

+
+ ))} +
+ + setInput(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleSend()} +/> + +``` + +--- + +#### `frontend/src/components/VectorSearchPanel.tsx` + +Semantisk sökning via embeddings. + +**Funktion**: +```typescript +const vectorMutation = useMutation({ + mutationFn: (query: string) => vectorSearch(query, 10), // Top 10 resultat + onSuccess: (data) => { + // data.results innehåller liknande anföranden + } +}); + +const handleVectorSearch = () => { + vectorMutation.mutate(searchQuery); +}; +``` + +Resultaten visas i samma format som `ResultsTable`. + +--- + +### TypeScript-typer: `frontend/src/types.ts` + +Definierar data-strukturer som matchar backend. + +```typescript +// Matchar backend/schemas.py SearchRequest +export interface SearchParams { + query: string; + party?: string; + year?: number; + category?: string; + speaker?: string; + sort_by: string; + sort_order: string; + offset: number; + limit: number; +} + +// Matchar backend TalkResult +export interface TalkResult { + id: string; + datum: string; + talare: string; + parti: string; + party_color: string; + anforandetext: string; + snippet: string; + kategori?: string; +} + +// Matchar backend SearchResponse +export interface SearchResponse { + results: TalkResult[]; + total: number; + offset: number; + limit: number; +} +``` + +**Varför TypeScript?** +- Fångar fel vid compile-time (innan kod körs) +- Autocomplete i editorn +- Dokumenterar API:t automatiskt + +--- + +## 🔄 Dataflöde + +### Exempel: Användaren söker efter "skola" + +1. **Frontend** (`SearchPanel.tsx`): + ```typescript + // Användaren skriver "skola" och klickar "Sök" + handleSearch() { + searchMutation.mutate({ + query: "skola", + party: "", + year: "", + sort_by: "date", + sort_order: "desc", + offset: 0, + limit: 50 + }); + } + ``` + +2. **API-klient** (`api.ts`): + ```typescript + // axios skickar POST /api/search med JSON-body + const response = await api.post('/search', params); + ``` + +3. **nginx** (reverse proxy): + ``` + location /api/ { + proxy_pass http://127.0.0.1:8000/api/; + } + ``` + Omdirigerar till backend på port 8000. + +4. **Backend** (`routes/search.py`): + ```python + @router.post("/api/search") + def search_endpoint(request: SearchRequest, db: Session = Depends(get_db)): + # Pydantic validerar request automatiskt + results, total = search_talks( + db=db, + query=request.query, # "skola" + filters={ + "party": request.party, + "year": request.year, + # ... + }, + sort_by=request.sort_by, + sort_order=request.sort_order, + offset=request.offset, + limit=request.limit + ) + + return SearchResponse( + results=results, + total=total, + offset=request.offset, + limit=request.limit + ) + ``` + +5. **Service** (`services/search_service.py`): + ```python + def search_talks(db, query, filters, ...): + # Bygger SQL-fråga + sql = f""" + SELECT {', '.join(select_columns)} + FROM talks + WHERE anforandetext ILIKE %s + """ + params = [f"%{query}%"] # "%skola%" + + # Lägger till filter + if filters.get('party'): + sql += " AND parti = %s" + params.append(filters['party']) + + # Kör fråga + result = db.execute(text(sql), params) + rows = result.fetchall() + + # Genererar snippets + talks = [generate_snippet(row, query) for row in rows] + + return talks, len(talks) + ``` + +6. **PostgreSQL**: + ```sql + SELECT id, datum, talare, parti, anforandetext, ... + FROM talks + WHERE anforandetext ILIKE '%skola%' + ORDER BY datum DESC + LIMIT 50 OFFSET 0; + ``` + +7. **Backend** returnerar JSON: + ```json + { + "results": [ + { + "id": "abc123", + "datum": "2023-11-15", + "talare": "Anna Andersson", + "parti": "S", + "party_color": "#E8112d", + "snippet": "...vi måste investera i skolan..." + }, + ... + ], + "total": 234, + "offset": 0, + "limit": 50 + } + ``` + +8. **Frontend** (`SearchPanel.tsx`): + ```typescript + onSuccess: (data) => { + // react-query cachas svaret + // ResultsTable uppdateras automatiskt med data.results + } + ``` + +9. **Rendering** (`ResultsTable.tsx`): + ```tsx + {results.map(talk => ( + + {talk.datum} + {talk.talare} + {talk.parti} + + + ))} + ``` + +--- + +## 🗄️ Databas & Schema + +### Huvudtabell: `talks` + +**Kolumner** (definierat i `info.py` → `select_columns`): + +```python +select_columns = [ + "id", # Unikt ID (UUID) + "datum", # Datum (YYYY-MM-DD) + "talare", # Talarens namn + "parti", # Partikod (S, M, SD, etc.) + "anforandetext", # Fullständig text + "kategori", # Debattämne + "year", # År (extrakt från datum) + "debatt_titel", # Debattens titel + "intressent_id", # Talarens ID + # ...fler kolumner +] +``` + +**Indexering** (viktigt för prestanda): + +```sql +-- Trigram-index för fritextsökning +CREATE INDEX talks_text_trgm_idx ON talks USING gin(anforandetext gin_trgm_ops); + +-- Index för filter +CREATE INDEX talks_year_idx ON talks(year); +CREATE INDEX talks_parti_idx ON talks(parti); +CREATE INDEX talks_datum_idx ON talks(datum); +``` + +--- + +### Embeddings-tabell: `talk_embeddings` + +**Schema** (skapas i `services/vector_store.py`): + +```sql +CREATE TABLE talk_embeddings ( + id SERIAL PRIMARY KEY, + talk_id TEXT NOT NULL, -- Referens till talks.id + chunk_index INTEGER, -- Vilken chunk (0, 1, 2, ...) + chunk_text TEXT, -- Texten för chunken + embedding vector(3072), -- pgvector-kolumn (embeddinggemma = 3072 dim) + metadata JSONB, -- Extra info (talare, parti, datum) + created_at TIMESTAMP DEFAULT NOW() +); + +-- Index för snabb vector-sökning +CREATE INDEX talk_embeddings_vector_idx ON talk_embeddings +USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); + +-- Unique constraint för idempotens +CREATE UNIQUE INDEX talk_embeddings_unique_idx +ON talk_embeddings(talk_id, chunk_index); +``` + +**Hur embeddings fungerar**: + +1. Långt anförande delas upp i chunkar (~500 tokens vardera) +2. Varje chunk får en embedding-vektor (3072 floats) +3. Vektorn representerar semantisk mening +4. Vid sökning: Generera embedding för frågan, hitta närmaste chunkar via cosine similarity + +**Exempel**: + +```python +# Anförande från Anna Andersson (talk_id="abc123") +# Texten är 2000 ord → delas upp i 4 chunkar + +talk_embeddings: +| id | talk_id | chunk_index | chunk_text | embedding | metadata | +|----|---------|-------------|---------------------|-------------------------|---------------------------------| +| 1 | abc123 | 0 | "Vi måste satsa..." | [0.23, -0.45, ...] | {"talare": "Anna", "parti": "S"}| +| 2 | abc123 | 1 | "Skolan behöver..." | [0.12, -0.67, ...] | {"talare": "Anna", "parti": "S"}| +| 3 | abc123 | 2 | "Lärare ska ha..." | [-0.34, 0.89, ...] | {"talare": "Anna", "parti": "S"}| +| 4 | abc123 | 3 | "Sammanfattning..." | [0.45, -0.12, ...] | {"talare": "Anna", "parti": "S"}| +``` + +**Vector-sök SQL**: + +```sql +-- Hitta de 5 mest liknande chunkarna till frågan "lärarutbildning" +SELECT + talk_id, + chunk_text, + metadata, + embedding <=> :query_embedding AS distance -- Cosine distance +FROM talk_embeddings +ORDER BY embedding <=> :query_embedding +LIMIT 5; +``` + +**Operator `<=>`**: pgvector's cosine distance operator (mindre värde = mer likt). + +--- + +## 🤖 AI/LLM-funktionalitet + +### LLM-wrapper: `_llm/llm.py` + +**Vad den gör**: Abstraherar anrop till OpenAI-kompatibla API:er (t.ex. vLLM). + +```python +from _llm import LLM + +llm = LLM(model='vllm') # eller 'gpt-4', 'gpt-3.5-turbo' + +messages = [ + {"role": "system", "content": "Du är en AI som hjälper med riksdagsdebatter."}, + {"role": "user", "content": "Vad säger Socialdemokraterna om skatt?"} +] + +response = llm.chat(messages=messages) +print(response) # AI-genererat svar +``` + +**Konfiguration** (miljövariabler): + +```bash +export LLM_BASE_URL=http://localhost:8000/v1 # vLLM server +export LLM_API_KEY=placeholder # API-nyckel (om krävs) +export CHAT_MODEL=gpt-3.5-turbo # Modell att använda +``` + +**Interna detaljer**: +- Använder `openai`-biblioteket under huven +- Hanterar streaming (om aktiverat) +- Kan konfigurera temperature, max_tokens, etc. + +--- + +### Chat-flöde i backend + +**Fil**: `backend/routes/chat.py` + +```python +@router.post("/api/chat") +def chat_endpoint(request: ChatRequest, db: Session = Depends(get_db)): + """ + AI-chat om riksdagsdebatter. + + Steg: + 1. Extrahera senaste användarfrågan + 2. Sök relevant kontext (vector eller keyword) + 3. Bygg prompt med systemmeddelande + kontext + fråga + 4. Anropa LLM + 5. Returnera svar + """ + + # Extrahera senaste frågan + last_message = request.messages[-1]['content'] + + # Sök kontext + if request.strategy == 'vector': + context = vector_search_for_chat(db, last_message) + elif request.strategy == 'keyword': + context = keyword_search(db, last_message) + else: # auto + context = vector_search_for_chat(db, last_message) + if not context: + context = keyword_search(db, last_message) + + # Bygg prompt + system_message = { + "role": "system", + "content": f"""Du är en AI-assistent som hjälper användare att förstå + svenska riksdagsdebatter. + + Här är relevant information från debatterna: + {context} + + Svara koncist baserat på denna information.""" + } + + messages = [system_message] + request.messages + + # Anropa LLM + llm = LLM(model='vllm') + response = llm.chat(messages=messages) + + return {"reply": response, "context_used": len(context)} +``` + +--- + +### Embeddings-generering + +**Fil**: `scripts/build_embeddings.py` + +**Vad scriptet gör**: +1. Hämtar anföranden från `talks`-tabellen (batchvis med `LIMIT` och `OFFSET`) +2. Delar upp varje anförande i chunkar +3. Genererar embeddings via Ollama +4. Sparar i `talk_embeddings` med `ON CONFLICT DO NOTHING` (idempotent) + +**Körning**: +```bash +# Processa första 200 anföranden +python -m scripts.build_embeddings --limit 200 + +# Fortsätt från rad 200 +python -m scripts.build_embeddings --limit 200 --offset 200 + +# Processa alla (varning: kan ta timmar!) +python -m scripts.build_embeddings --limit 1000000 +``` + +**Viktiga funktioner**: + +```python +def process_talk(talk: dict, db: Session): + """ + Processar ett enda anförande. + + Args: + talk: Dict med talk-data (id, anforandetext, talare, etc.) + db: Databas-session + """ + # Chunka texten + chunks = chunk_text(talk['anforandetext'], max_tokens=500) + + # För varje chunk + for idx, chunk in enumerate(chunks): + # Generera embedding + embedding = get_embedding(chunk) + + # Spara till databas + store_embedding( + db=db, + talk_id=talk['id'], + chunk_index=idx, + chunk_text=chunk, + embedding=embedding, + metadata={ + "talare": talk['talare'], + "parti": talk['parti'], + "datum": talk['datum'] + } + ) +``` + +**Progress-tracking**: +``` +Processar anföranden 0-200... +[█████████░░░░░░░░░░░] 47% (94/200) - ETA: 2m 34s +``` + +--- + +## 🔧 Utvecklingsflöde + +### Starta utvecklingsmiljö + +#### 1. Backend +```bash +# Terminal 1 +cd /home/lasse/riksdagen +uvicorn backend.app:app --reload --port 8000 + +# --reload: Startar om automatiskt vid kodändring +# --port 8000: Lyssnar på port 8000 +``` + +**Verifiera**: Gå till `http://localhost:8000/docs` (FastAPI Swagger UI) + +#### 2. Frontend (utvecklingsläge) +```bash +# Terminal 2 +cd /home/lasse/riksdagen/frontend +npm run dev + +# Startar Vite dev-server på http://localhost:5173 +# Hot module replacement (HMR) aktiverat +``` + +#### 3. Bygg för produktion +```bash +# Från projektroten +make frontend + +# Eller manuellt +cd frontend +npm install # Installera dependencies +npm run build # Bygger till frontend/dist/ + +# Ladda om nginx +sudo systemctl reload nginx +``` + +--- + +### Vanliga ändringsscenarier + +#### Scenario 1: Lägg till ny filter-parameter + +**Backend** (`backend/schemas.py`): +```python +class SearchRequest(BaseModel): + # ...existing fields... + municipality: Optional[str] = None # Ny parameter +``` + +**Backend** (`services/search_service.py`): +```python +def search_talks(db, query, filters, ...): + # ...existing code... + + if filters.get('municipality'): + sql += " AND municipality = %s" + params.append(filters['municipality']) +``` + +**Frontend** (`types.ts`): +```typescript +export interface SearchParams { + // ...existing fields... + municipality?: string; // Ny parameter +} +``` + +**Frontend** (`SearchPanel.tsx`): +```tsx +const [municipality, setMunicipality] = useState(''); + +// I JSX + + +// I handleSearch +searchMutation.mutate({ + // ...existing params... + municipality +}); +``` + +**Efter ändringar**: +1. Starta om backend (Ctrl+C, kör uvicorn igen) +2. Frontend uppdateras automatiskt (Vite HMR) + +--- + +#### Scenario 2: Lägg till ny endpoint + +**Backend** (`backend/routes/stats.py` – ny fil): +```python +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from backend.database import get_db + +router = APIRouter(prefix="/api", tags=["statistics"]) + +@router.get("/stats/party-distribution") +def get_party_distribution(db: Session = Depends(get_db)): + """ + Returnerar antal anföranden per parti. + """ + result = db.execute(text(""" + SELECT parti, COUNT(*) as count + FROM talks + GROUP BY parti + ORDER BY count DESC + """)) + + return [{"party": row[0], "count": row[1]} for row in result] +``` + +**Backend** (`backend/app.py`): +```python +# ...existing imports... +from backend.routes.stats import router as stats_router + +# ...existing code... +app.include_router(stats_router) +``` + +**Frontend** (`api.ts`): +```typescript +export async function getPartyDistribution(): Promise { + const response = await api.get('/stats/party-distribution'); + return response.data; +} +``` + +**Frontend** (ny komponent `StatsPanel.tsx`): +```typescript +import { useQuery } from '@tanstack/react-query'; +import { getPartyDistribution } from '../api'; + +function StatsPanel() { + const { data, isLoading } = useQuery({ + queryKey: ['party-distribution'], + queryFn: getPartyDistribution + }); + + if (isLoading) return
Laddar...
; + + return ( +
+

Anföranden per parti

+
    + {data?.map(item => ( +
  • + {item.party}: {item.count} +
  • + ))} +
+
+ ); +} +``` + +--- + +#### Scenario 3: Ändra databas-schema + +**1. Manuell ändring** (för utveckling): +```sql +-- Koppla till PostgreSQL +psql -U riksdagen -d riksdagen + +-- Lägg till kolumn +ALTER TABLE talks ADD COLUMN sentiment VARCHAR(20); + +-- Uppdatera info.py +# Lägg till "sentiment" i select_columns +``` + +**2. För produktion: Använd migreringsverktyg (Alembic)** + +```bash +# Installera Alembic +pip install alembic + +# Initiera +alembic init alembic + +# Skapa migration +alembic revision -m "Add sentiment column" + +# Redigera filen i alembic/versions/ +def upgrade(): + op.add_column('talks', sa.Column('sentiment', sa.String(20))) + +def downgrade(): + op.drop_column('talks', 'sentiment') + +# Kör migration +alembic upgrade head +``` + +--- + +## 🐛 Felsökning + +### Problem: Frontend visar 502 Bad Gateway + +**Orsak**: nginx kan inte nå backend. + +**Lösning**: +```bash +# 1. Kolla att backend körs +curl http://127.0.0.1:8000/healthz + +# Om inget svar: +uvicorn backend.app:app --reload --port 8000 + +# 2. Kolla nginx-konfiguration +sudo nginx -t + +# 3. Kolla nginx-loggar +sudo tail -f /var/log/nginx/error.log +``` + +--- + +### Problem: TypeScript-fel i frontend + +**Fel**: `Property 'X' does not exist on type 'Y'` + +**Orsak**: Type mismatch mellan frontend och backend. + +**Lösning**: +1. Kolla backend-schema i `backend/schemas.py` +2. Uppdatera motsvarande interface i `frontend/src/types.ts` +3. Kör `npm run build` för att verifiera + +--- + +### Problem: Inget resultat från vector-sök + +**Orsak**: Inga embeddings genererade. + +**Lösning**: +```bash +# 1. Kolla att tabellen finns +psql -U riksdagen -d riksdagen -c "SELECT COUNT(*) FROM talk_embeddings;" + +# Om 0 rader: +python -m scripts.build_embeddings --limit 100 + +# 2. Kolla att Ollama körs +curl http://192.168.1.11:11434/api/tags + +# 3. Testa embedding-generering +python -c " +from backend.services.embedding import get_embedding +emb = get_embedding('test') +print(len(emb)) # Ska ge 3072 +" +``` + +--- + +### Problem: Chat-svar är irrelevanta + +**Orsak**: Dålig kontext från sök. + +**Debug**: +```python +# I backend/routes/chat.py, lägg till logging +import logging +logger = logging.getLogger(__name__) + +# I chat_endpoint +logger.info(f"Context found: {len(context)} characters") +logger.info(f"First 200 chars: {context[:200]}") + +# Kör backend med log-level DEBUG +uvicorn backend.app:app --reload --log-level debug +``` + +**Lösning**: +1. Öka `top_k` i vector-sök (fler resultat) +2. Förbättra chunkning-strategi (överlappande chunkar) +3. Justera prompt i system-message + +--- + +### Problem: Långsam sökning + +**Orsak**: Saknade index. + +**Lösning**: +```sql +-- Kolla existerande index +SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'talks'; + +-- Lägg till saknade index +CREATE INDEX talks_text_trgm_idx ON talks USING gin(anforandetext gin_trgm_ops); +CREATE INDEX talks_year_idx ON talks(year); +CREATE INDEX talks_parti_idx ON talks(parti); + +-- För vector-sök +CREATE INDEX talk_embeddings_vector_idx ON talk_embeddings +USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); +``` + +**Optimering**: +```python +# I services/search_service.py +# Begränsa kolumner som returneras +select_columns = ["id", "datum", "talare", "parti", "snippet"] # Inte hela anforandetext + +# Använd paginering +LIMIT 50 OFFSET 0 # Hämta bara 50 rader åt gången +``` + +--- + +## 📝 Sammanfattning: Viktiga filer och deras roller + +| Fil | Vad den gör | +|-----|-------------| +| `backend/app.py` | Root för FastAPI-app, registrerar alla routes | +| `backend/database.py` | Databaskoppling, session-factory, aktiverar pgvector | +| `backend/routes/search.py` | Endpoint för nyckelordssökning | +| `backend/routes/chat.py` | Endpoints för AI-chat och vector-sök | +| `backend/services/search_service.py` | SQL-frågor för sökning, snippet-generering | +| `backend/services/vector_store.py` | pgvector-operationer, likhetssökning | +| `backend/services/embedding.py` | Chunkning, embedding-generering via Ollama | +| `frontend/src/App.tsx` | Root-komponent, mode-switching (sök/chat/vector) | +| `frontend/src/api.ts` | Axios-baserad API-klient | +| `frontend/src/components/SearchPanel.tsx` | Sökformulär med filter | +| `frontend/src/components/ChatPanel.tsx` | Chat-gränssnitt | +| `frontend/src/components/ResultsTable.tsx` | Tabellvisning av resultat | +| `scripts/build_embeddings.py` | Genererar embeddings för alla anföranden | +| `_llm/llm.py` | Wrapper för OpenAI-kompatibla LLM-anrop | +| `info.py` | Konstanter (partier, färger, kolumner) | +| `config.py` | Databas-URL och konfiguration | + +--- + +## 🚀 Nästa steg: Förslag på förbättringar + +1. **Streaming för chat**: Visa AI-svaret ord för ord (WebSocket eller Server-Sent Events) +2. **Caching**: Spara frekventa frågor i Redis för snabbare svar +3. **Feedback-loop**: Använd användarfeedback för att förbättra sökresultat +4. **Avancerad filtrering**: Datum-intervall, flera partier samtidigt +5. **Export-funktion**: Ladda ner resultat som CSV/PDF +6. **Visualiseringar**: Diagram för partier, tidslinjer, sentiment-analys + +--- + +## 🆘 Hjälp och support + +- **Backend-loggar**: `uvicorn backend.app:app --log-level debug` +- **Frontend-konsol**: Öppna DevTools (F12) i webbläsaren +- **Databas-frågor**: `psql -U riksdagen -d riksdagen` +- **API-dokumentation**: `http://localhost:8000/docs` (när backend körs) + +Lycka till med utvecklingen! 🎉