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.
 
 
 
 
 

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
  2. Projektstruktur
  3. Backend-arkitektur
  4. Frontend-arkitektur
  5. Dataflöden
  6. Viktiga Python- och TypeScript-komponenter
  7. ArangoDB och ArangoSearch
  8. ChromaDB för vektorsök
  9. LLM-integration
  10. Utvecklingsflöde
  11. 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:

    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

Exempel:

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.

# 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:

@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.

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.

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:

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:

<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.

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:

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:

<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:

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.

// 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):

    // 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):

    // 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):

    @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):

    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:

    SELECT id, datum, talare, parti, anforandetext, ...
    FROM talks
    WHERE anforandetext ILIKE '%skola%'
    ORDER BY datum DESC
    LIMIT 50 OFFSET 0;
    
  7. Backend returnerar 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):

    onSuccess: (data) => {
      // react-query cachas svaret
      // ResultsTable uppdateras automatiskt med data.results
    }
    
  9. Rendering (ResultsTable.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.pyselect_columns):

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):

-- 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):

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:

# 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:

-- 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).

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):

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

@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:

# 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:

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

# 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)

# 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

# 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):

class SearchRequest(BaseModel):
    # ...existing fields...
    municipality: Optional[str] = None  # Ny parameter

Backend (services/search_service.py):

def search_talks(db, query, filters, ...):
    # ...existing code...
    
    if filters.get('municipality'):
        sql += " AND municipality = %s"
        params.append(filters['municipality'])

Frontend (types.ts):

export interface SearchParams {
  // ...existing fields...
  municipality?: string;  // Ny parameter
}

Frontend (SearchPanel.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):

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):

# ...existing imports...
from backend.routes.stats import router as stats_router

# ...existing code...
app.include_router(stats_router)

Frontend (api.ts):

export async function getPartyDistribution(): Promise<PartyDistribution[]> {
  const response = await api.get('/stats/party-distribution');
  return response.data;
}

Frontend (ny komponent StatsPanel.tsx):

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):

-- 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)

# 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:

# 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:

# 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:

# 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:

-- 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:

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