Compare commits

...

5 Commits

Author SHA1 Message Date
Lasse Server 3ba8c3340a Made a new readme 2 months ago
Lasse Server 54db0e339b Implement clean_talk function and main script for processing talks 2 months ago
Lasse Server bd922c498f Add routing and talk detail view; refactor search view and results table 2 months ago
Lasse Server 39792b5dbe Update package dependencies and add react-router-dom 2 months ago
Lasse Server 665abd4dfb Refactor search response structure and add get_talk endpoint 2 months ago
  1. 1279
      README.md
  2. 138
      backend/app.py
  3. 4
      backend/schemas.py
  4. 1
      backend/services/search.py
  5. 54
      frontend/package-lock.json
  6. 11
      frontend/package.json
  7. 100
      frontend/src/App.tsx
  8. 16
      frontend/src/api.ts
  9. 108
      frontend/src/components/ResultsTable.tsx
  10. 212
      frontend/src/components/TalkView.tsx
  11. 223
      frontend/src/styles.css
  12. 2
      frontend/src/types.ts
  13. 60
      scripts/clean_talks.py

File diff suppressed because it is too large Load Diff

@ -6,6 +6,7 @@ from datetime import datetime
import httpx
from fastapi import Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from arango_client import arango
from info import debate_types, explainer, limit_warning, party_colors
from .schemas import (
@ -50,36 +51,28 @@ def meta():
@app.post("/api/search", response_model=SearchResponse)
def search(payload: SearchRequest):
print('PAYLOAD IN APP', payload)
results, stats, limit_reached = search_service.search(
payload, include_snippets=payload.include_snippets
)
print(f'Search service returned {len(results)} results')
# Log first result to see structure
if results:
print('First result structure:', results[0].keys() if isinstance(results[0], dict) else 'not a dict')
print('First result _id:', results[0].get('_id') if isinstance(results[0], dict) else 'N/A')
# Try to convert results to TalkHit objects
hits = []
for idx, hit in enumerate(results):
try:
talk_hit = TalkHit(**hit)
hits.append(talk_hit)
# Serialize using alias so 'id' is sent to frontend, not '_id'
hit_dict = talk_hit.dict(by_alias=True)
hits.append(hit_dict)
except Exception as e:
print(f'Error converting result {idx} to TalkHit: {e}')
print(f'Problematic result: {hit}')
print(f"Error converting result {idx} to TalkHit: {e}")
print(f"Problematic result: {hit}")
# Continue with other results instead of failing completely
continue
print(f'Successfully converted {len(hits)} results to TalkHit objects')
return SearchResponse(
results=hits,
stats=stats,
active_filters={
return {
"results": hits,
"stats": stats,
"active_filters": {
"parties": payload.parties,
"people": payload.people,
"debates": payload.debates,
@ -88,8 +81,8 @@ def search(payload: SearchRequest):
"speaker_ids": payload.speaker_ids,
"speaker": payload.speaker,
},
limit_reached=limit_reached,
)
"limit_reached": limit_reached,
}
@app.post("/api/chat", response_model=ChatResponse)
@ -119,3 +112,106 @@ def chat(payload: ChatRequest) -> ChatResponse:
return ChatResponse(answer=chat_result["answer"], sources=chat_result["sources"])
@app.get("/api/talk/{talk_id}")
async def get_talk(talk_id: str) -> dict:
"""
Fetch a single talk document by its ID from the 'talks' collection.
This endpoint accepts either:
- A full _id like "talks/H40911"
- Just the _key like "H40911" (will be prefixed with "talks/")
The document is joined with the corresponding person from the 'people' collection
using the intressent_id field.
The response also includes lightweight navigation data (previous/next speeches)
for the same debate when ordering information is available.
Args:
talk_id (str): The talk ID (either full _id or just _key)
Returns:
dict: The talk document with person information merged in
Raises:
HTTPException: 404 if talk not found
"""
# If the ID doesn't contain a slash, assume it's just the _key and prefix with collection
if "/" not in talk_id:
full_id = f"talks/{talk_id}"
else:
full_id = talk_id
# AQL query to fetch the talk and join with person data
query = """
LET doc_full = DOCUMENT(@talk_id)
FILTER doc_full != null
/* Only keep the relevant fields from the talk */
LET doc = KEEP(
doc_full,
[
"anforandetext",
"talare",
"parti",
"datum",
"kammaraktivitet",
"avsnittsrubrik",
"titel",
"anforande_nummer",
"replik",
"url_session",
"url_audio"
]
)
/* Fetch person only if intressent_id exists */
LET person_full = doc_full.intressent_id
? DOCUMENT(CONCAT("people/", doc_full.intressent_id))
: null
LET person = person_full
? KEEP(person_full, ["bild_url_192", "tilltalsnamn", "efternamn", "valkrets", "status"])
: null
/* Interpret anforande_nummer as number */
LET num = IS_NUMBER(TO_NUMBER(doc.anforande_nummer)) ? TO_NUMBER(doc.anforande_nummer) : null
LET previous = num != null
? FIRST(
FOR t IN talks
FILTER t.datum == doc.datum
AND t.kammaraktivitet == doc.kammaraktivitet
AND IS_NUMBER(TO_NUMBER(t.anforande_nummer))
AND TO_NUMBER(t.anforande_nummer) == num - 1
RETURN t._id
)
: null
LET next = num != null
? FIRST(
FOR t IN talks
FILTER t.datum == doc.datum
AND t.kammaraktivitet == doc.kammaraktivitet
AND IS_NUMBER(TO_NUMBER(t.anforande_nummer))
AND TO_NUMBER(t.anforande_nummer) == num + 1
RETURN t._id
)
: null
RETURN MERGE(doc, {
person: person,
navigation: {
previous: previous,
next: next
}
})
"""
results = arango.execute_aql(query, bind_vars={"talk_id": full_id})
if not results or results[0] is None:
raise HTTPException(status_code=404, detail=f"Talk not found: {talk_id}")
return results[0]

@ -33,7 +33,7 @@ class SearchRequest(SearchFilters):
class TalkHit(BaseModel):
_id: str # Changed from expecting specific format - just accept any string
id: str = Field(..., alias="_id") # Use 'id' as field name, alias to '_id'
text: str
snippet: Optional[str] = None # Add default to make validation more forgiving
snippet_long: Optional[str] = None
@ -51,6 +51,8 @@ class TalkHit(BaseModel):
class Config:
# Allow extra fields from the database that we don't explicitly define
extra = "ignore"
validate_by_name = True
allow_population_by_alias = True
class AggregatedStats(BaseModel):

@ -649,6 +649,7 @@ class SearchService:
results.append(
{
"_id": doc.get("_id"),
# "id": doc.get("_id"), # Optional: add for debugging
"text": text,
"snippet": snippet,
"snippet_long": snippet_long,

@ -14,6 +14,7 @@
"marked": "^12.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^7.9.4",
"recharts": "^2.8.0"
},
"devDependencies": {
@ -1653,6 +1654,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -3271,6 +3281,44 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.9.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.4.tgz",
"integrity": "sha512-SD3G8HKviFHg9xj7dNODUKDFgpG4xqD5nhyd0mYoB5iISepuZAvzSr8ywxgxKJ52yRzf/HWtVHc9AWwoTbljvA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.9.4",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.4.tgz",
"integrity": "sha512-f30P6bIkmYvnHHa5Gcu65deIXoA2+r3Eb6PJIAddvsT9aGlchMatJ51GgpU470aSqRRbFX22T70yQNUGuW3DfA==",
"license": "MIT",
"dependencies": {
"react-router": "7.9.4"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
@ -3435,6 +3483,12 @@
"node": ">=10"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

@ -13,13 +13,15 @@
"dependencies": {
"@tanstack/react-query": "^5.51.0",
"axios": "^1.6.7",
"dompurify": "^3.1.6",
"marked": "^12.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"recharts": "^2.8.0",
"dompurify": "^3.1.6",
"marked": "^12.0.2"
"react-router-dom": "^7.9.4",
"recharts": "^2.8.0"
},
"devDependencies": {
"@types/dompurify": "^3.0.5",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@typescript-eslint/eslint-plugin": "^7.2.0",
@ -28,7 +30,6 @@
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"typescript": "^5.3.3",
"vite": "^4.5.3",
"@types/dompurify": "^3.0.5"
"vite": "^4.5.3"
}
}

@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { useQuery, useMutation } from "@tanstack/react-query"; // Hooks for data fetching & mutations
import { fetchMeta, searchTalks, sendFeedback, setSessionId } from "./api"; // API functions
import type { SearchFilters, SearchRequest, TalkHit } from "./types";
@ -8,11 +9,13 @@ import { ResultsTable } from "./components/ResultsTable"; // Table of result
import { ChatPanel, type ChatPanelHandle, INITIAL_ASSISTANT_MESSAGE } from "./components/ChatPanel";
import type { ChatMessage, ChatTurn } from "./types";
import MentionTextarea from "./components/MentionTextarea"; // Ensure import for ref type
import { TalkView } from "./components/TalkView";
const DEFAULT_FILTERS: SearchFilters = {
parties: [], people: [], debates: [], from_year: undefined, to_year: undefined,
};
const PAGE_SIZE = 25; // Number of results to show per page
const SEARCH_STATE_KEY = "riksdagen-search-state";
/**
* Remove all @mentions from a query string.
@ -43,7 +46,7 @@ function stripMentions(query: string): string {
return result;
}
function App() {
function SearchView() {
// --- UI state ---
const [mode, setMode] = useState<"search" | "chat">("search");
const [query, setQuery] = useState(""); // Current search query text
@ -52,6 +55,9 @@ function App() {
// Change from single speakerId to array of speakerIds
const [speakerIds, setSpeakerIds] = useState<string[]>([]); // Store selected speaker IDs as a list
const [results, setResults] = useState<TalkHit[]>([]); // Holds search hits
const [hasHydratedSearch, setHasHydratedSearch] = useState(false);
// Tracks whether we are currently restoring persisted search state so we can skip side effects.
const isRestoringStateRef = useRef<boolean>(true);
// --- Pagination and sorting ---
const [sortMode, setSortMode] = useState<"relevance" | "date">("relevance");
@ -234,7 +240,75 @@ function App() {
};
useEffect(() => {
// Any filter change should restart pagination so the user always sees page 1 of the refined results.
// Restore the latest search state (query, filters, results, etc.) when the list view mounts again.
if (typeof window === "undefined") {
isRestoringStateRef.current = false;
setHasHydratedSearch(true);
return;
}
const stored = window.sessionStorage.getItem(SEARCH_STATE_KEY);
if (!stored) {
isRestoringStateRef.current = false;
setHasHydratedSearch(true);
return;
}
try {
const parsed = JSON.parse(stored) as {
query?: string;
filters?: SearchFilters;
results?: TalkHit[];
sortMode?: "relevance" | "date";
visibleCount?: number;
hasSearched?: boolean;
lastError?: string | null;
speaker?: string | null;
speakerIds?: string[];
};
if (typeof parsed.query === "string") setQuery(parsed.query);
if (parsed.filters) {
setFilters({
parties: parsed.filters.parties ?? [],
people: parsed.filters.people ?? [],
debates: parsed.filters.debates ?? [],
from_year: parsed.filters.from_year ?? undefined,
to_year: parsed.filters.to_year ?? undefined,
});
}
if (Array.isArray(parsed.results)) setResults(parsed.results);
if (parsed.sortMode === "date" || parsed.sortMode === "relevance") setSortMode(parsed.sortMode);
if (typeof parsed.visibleCount === "number") setVisibleCount(parsed.visibleCount);
if ("hasSearched" in parsed) setHasSearched(Boolean(parsed.hasSearched));
if ("lastError" in parsed) setLastError(parsed.lastError ?? null);
setSpeaker(parsed.speaker ?? null);
setSpeakerIds(Array.isArray(parsed.speakerIds) ? parsed.speakerIds : []);
} catch (error) {
console.error("Failed to restore search state from session storage:", error);
window.sessionStorage.removeItem(SEARCH_STATE_KEY);
} finally {
isRestoringStateRef.current = false;
setHasHydratedSearch(true);
}
}, []);
useEffect(() => {
// Persist the current search state so it is available when returning from the detail view.
if (!hasHydratedSearch || isRestoringStateRef.current || typeof window === "undefined") return;
const persistableState = {
query,
filters,
results,
sortMode,
visibleCount,
hasSearched,
lastError,
speaker,
speakerIds,
};
window.sessionStorage.setItem(SEARCH_STATE_KEY, JSON.stringify(persistableState));
}, [query, filters, results, sortMode, visibleCount, hasSearched, lastError, speaker, speakerIds, hasHydratedSearch]);
useEffect(() => {
// Any filter change while the user is interacting should reset pagination back to the first page.
if (isRestoringStateRef.current) return;
setVisibleCount(PAGE_SIZE);
}, [filters, speaker, speakerIds]); // ← ADD speakerIds to dependencies
@ -249,13 +323,11 @@ function App() {
}, []);
return (
<div className="app">
{/* Page header */}
<>
<header className="page-header">
<h1>Vad säger de i Riksdagen?</h1>
</header>
{/* Main content: search form + results */}
<main className="content">
<SearchPanel
meta={meta.data}
@ -266,8 +338,6 @@ function App() {
onSubmit={() => {
// Strip @mentions from query before sending to backend
const cleanQuery = stripMentions(query);
console.log('Original query:', query);
console.log('Cleaned query:', cleanQuery);
searchMutation.mutate({
q: cleanQuery,
...filters,
@ -279,7 +349,6 @@ function App() {
speakerSuggestions={speakerSuggestions}
onSelectSpeaker={(name, selectedSpeakerId) => {
// Update speaker name and speakerIds array
console.log('Selected speaker:', name, 'with ID:', selectedSpeakerId);
setSpeaker(name);
if (selectedSpeakerId) {
setSpeakerIds([selectedSpeakerId]);
@ -363,7 +432,20 @@ function App() {
/>
)}
</main>
</div>
</>
);
}
function App() {
return (
<BrowserRouter>
<div className="app">
<Routes>
<Route path="/" element={<SearchView />} />
<Route path="/talk/:id" element={<TalkView />} />
</Routes>
</div>
</BrowserRouter>
);
}

@ -78,3 +78,19 @@ export async function sendChat(
focus_ids: data.focus_ids ?? [],
};
}
/**
* Fetches a single talk by its ID, including person information.
*
* @param id - The talk ID (e.g., "H40911")
* @returns Promise containing the talk data with person info
*/
export async function fetchTalk(id: string): Promise<any> {
const response = await fetch(`/api/talk/${encodeURIComponent(id)}`, {
headers: getSessionHeaders(),
});
if (!response.ok) {
throw new Error(`Failed to fetch talk: ${response.statusText}`);
}
return response.json();
}

@ -1,3 +1,4 @@
import { Link, useNavigate } from "react-router-dom";
import type { TalkHit } from "../types";
type Props = {
@ -8,22 +9,54 @@ type Props = {
};
export function ResultsTable({ results, onLoadMore, nextBatchSize, compact }: Props) {
return (
<section className={`results-table${compact ? " results-table--compact" : ""}`}>
<table>
<thead>
<tr>
<th>Datum</th>
<th>Talare</th>
<th>Parti</th>
<th>Debattyp</th>
<th>Utdrag</th>
<th>Länkar</th>
</tr>
</thead>
<tbody>
{results.map((hit) => (
<tr key={hit._id}>
const navigate = useNavigate();
return (
<section className={`results-table${compact ? " results-table--compact" : ""}`}>
<table>
<thead>
<tr>
<th>Datum</th>
<th>Talare</th>
<th>Parti</th>
<th>Debattyp</th>
<th>Utdrag</th>
<th>Länkar</th>
</tr>
</thead>
<tbody>
{results.map((hit) => {
// Always use _id, and strip "talks/" prefix for routing
const talkKey = hit._id?.startsWith("talks/") ? hit._id.slice(6) : hit._id;
if (!talkKey) {
console.warn("Result hit missing _id:", hit);
}
// Handler for row click - navigates to talk page
const handleRowClick = () => {
navigate(`/talk/${talkKey}`);
};
// Handler to stop propagation for nested interactive elements
const stopPropagation = (e: React.MouseEvent) => {
e.stopPropagation();
};
return (
<tr
key={hit._id}
className="results-table__row"
onClick={handleRowClick}
role="button"
tabIndex={0}
onKeyDown={(e) => {
// Allow keyboard navigation with Enter or Space
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleRowClick();
}
}}
>
<td>{hit.date}</td>
<td>{hit.speaker}</td>
<td>
@ -35,7 +68,8 @@ export function ResultsTable({ results, onLoadMore, nextBatchSize, compact }: Pr
<td>
<div className="snippet">
<p>{hit.snippet}</p>
<details>
{/* Stop propagation so details toggle doesn't trigger row click */}
<details onClick={stopPropagation}>
<summary>Längre utdrag</summary>
<pre className="snippet-long">
{hit.snippet_long
@ -46,28 +80,40 @@ export function ResultsTable({ results, onLoadMore, nextBatchSize, compact }: Pr
</div>
</td>
<td>
{/* Stop propagation so external links work independently */}
{hit.url_session && (
<a href={hit.url_session} target="_blank" rel="noreferrer">
<a
href={hit.url_session}
target="_blank"
rel="noreferrer"
onClick={stopPropagation}
>
Webb-TV
</a>
)}
{hit.url_audio && (
<a href={hit.url_audio} target="_blank" rel="noreferrer">
<a
href={hit.url_audio}
target="_blank"
rel="noreferrer"
onClick={stopPropagation}
>
Ljud
</a>
)}
</td>
</tr>
))}
</tbody>
</table>
{onLoadMore && nextBatchSize && nextBatchSize > 0 && (
<footer className="results-table__footer">
<button type="button" className="primary" onClick={onLoadMore}>
Visa {nextBatchSize} fler
</button>
</footer>
)}
</section>
);
);
})}
</tbody>
</table>
{onLoadMore && nextBatchSize && nextBatchSize > 0 && (
<footer className="results-table__footer">
<button type="button" className="primary" onClick={onLoadMore}>
Visa {nextBatchSize} fler
</button>
</footer>
)}
</section>
);
}

@ -0,0 +1,212 @@
import { useQuery } from "@tanstack/react-query";
import { useParams, Link, useNavigate } from "react-router-dom";
import { fetchTalk } from "../api";
/**
* TalkView component displays a single talk with full details.
*
* It shows:
* - Speaker information with photo
* - Talk metadata (date, debate type, etc.)
* - Full text of the speech
* - Links to related resources
*/
export function TalkView() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: talk, isLoading, error } = useQuery({
queryKey: ["talk", id],
queryFn: () => fetchTalk(id!),
enabled: !!id,
});
if (isLoading) {
return (
<div className="talk-view">
<div className="panel">
<p>Laddar anförande...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="talk-view">
<div className="panel error-banner">
Kunde inte ladda anförande: {error.message}
</div>
</div>
);
}
if (!talk) {
return (
<div className="talk-view">
<div className="panel">
<p>Anförande hittades inte.</p>
</div>
</div>
);
}
// Fix image URLs from http to https
const imageUrl = talk.person?.bild_url_192?.replace('http://', 'https://');
const previousTalk = talk.navigation?.previous ?? null;
const nextTalk = talk.navigation?.next ?? null;
// Normalise database IDs so router links stay consistent.
const normalizeTalkId = (rawId?: string | null): string | null => {
if (!rawId) {
return null;
}
// Handle both object with _id property and plain string
const idString = typeof rawId === 'object' && rawId !== null && '_id' in rawId
? (rawId as any)._id
: String(rawId);
const result = idString.startsWith("talks/") ? idString.slice(6) : idString;
return result;
};
const previousId = normalizeTalkId(previousTalk);
const nextId = normalizeTalkId(nextTalk);
// Use browser history when possible so the search page restores prior state.
const handleBackClick = () => {
navigate("/");
};
return (
<div className="talk-view">
{/* Navigation row: previous, back, next */}
<div className="talk-view__navRow">
{/* Left: Föregående tal */}
<div className="talk-view__navCell talk-view__navCell--left talk-view__navCell--minwidth">
{previousId && previousTalk ? (
<Link to={`/talk/${previousId}`} className="secondary-button talk-view__navButton">
{/* Use fontSize 1em for arrow so button height matches others */}
<span aria-hidden="true" style={{ fontSize: "1em", marginRight: "0.3em" }}></span>
Föregående i protokollet
</Link>
) : (
<span className="secondary-button talk-view__navButton" style={{ visibility: "hidden" }}>&nbsp;</span>
)}
</div>
{/* Center: Tillbaka till sökresultat */}
<div className="talk-view__navCell talk-view__navCell--center talk-view__navCell--minwidth">
<button
type="button"
className="secondary-button talk-view__navButton"
onClick={handleBackClick}
>
Tillbaka till sökresultat
</button>
</div>
{/* Right: Nästa tal */}
<div className="talk-view__navCell talk-view__navCell--right talk-view__navCell--minwidth">
{nextId && nextTalk ? (
<Link to={`/talk/${nextId}`} className="secondary-button talk-view__navButton">
Nästa i protokollet
{/* Use fontSize 1em for arrow so button height matches others */}
<span aria-hidden="true" style={{ fontSize: "1em", marginLeft: "0.3em" }}></span>
</Link>
) : (
<span className="secondary-button talk-view__navButton" style={{ visibility: "hidden" }}>&nbsp;</span>
)}
</div>
</div>
{/* Speaker card */}
<div className="panel talk-view__speaker">
{imageUrl && (
<img
src={imageUrl}
alt={talk.talare}
className="talk-view__speaker-photo"
/>
)}
<div className="talk-view__speaker-info">
<div className="talk-view__speaker-row">
<h1>{talk.talare}</h1>
<span className="party-chip" data-party={talk.parti ?? ""}>
{talk.parti}
</span>
</div>
<div className="talk-view__speaker-meta">
{/* Show valkrets if available */}
{talk.person?.valkrets && (
<span className="talk-view__speaker-detail">
{talk.person.valkrets}
</span>
)}
{/* Show status if available */}
{talk.person?.status && (
<span className="talk-view__speaker-detail">
{talk.person.status}
</span>
)}
</div>
</div>
</div>
{/* Talk metadata */}
<div className="panel talk-view__metadata">
<dl className="talk-view__meta-grid">
<dt>Datum</dt>
<dd>{talk.datum}</dd>
<dt>Debattyp</dt>
<dd>{talk.kammaraktivitet}</dd>
<dt>Rubrik</dt>
<dd>{talk.avsnittsrubrik}</dd>
{talk.titel && (
<>
<dt>Protokoll</dt>
<dd>{talk.titel}</dd>
</>
)}
</dl>
</div>
{/* Talk text */}
<div className="panel talk-view__text">
<div className="talk-view__content">
{talk.anforandetext}
</div>
</div>
{/* Links */}
{(talk.url_session || talk.url_audio) && (
<div className="panel talk-view__links">
<h3>Länkar</h3>
<div className="talk-view__link-group">
{talk.url_session && (
<a
href={talk.url_session}
target="_blank"
rel="noreferrer"
className="primary"
>
Webb-TV
</a>
)}
{talk.url_audio && (
<a
href={talk.url_audio}
target="_blank"
rel="noreferrer"
className="primary"
>
Ljud
</a>
)}
</div>
</div>
)}
</div>
);
}

@ -1062,6 +1062,11 @@ textarea {
opacity: 0.7; /* slightly faded for clarity */
}
/* Ensure search input text is always dark and readable */
.search-panel__input {
color: #222 !important; /* Use !important to override unwanted styles */
}
/* Display area for MentionInput (shows plain text or placeholder) */
.mention-input-display {
width: 100%;
@ -1084,8 +1089,8 @@ textarea {
top: 0;
left: 0;
background: transparent;
/* Show text as transparent only when there is a value, otherwise show normal color for placeholder */
color: transparent;
color: #222;
caret-color: black;
padding: 8px 12px;
font-size: 16px;
@ -1100,3 +1105,217 @@ textarea {
font-weight: 400;
opacity: 0.7;
}
/* Talk View Styles */
.talk-view {
max-width: 900px;
margin: 0 auto;
padding: 2rem 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.talk-view__nav {
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.talk-view__nav--vertical {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.talk-view__navBackRow {
width: 100%;
display: flex;
justify-content: center;
}
.talk-view__navActions {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.5rem;
}
.talk-view__navActionsSplit {
display: flex;
justify-content: space-between;
width: 100%;
gap: 0.5rem;
}
.talk-view__navButton {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--text);
font-weight: 600;
text-decoration: none;
font-size: 0.95rem; /* Explicit font size for consistency across button/link */
}
.talk-view__navButton:hover {
color: var(--primary);
text-decoration: none;
}
.talk-view__speaker {
display: flex;
align-items: center;
gap: 1.5rem;
}
.talk-view__speaker-photo {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
border: 3px solid var(--panel-border);
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.1);
}
.talk-view__speaker-info {
width: 100%; /* Make the info container fill the panel width */
display: flex;
flex-direction: column;
gap: 0.75rem;
/* Remove flex: 1 so it doesn't just fill remaining space */
}
.talk-view__speaker-row {
display: flex;
justify-content: center; /* Center speaker name and party-chip horizontally */
align-items: center;
gap: 1rem;
}
.talk-view__speaker-meta {
display: flex;
justify-content: center; /* Center meta info horizontally */
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.talk-view__metadata h2,
.talk-view__text h2,
.talk-view__links h3 {
margin: 0 0 1rem 0;
font-size: 1.4rem;
color: var(--text);
}
.talk-view__meta-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.75rem 1.5rem;
margin: 0;
}
.talk-view__meta-grid dt {
font-weight: 600;
color: var(--muted);
}
.talk-view__meta-grid dd {
margin: 0;
color: var(--text);
}
.talk-view__content {
line-height: 1.7;
color: var(--text);
font-size: 1.05rem;
}
.talk-view__link-group {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
/* Make table rows look clickable */
.results-table__row {
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.results-table__row:hover {
transform: translateY(-2px);
}
.results-table__row:hover td {
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.12);
}
.results-table__row a {
cursor: pointer; /* Ensure links still show pointer cursor */
}
.talk-view__navRow {
display: flex;
width: 100%;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.talk-view__navCell {
flex: 1 1 0;
display: flex;
justify-content: center;
}
.talk-view__navCell--left {
justify-content: flex-start;
}
.talk-view__navCell--center {
justify-content: center;
}
.talk-view__navCell--right {
justify-content: flex-end;
}
.talk-view__navCell--minwidth {
min-width: 120px; /* Ensures each cell has enough space for a button */
}
@media (max-width: 768px) {
.talk-view {
padding: 1.5rem 1rem;
}
.talk-view__speaker {
flex-direction: column;
align-items: flex-start;
}
.talk-view__speaker-photo {
width: 100px;
height: 100px;
}
.talk-view__speaker-info h1 {
font-size: 1.6rem;
}
.talk-view__meta-grid {
grid-template-columns: 1fr;
gap: 0.5rem;
}
.talk-view__meta-grid dt {
font-size: 0.85rem;
margin-bottom: -0.25rem;
}
}

@ -1,5 +1,5 @@
export type TalkHit = {
_id: string; // Changed from number to string - ArangoDB _id is like "talks/12345"
id: string; // ArangoDB _id, but named 'id' in TypeScript
dok_id?: string; // Made optional since it might not always be present
text: string;
snippet?: string | null;

@ -0,0 +1,60 @@
from arango_client import arango
def clean_talk(text):
import re
# Remove "STYLEREF Kantrubrik \* MERGEFORMAT" from the text
if "STYLEREF Kantrubrik * MERGEFORMAT" in text:
text = text.replace("STYLEREF Kantrubrik * MERGEFORMAT", "")
# Remove "- " from the text when there are text on both sides, eg. till- sammans (this comes from line breaks in Word)
text = re.sub(r"(?<=\S)-\s(?=\S)", "", text)
# Remove linebreaks in the middle of sentences
text = re.sub(r"(?<=[^\s.!?:;])\n(?=[a-zåäö])", " ", text)
return text
if __name__ == "__main__":
people = arango.db.aql.execute(
"FOR p IN people RETURN {'namn': CONCAT(p.tilltalsnamn, ' ', p.efternamn), '_key': p._key}"
)
people_dict = {str(p["_key"]): p["namn"] for p in people}
cursor = arango.db.aql.execute(
"""FOR t IN talks RETURN {'_id': t._id, 'anforandetext': t.anforandetext, 'avsnittsrubrik': t.avsnittsrubrik, 'parti': t.parti, 'intressent_id': t.intressent_id}""",
batch_size=100,
count=True,
)
cleaned_talks = []
n = 0
for talk in cursor:
n += 1
talk["anforandetext"] = clean_talk(talk.get("anforandetext", ""))
talk["avsnittsrubrik"] = clean_talk(talk.get("avsnittsrubrik", ""))
if talk.get("intressent_id") in people_dict:
talk["talare"] = people_dict[str(talk.get("intressent_id"))]
if talk["parti"] == "FP":
talk["parti"] = "L"
if talk["parti"] == "KDS":
talk["parti"] = "KD"
if talk["parti"] in [
"TALMANNEN",
"FÖRSTE VICE TALMANNEN",
"ANDRE VICE TALMANNEN",
"TREDJE VICE TALMANNEN",
"ÅLDERSPRESIDENTEN",
"HANS MAJESTÄT KONUNGEN",
"TJÄNSTGÖRANDE ÅLDERSPRESIDENTEN",
]:
# Make first letter uppercase and rest lowercase
talk["parti"] = talk["parti"].title()
if talk["parti"] == "":
talk["parti"] = "-"
cleaned_talks.append(talk)
if len(cleaned_talks) >= 100:
arango.db.collection("talks").update_many(cleaned_talks, silent=True)
print(
f"Processed {n} talks", end="\r"),
cleaned_talks = []
Loading…
Cancel
Save