|
|
# 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! 🎉
|
|
|
|