You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

1404 lines
42 KiB

# Riksdagen Sökplattform – Teknisk Översikt
En plattform för att utforska riksdagsdebatter och metadata. Backenden bygger på FastAPI, ArangoDB (med ArangoSearch), samt ChromaDB för vektorsök i chatten. Frontenden är en React/Vite-applikation skriven i TypeScript. LLM-funktionalitet kopplas in via `_llm`-paketet.
---
## 📋 Innehållsförteckning
1. [Systemöversikt](#systemöversikt)
2. [Projektstruktur](#projektstruktur)
3. [Backend-arkitektur](#backend-arkitektur)
4. [Frontend-arkitektur](#frontend-arkitektur)
5. [Dataflöden](#dataflöden)
6. [Viktiga Python- och TypeScript-komponenter](#viktiga-python--och-typescript-komponenter)
7. [ArangoDB och ArangoSearch](#arangodb-och-arangosearch)
8. [ChromaDB för vektorsök](#chromadb-för-vektorsök)
9. [LLM-integration](#llm-integration)
10. [Utvecklingsflöde](#utvecklingsflöde)
11. [Felsökning och tips](#felsökning-och-tips)
---
## 🏗 Systemöversikt
```
┌─────────────┐ HTTP ┌──────────────┐ Python ┌───────────────┐
│ Browser │ ─────────▶ │ FastAPI │ ───────────▶ │ ArangoDB │
│ (React) │ ◀───────── │ backend │ ◀─────────── │ (ArangoSearch) │
└─────────────┘ └──────┬───────┘ └───────────────┘
│ embeddings / metadata
┌──────────────┐
│ ChromaDB │
└──────┬───────┘
┌──────────────┐
│ _llm │ (OpenAI API-kompatibel klient)
└──────────────┘
```
- **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).
---
## 📁 Projektstruktur
```
riksdagen/
├── arango_client.py # Initierar Arango-klienten (används av backend & scripts)
├── backend/
│ ├── app.py # FastAPI-app, routerregistrering
│ ├── search.py # SearchService med ArangoSearch-frågor
│ ├── chat.py # Chat-endpoints (LLM + Chroma)
│ ├── vector.py # Hjälpfunktioner för vektorsök (omfattar Chroma-anrop)
│ └── ... # Övriga routrar, scheman och konfigurationer
├── frontend/
│ ├── src/
│ │ ├── App.tsx # Inträde, växlar mellan sök/chat-lägen
│ │ ├── api.ts # Axios-klient mot `/api/*`
│ │ ├── types.ts # TypeScript-typer för API-svar
│ │ ├── components/ # UI-komponenter (SearchPanel, ChatPanel m.fl.)
│ │ └── styles.css # Globala stilar
│ └── vite.config.ts
├── scripts/
│ ├── ingest_arguments.py # Exempel: läser in data till ArangoDB
│ └── build_embeddings.py # Skapar embeddings och för in i Chroma
├── _arango/
│ ├── _arango.py # Wrapper kring python-arango (förbättrad initiering)
│ ├── queries/ # Samling av AQL/ArangoSearch-frågor
│ └── helpers.py # Hjälpfunktioner för AQL-byggande
├── _chromadb/
│ ├── client.py # Instansierar ChromaDB-klient
│ ├── collections.py # Hanterar samlingar (persistens, insättning, sök)
│ └── embeddings.py # Hjälp för integrering mot embeddingsgenerator
├── _llm/
│ └── llm.py # LLM-wrapper (OpenAI API-kompatibelt gränssnitt)
└── README.md
```
> **Tips:** Utforska `_arango`, `_chromadb` och `_llm` för att förstå hur databaskopplingar och LLM-anrop är kapslade.
---
## 🔧 Backend-arkitektur
### `backend/app.py`
- Skapar FastAPI-instansen och exponerar endpoints under `/api`.
- Registrerar t.ex. `search_router`, `chat_router`, `meta_router`.
- Konfigurerar CORS så att frontenden kan nå backenden under utveckling och produktion.
### `backend/search.py`
- Innehåller klassen **`SearchService`**.
- `SearchService` använder `arango_client` och `_arango`-hjälpare för att köra ArangoSearch-frågor.
- Vanliga metoder (namn kan skilja något, läs filen för exakt signatur):
- `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`
- Hanterar endpointen `POST /api/chat`.
- Steg i chatthanteringen:
1. Plockar senaste användarfrågan.
2. Anropar ChromaDB (via `_chromadb`) för semantisk återhämtning.
3. Kompletterar kontext med Arango-sökresultat om relevant.
4. Bygger prompt och anropar `_llm.LLM`.
5. Returnerar AI-svaret plus metadata (t.ex. vilka dokument som användes).
- Har ofta även `POST /api/vector-search` som låter frontenden testa Chroma-resultat separat.
### `backend/vector.py` (eller motsvarande modul)
- Innehåller hjälpfunktioner för att:
- Normalisera text.
- Skicka query till ChromaDB och hämta top-k embeddings-matchningar.
- Eventuellt uppdatera/återskapa Chroma-samlingar.
---
## ⚛ Frontend-arkitektur
- **`App.tsx`**: växlar mellan sök- och chat-läge (t.ex. via `const [mode, setMode] = useState<'search' | 'chat'>('search')`).
- **`api.ts`**: axios-klient som exporterar:
- `searchTalks(params)``POST /api/search`.
- `chat(messages, strategy)``POST /api/chat`.
- `vectorSearch(query)``POST /api/vector-search`.
- `getMeta()``GET /api/meta`.
- **`components/SearchPanel.tsx`**:
- Kontrollerade inputs för fritext, år, parti m.m.
- Använder `react-query` (`useMutation/useQuery`) för att trigga backend-anrop.
- Visar resultat i `ResultsTable.tsx`.
- **`components/ChatPanel.tsx`**:
- Håller konversation i state (lista av `{ role, content }`).
- Skickar hela historiken till chat-endpointen.
- Renderar streamen av svar.
- **`types.ts`**:
- Speglar backendens Pydantic-scheman med TypeScript-interfaces (`SearchRequest`, `SearchResponse`, `TalkResult`, `ChatRequest`, etc.).
- Bra översättning mellan Python-modeller och TypeScript.
---
## 🔄 Dataflöden
### Sökning via ArangoSearch
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.
### Chatt med LLM
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.
---
## 🧩 Viktiga Python- och TypeScript-komponenter
| Fil | Funktion |
|-----|----------|
| `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
- **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 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`.
> **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.
---
## 🧠 ChromaDB för vektorsök
- `_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.
- 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.
---
## 🤖 LLM-integration
- `_llm/llm.py` är huvudgränssnittet. Grundmönster:
```python
from _llm import LLM
def generate_answer(prompt: str) -> str:
llm = LLM(model="vllm")
messages = [
{"role": "system", "content": "Du är en assistent som förklarar riksdagsdebatter sakligt."},
{"role": "user", "content": prompt},
]
response = llm.chat(messages=messages)
return response
```
- Miljövariabler (`CHAT_MODEL`, `LLM_BASE_URL`, `LLM_API_KEY`) styr målmodell och endpoint.
- Chat-endpointen täcker redan vanlig användning. För experiment: återanvänd samma mönster men bygg tydlig prompt av kontexten du hämtar.
> 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 (
<div>
{/* Knappar för att växla läge */}
<button onClick={() => setMode('search')}>Sök</button>
<button onClick={() => setMode('chat')}>Chat</button>
{/* Visar olika komponenter baserat på mode */}
{mode === 'search' && <SearchPanel />}
{mode === 'chat' && <ChatPanel />}
{mode === 'vector' && <VectorSearchPanel />}
</div>
);
}
```
**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 && <Component />` 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<SearchResponse> {
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<ChatResponse> {
const response = await api.post('/chat', { messages });
return response.data;
}
```
**TypeScript-koncept**:
- `async/await`: Hanterar asynkrona operationer (API-anrop)
- `Promise<T>`: 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<number | ''>(''); // 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
<input
type="text"
value={query}
onChange={(e) => 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 <div>Laddar...</div>;
if (results.length === 0) return <div>Inga resultat</div>;
return (
<table>
<thead>
<tr>
<th>Datum</th>
<th>Talare</th>
<th>Parti</th>
<th>Innehåll</th>
</tr>
</thead>
<tbody>
{results.map((talk) => (
<tr key={talk.id}>
<td>{talk.datum}</td>
<td>{talk.talare}</td>
<td style={{ color: talk.party_color }}>
{talk.parti}
</td>
<td>{talk.snippet}</td>
</tr>
))}
</tbody>
</table>
);
}
```
**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<ChatMessage[]>([
{ 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
<div className="chat-messages">
{messages.map((msg, idx) => (
<div key={idx} className={msg.role}>
<strong>{msg.role === 'user' ? 'Du' : 'AI'}:</strong>
<p>{msg.content}</p>
</div>
))}
</div>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSend()}
/>
<button onClick={handleSend}>Skicka</button>
```
---
#### `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 <mark>skola</mark>n..."
},
...
],
"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 => (
<tr>
<td>{talk.datum}</td>
<td>{talk.talare}</td>
<td style={{color: talk.party_color}}>{talk.parti}</td>
<td dangerouslySetInnerHTML={{__html: talk.snippet}} />
</tr>
))}
```
---
## 🗄 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
<select value={municipality} onChange={(e) => setMunicipality(e.target.value)}>
<option value="">Alla kommuner</option>
<option value="Stockholm">Stockholm</option>
{/* ...fler alternativ */}
</select>
// 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<PartyDistribution[]> {
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 <div>Laddar...</div>;
return (
<div>
<h2>Anföranden per parti</h2>
<ul>
{data?.map(item => (
<li key={item.party}>
{item.party}: {item.count}
</li>
))}
</ul>
</div>
);
}
```
---
#### 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! 🎉