···1-- use uv run python for every python command, including manage.py, scripts, temporary fixes, etc.0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
···1+You are an experienced, pragmatic software engineer. You don't over-engineer a solution when a simple one is possible.
2+Rule #1: If you want exception to ANY rule, YOU MUST STOP and get explicit permission from Jesse first. BREAKING THE LETTER OR SPIRIT OF THE RULES IS FAILURE.
3+4+## Foundational rules
5+6+- Doing it right is better than doing it fast. You are not in a rush. NEVER skip steps or take shortcuts.
7+- Tedious, systematic work is often the correct solution. Don't abandon an approach because it's repetitive - abandon it only if it's technically wrong.
8+- Honesty is a core value. If you lie, you'll be replaced.
9+- You MUST think of and address your human partner as "Jesse" at all times
10+11+## Our relationship
12+13+- We're colleagues working together as "Jesse" and "Claude" - no formal hierarchy.
14+- Don't glaze me. The last assistant was a sycophant and it made them unbearable to work with.
15+- YOU MUST speak up immediately when you don't know something or we're in over our heads
16+- YOU MUST call out bad ideas, unreasonable expectations, and mistakes - I depend on this
17+- NEVER be agreeable just to be nice - I NEED your HONEST technical judgment
18+- NEVER write the phrase "You're absolutely right!" You are not a sycophant. We're working together because I value your opinion.
19+- YOU MUST ALWAYS STOP and ask for clarification rather than making assumptions.
20+- If you're having trouble, YOU MUST STOP and ask for help, especially for tasks where human input would be valuable.
21+- When you disagree with my approach, YOU MUST push back. Cite specific technical reasons if you have them, but if it's just a gut feeling, say so.
22+- If you're uncomfortable pushing back out loud, just say "Strange things are afoot at the Circle K". I'll know what you mean
23+- You have issues with memory formation both during and between conversations. Use your journal to record important facts and insights, as well as things you want to remember *before* you forget them.
24+- You search your journal when you trying to remember or figure stuff out.
25+- We discuss architectutral decisions (framework changes, major refactoring, system design)
26+ together before implementation. Routine fixes and clear implementations don't need
27+ discussion.
28+29+30+# Proactiveness
31+32+When asked to do something, just do it - including obvious follow-up actions needed to complete the task properly.
33+ Only pause to ask for confirmation when:
34+ - Multiple valid approaches exist and the choice matters
35+ - The action would delete or significantly restructure existing code
36+ - You genuinely don't understand what's being asked
37+ - Your partner specifically asks "how should I approach X?" (answer the question, don't jump to
38+ implementation)
39+40+## Designing software
41+42+- YAGNI. The best code is no code. Don't add features we don't need right now.
43+- When it doesn't conflict with YAGNI, architect for extensibility and flexibility.
44+45+46+## Test Driven Development (TDD)
47+48+- FOR EVERY NEW FEATURE OR BUGFIX, YOU MUST follow Test Driven Development :
49+ 1. Write a failing test that correctly validates the desired functionality
50+ 2. Run the test to confirm it fails as expected
51+ 3. Write ONLY enough code to make the failing test pass
52+ 4. Run the test to confirm success
53+ 5. Refactor if needed while keeping tests green
54+55+## Writing code
56+57+- When submitting work, verify that you have FOLLOWED ALL RULES. (See Rule #1)
58+- YOU MUST make the SMALLEST reasonable changes to achieve the desired outcome.
59+- We STRONGLY prefer simple, clean, maintainable solutions over clever or complex ones. Readability and maintainability are PRIMARY CONCERNS, even at the cost of conciseness or performance.
60+- YOU MUST WORK HARD to reduce code duplication, even if the refactoring takes extra effort.
61+- YOU MUST NEVER throw away or rewrite implementations without EXPLICIT permission. If you're considering this, YOU MUST STOP and ask first.
62+- YOU MUST get Jesse's explicit approval before implementing ANY backward compatibility.
63+- YOU MUST MATCH the style and formatting of surrounding code, even if it differs from standard style guides. Consistency within a file trumps external standards.
64+- YOU MUST NOT manually change whitespace that does not affect execution or output. Otherwise, use a formatting tool.
65+- Fix broken things immediately when you find them. Don't ask permission to fix bugs.
66+67+68+69+## Naming
70+71+ - Names MUST tell what code does, not how it's implemented or its history
72+ - When changing code, never document the old behavior or the behavior change
73+ - NEVER use implementation details in names (e.g., "ZodValidator", "MCPWrapper", "JSONParser")
74+ - NEVER use temporal/historical context in names (e.g., "NewAPI", "LegacyHandler", "UnifiedTool", "ImprovedInterface", "EnhancedParser")
75+ - NEVER use pattern names unless they add clarity (e.g., prefer "Tool" over "ToolFactory")
76+77+ Good names tell a story about the domain:
78+ - `Tool` not `AbstractToolInterface`
79+ - `RemoteTool` not `MCPToolWrapper`
80+ - `Registry` not `ToolRegistryManager`
81+ - `execute()` not `executeToolWithValidation()`
82+83+## Code Comments
84+85+ - NEVER add comments explaining that something is "improved", "better", "new", "enhanced", or referencing what it used to be
86+ - NEVER add instructional comments telling developers what to do ("copy this pattern", "use this instead")
87+ - Comments should explain WHAT the code does or WHY it exists, not how it's better than something else
88+ - If you're refactoring, remove old comments - don't add new ones explaining the refactoring
89+ - YOU MUST NEVER remove code comments unless you can PROVE they are actively false. Comments are important documentation and must be preserved.
90+ - YOU MUST NEVER add comments about what used to be there or how something has changed.
91+ - YOU MUST NEVER refer to temporal context in comments (like "recently refactored" "moved") or code. Comments should be evergreen and describe the code as it is. If you name something "new" or "enhanced" or "improved", you've probably made a mistake and MUST STOP and ask me what to do.
92+ - All code files MUST start with a brief 2-line comment explaining what the file does. Each line MUST start with "ABOUTME: " to make them easily greppable.
93+94+ Examples:
95+ // BAD: This uses Zod for validation instead of manual checking
96+ // BAD: Refactored from the old validation system
97+ // BAD: Wrapper around MCP tool protocol
98+ // GOOD: Executes tools with validated arguments
99+100+ If you catch yourself writing "new", "old", "legacy", "wrapper", "unified", or implementation details in names or comments, STOP and find a better name that describes the thing's
101+ actual purpose.
102+103+## Version Control
104+105+- If the project isn't in a git repo, STOP and ask permission to initialize one.
106+- YOU MUST STOP and ask how to handle uncommitted changes or untracked files when starting work. Suggest committing existing work first.
107+- When starting work without a clear branch for the current task, YOU MUST create a WIP branch.
108+- YOU MUST TRACK All non-trivial changes in git.
109+- YOU MUST commit frequently throughout the development process, even if your high-level tasks are not yet done. Commit your journal entries.
110+- NEVER SKIP, EVADE OR DISABLE A PRE-COMMIT HOOK
111+- NEVER use `git add -A` unless you've just done a `git status` - Don't add random test files to the repo.
112+113+## Testing
114+115+- ALL TEST FAILURES ARE YOUR RESPONSIBILITY, even if they're not your fault. The Broken Windows theory is real.
116+- Never delete a test because it's failing. Instead, raise the issue with Jesse.
117+- Tests MUST comprehensively cover ALL functionality.
118+- YOU MUST NEVER write tests that "test" mocked behavior. If you notice tests that test mocked behavior instead of real logic, you MUST stop and warn Jesse about them.
119+- YOU MUST NEVER implement mocks in end to end tests. We always use real data and real APIs.
120+- YOU MUST NEVER ignore system or test output - logs and messages often contain CRITICAL information.
121+- Test output MUST BE PRISTINE TO PASS. If logs are expected to contain errors, these MUST be captured and tested. If a test is intentionally triggering an error, we *must* capture and validate that the error output is as we expect
122+123+124+## Issue tracking
125+126+- You MUST use your TodoWrite tool to keep track of what you're doing
127+- You MUST NEVER discard tasks from your TodoWrite todo list without Jesse's explicit approval
128+129+## Systematic Debugging Process
130+131+YOU MUST ALWAYS find the root cause of any issue you are debugging
132+YOU MUST NEVER fix a symptom or add a workaround instead of finding a root cause, even if it is faster or I seem like I'm in a hurry.
133+134+YOU MUST follow this debugging framework for ANY technical issue:
135+136+### Phase 1: Root Cause Investigation (BEFORE attempting fixes)
137+- **Read Error Messages Carefully**: Don't skip past errors or warnings - they often contain the exact solution
138+- **Reproduce Consistently**: Ensure you can reliably reproduce the issue before investigating
139+- **Check Recent Changes**: What changed that could have caused this? Git diff, recent commits, etc.
140+141+### Phase 2: Pattern Analysis
142+- **Find Working Examples**: Locate similar working code in the same codebase
143+- **Compare Against References**: If implementing a pattern, read the reference implementation completely
144+- **Identify Differences**: What's different between working and broken code?
145+- **Understand Dependencies**: What other components/settings does this pattern require?
146+147+### Phase 3: Hypothesis and Testing
148+1. **Form Single Hypothesis**: What do you think is the root cause? State it clearly
149+2. **Test Minimally**: Make the smallest possible change to test your hypothesis
150+3. **Verify Before Continuing**: Did your test work? If not, form new hypothesis - don't add more fixes
151+4. **When You Don't Know**: Say "I don't understand X" rather than pretending to know
152+153+### Phase 4: Implementation Rules
154+- ALWAYS have the simplest possible failing test case. If there's no test framework, it's ok to write a one-off test script.
155+- NEVER add multiple fixes at once
156+- NEVER claim to implement a pattern without reading it completely first
157+- ALWAYS test after each change
158+- IF your first fix doesn't work, STOP and re-analyze rather than adding more fixes
159+160+## Learning and Memory Management
161+162+- YOU MUST use the journal tool frequently to capture technical insights, failed approaches, and user preferences
163+- Before starting complex tasks, search the journal for relevant past experiences and lessons learned
164+- Document architectural decisions and their outcomes for future reference
165+- Track patterns in user feedback to improve collaboration over time
166+- When you notice something that should be fixed but is unrelated to your current task, document it in your journal rather than fixing it immediately
167+- Always use uv run python manage.py {shell,runserver,test,etc}
+4-2
README.md
···41. Install dependencies with `uv sync`.
52. Run `uv run python manage.py migrate` from `website/` to bootstrap the database.
63. (Optional) Import representatives via `uv run python manage.py sync_representatives --level all`.
7-4. Launch the dev server with `uv run python manage.py runserver` and visit http://localhost:8000/.
089## Architecture
10- **Frameworks**: Django 5.2 / Python 3.13 managed with `uv`. The project root holds dependency metadata; all Django code lives in `website/` (settings in `writethem/`, app logic in `letters/`).
11- **Domain models**: `letters/models.py` defines parliaments, terms, constituencies, representatives, committees, letters, signatures, identity verification, and moderation reports. Relationships reflect multi-level mandates (EU/Federal/State) and committee membership.
12- **Sync pipeline**: `RepresentativeSyncService` (in `letters/services.py`) calls the Abgeordnetenwatch v2 API to create/update parliaments, terms, electoral districts, constituencies, representatives, and committee memberships. Management command `sync_representatives` orchestrates the import.
13-- **Suggestion engine**: `ConstituencySuggestionService` analyses letter titles + postal codes to recommend representatives, tags, and similar letters. The HTMX partial `letters/partials/suggestions.html` renders the live preview used on the letter form.
014- **Identity & signatures**: `IdentityVerificationService` (stub) attaches address information to users; signature counts and verification badges are derived from the associated verification records.
15- **Presentation**: Class-based views in `letters/views.py` back the main pages (letter list/detail, creation, representative detail, user profile). Templates under `letters/templates/` share layout via partials (e.g., `letter_card.html`).
16- **Utilities**: Management commands in `letters/management/commands/` cover representative sync, taxonomy tests, and helper scripts. Tests in `letters/tests.py` exercise model behaviour, letter flows, and the suggestion service.
···41. Install dependencies with `uv sync`.
52. Run `uv run python manage.py migrate` from `website/` to bootstrap the database.
63. (Optional) Import representatives via `uv run python manage.py sync_representatives --level all`.
7+4. Download constituency boundaries: `uv run python manage.py fetch_wahlkreis_data` (required for accurate address-based matching).
8+5. Launch the dev server with `uv run python manage.py runserver` and visit http://localhost:8000/.
910## Architecture
11- **Frameworks**: Django 5.2 / Python 3.13 managed with `uv`. The project root holds dependency metadata; all Django code lives in `website/` (settings in `writethem/`, app logic in `letters/`).
12- **Domain models**: `letters/models.py` defines parliaments, terms, constituencies, representatives, committees, letters, signatures, identity verification, and moderation reports. Relationships reflect multi-level mandates (EU/Federal/State) and committee membership.
13- **Sync pipeline**: `RepresentativeSyncService` (in `letters/services.py`) calls the Abgeordnetenwatch v2 API to create/update parliaments, terms, electoral districts, constituencies, representatives, and committee memberships. Management command `sync_representatives` orchestrates the import.
14+- **Constituency matching**: `AddressGeocoder` converts full addresses to coordinates via OSM Nominatim (cached in `GeocodeCache`). `WahlkreisLocator` performs point-in-polygon lookups against official Bundestag GeoJSON boundaries. `ConstituencyLocator` integrates both with PLZ fallback. See `docs/matching-algorithm.md` for details.
15+- **Suggestion engine**: `ConstituencySuggestionService` analyses letter titles + addresses to recommend representatives, tags, and similar letters. The HTMX partial `letters/partials/suggestions.html` renders the live preview used on the letter form.
16- **Identity & signatures**: `IdentityVerificationService` (stub) attaches address information to users; signature counts and verification badges are derived from the associated verification records.
17- **Presentation**: Class-based views in `letters/views.py` back the main pages (letter list/detail, creation, representative detail, user profile). Templates under `letters/templates/` share layout via partials (e.g., `letter_card.html`).
18- **Utilities**: Management commands in `letters/management/commands/` cover representative sync, taxonomy tests, and helper scripts. Tests in `letters/tests.py` exercise model behaviour, letter flows, and the suggestion service.
···1+# Constituency Matching Algorithm
2+3+## Overview
4+5+WriteThem.eu uses a two-stage process to match users to their correct Bundestag constituency:
6+7+1. **Address Geocoding**: Convert user's address to latitude/longitude coordinates
8+2. **Point-in-Polygon Lookup**: Find which constituency polygon contains those coordinates
9+10+## Stage 1: Address Geocoding
11+12+We use OpenStreetMap's Nominatim API to convert addresses to coordinates.
13+14+### Process:
15+1. User provides: Street, Postal Code, City
16+2. System checks cache (GeocodeCache table) for previous results
17+3. If not cached, query Nominatim API with rate limiting (1 req/sec)
18+4. Cache result (success or failure) to minimize API calls
19+5. Return (latitude, longitude) or None
20+21+### Fallback:
22+If geocoding fails or user only provides postal code, fall back to PLZ prefix heuristic (maps first 2 digits to state).
23+24+## Stage 2: Point-in-Polygon Lookup
25+26+We use official Bundestag constituency boundaries (GeoJSON format) with shapely for geometric queries.
27+28+### Process:
29+1. Load GeoJSON with 299 Bundestag constituencies on startup
30+2. Create shapely Point from coordinates
31+3. Check which constituency Polygon contains the point
32+4. Look up Constituency object in database by external_id
33+5. Return Constituency or None
34+35+### Performance:
36+- GeoJSON loaded once at startup (~2MB in memory)
37+- Class-level caching prevents repeated loads
38+- Lookup typically takes 10-50ms
39+- No external API calls required
40+41+## Data Sources
42+43+- **Constituency Boundaries**: [dknx01/wahlkreissuche](https://github.com/dknx01/wahlkreissuche) (Open Data)
44+- **Geocoding**: [OpenStreetMap Nominatim](https://nominatim.openstreetmap.org/) (Open Data)
45+- **Representative Data**: [Abgeordnetenwatch API](https://www.abgeordnetenwatch.de/api)
46+47+## Accuracy
48+49+This approach provides constituency-accurate matching (exact Wahlkreis), significantly more precise than PLZ-based heuristics which only provide state-level accuracy.
50+51+### Known Limitations:
52+- Requires valid German address
53+- Dependent on OSM geocoding quality
54+- Rate limited to 1 request/second (public API)
55+56+## Implementation Details
57+58+### Services
59+60+- **AddressGeocoder** (`letters/services.py`): Handles geocoding with caching
61+- **WahlkreisLocator** (`letters/services.py`): Performs point-in-polygon matching
62+- **ConstituencyLocator** (`letters/services.py`): Integrates both services with PLZ fallback
63+64+### Database Models
65+66+- **GeocodeCache** (`letters/models.py`): Caches geocoding results to minimize API calls
67+- **Constituency** (`letters/models.py`): Stores constituency information with external_id mapping to GeoJSON
68+69+### Management Commands
70+71+- **fetch_wahlkreis_data**: Downloads official Bundestag constituency boundaries
72+- **query_wahlkreis**: Query constituency by address or postal code
73+- **query_topics**: Find matching topics for letter text
74+- **query_representatives**: Find representatives by address and/or topics
75+76+### Testing
77+78+Run the test suite:
79+```bash
80+python manage.py test letters.tests.test_address_matching
81+python manage.py test letters.tests.test_topic_mapping
82+python manage.py test letters.tests.test_constituency_suggestions
83+```
···1+# Recipient Matching Vision
2+3+## Goal
4+Ensure every letter reaches the most relevant representative by combining precise constituency mapping with topic-awareness and reliable representative metadata.
5+6+## Core Pillars
7+8+1. **Constituency Precision**
9+ - Replace postal-prefix heuristics with official boundary data:
10+ - Bundestag Wahlkreise (Bundeswahlleiter / BKG GeoJSON)
11+ - Landtag electoral districts via state open-data portals or OParl feeds
12+ - EU parliament treated as nationwide constituency
13+ - Normalise mandate modes:
14+ - Direktmandat โ voters in that Wahlkreis
15+ - Landesliste โ voters in the state
16+ - Bundes/EU list โ national constituencies
17+ - Centralise the logic in a โconstituency routerโ so each parliamentโs data source is pluggable.
18+19+2. **Topic Understanding**
20+ - Analyse title + body to classify concerns into a canonical taxonomy (reuse committee topics, extend as needed).
21+ - Infer the responsible level (EU / Bund / Land / Kommune) from topic metadata.
22+ - Keep the topic model extensible (keyword heuristics today, embeddings or classifiers tomorrow).
23+24+3. **Rich Representative Profiles**
25+ - Build a `RepresentativeProfile` table to store per-source enrichments:
26+ - Source (ABGEORDNETENWATCH, BUNDESTAG, LANDTAG_*)
27+ - Normalised fields: focus areas, biography, external links, responsiveness
28+ - Raw metadata + sync timestamps
29+ - Importers:
30+ - Abgeordnetenwatch: `/politicians/{id}` (issues, responsiveness, social links)
31+ - Bundestag: official vita JSON (`mdbId`) for biography + spokesperson roles
32+ - Landtage: state-specific data feeds (OParl, CSV, or one-off scrapers)
33+ - Profiles coexist; the merging service resolves conflicts and picks the best available data.
34+35+## Matching Pipeline
36+1. **Constituency filter**: Use the router and mandate rules to determine eligible reps.
37+2. **Topic filter**: Narrow to the inferred level and portfolio.
38+3. **Scoring**: Blend signalsโconstituency proximity, topic match (committee โ topic), activity (votes, questions), responsiveness stats, optional user preferences.
39+4. **Explanation**: Provide human-readable reasons (โDirect MP for WK 123; sits on Verkehrsausschuss; answered 90% of Abgeordnetenwatch questionsโ).
40+41+## Data Sources Reference
42+43+| Use Case | Federal | State | EU |
44+|-------------------------|-------------------------------------|---------------------------------------------|--------------------------------|
45+| Mandates & committees | Abgeordnetenwatch API | Abgeordnetenwatch, OParl, Landtag portals | EU Parliament REST API |
46+| Constituency boundaries | Bundeswahlleiter GeoJSON, BKG | Landeswahlleitungen, state GIS datasets | Whole-of-Germany (single geom) |
47+| Biography / focus | Bundestag vita JSON, Abgeordnetenwatch issues | Landtag bios (open data) | Europarl member profiles |
48+49+## Implementation Notes
50+- Expose `sync_representative_profiles` commands per source; schedule separately from mandate sync.
51+- Track `source_version`/`hash` to avoid redundant imports.
52+- View layer consumes a `RepresentativeProfileService` that aggregates focus areas, biography, links, responsiveness.
53+- Keep a roadmap for future sources (party press, DIP21 votes, Europarl โfilesโ).
54+55+## Next Steps
56+- Implement `RepresentativeProfile` model + importers for Abgeordnetenwatch and Bundestag.
57+- Integrate boundary datasets and swap the PLZ router.
58+- Wire the matching pipeline into the letter form suggestions and automated routing.
59+- Add logging/monitoring for profile freshness and matching success.
60+
···1+# Identity Verification Vision
2+3+## Objective
4+Guarantee that only real, uniquely verified individuals can sign letters and that their constituency is provenโideally by reusing identities users already hold (e.g., Verimi, BundID, bank login) rather than forcing a new verification flow.
5+6+## Core Requirements
7+- **Proof of personhood:** Ensure each signature is tied to a real individual (no throwaway accounts).
8+- **Proof of constituency:** Capture verified address (street, PLZ, city) and map it to the correct Wahlkreis / Landtag district.
9+- **Reusable identity:** Prefer providers where users can consent to sharing existing verified attributes (lower friction than video calls).
10+- **Evidence retention:** Store cryptographically signed responses or verification references so we can prove the verification later.
11+- **Expiry / refresh:** Verification should have a validity window (e.g., re-check every 6โ12 months or when user updates address).
12+13+## Recommended Providers (Germany)
14+15+### Identity Wallets (best reuse experience)
16+- **Verimi** (OAuth2/OIDC)
17+ - Users already have a Verimi wallet โ grant consent โ we receive name + address.
18+ - Supports multiple underlying methods (eID, VideoIdent, bank sources).
19+- **BundID / BundesIdent** (official government ID)
20+ - OIDC-based access to Personalausweis attributes via government portal.
21+ - Gold standard for address proof; onboarding limited to approved use cases.
22+- **yesยฎ (yes.com)**
23+ - Bank login to participating institutions; returns bank-verified identity/address via OpenID Connect.
24+ - No new verification, just consent.
25+- **Signicat Identity Platform**
26+ - Aggregator: supports Verimi, yesยฎ, BankID, eIDAS. Useful if expansion beyond Germany is planned.
27+- **Nect Ident**
28+ - After an initial automated verification, users can re-share their identity from a wallet.
29+30+### Alternative Methods
31+- **BankIdent / PSD2 providers** (WebID BankIdent, yesยฎ, Deutsche Bank BankIdent)
32+ - Users log into their bank; returns name/address. High trust, no video.
33+- **eID solutions (AUTHADA, D-Trust)**
34+ - NFC-based Personalausweis reading; some provide reusable tokens after first use.
35+- **VideoIdent (IDnow, WebID VideoIdent, POSTIDENT)**
36+ - Higher friction; use as fallback when wallet/bank options fail.
37+38+## Integration Architecture
39+1. **Abstraction layer:** Implement a `VerificationProvider` interface with methods like `start_verification(user)` and `handle_callback(payload)`.
40+2. **Provider adapters:** Build adapters for Verimi, yesยฎ, BundID, etc., each handling OAuth2/OIDC flows, token validation, and attribute extraction.
41+3. **Verification storage:** Extend `IdentityVerification` to store provider name, verification reference, timestamp, address, and provider response hash/signature.
42+4. **Constituency mapping:** After receiving address data, run it through the constituency router (GeoJSON-based once available) to attach the exact direct-mandate seat/state.
43+5. **Expiry handling:** Add `expires_at`โprompt users to re-verify when outdated or on address change.
44+6. **Audit trail:** Log provider responses; maintain a verification history per user.
45+7. **Fallback/manual path:** Offer manual verification (moderator-reviewed documents) only if all automated providers fail, clearly flagging such signatures.
46+47+## User Flow Blueprint
48+1. User chooses โVerify identityโ.
49+2. We present available providers (Verimi, yesยฎ, BundIDโฆ).
50+3. User authenticates/consents with chosen provider.
51+4. Provider redirects back / sends webhook with verification result + attributes.
52+5. We validate response, persist identity data, and map PLZ โ constituency.
53+6. Signatures now display โVerified constituentโ (and reinforce direct mandates with proof).
54+55+## Implementation Priorities
56+- Start with a wallet provider (Verimi or yesยฎ) for minimal friction.
57+- Add BundID for maximum trust where accessible.
58+- Abstract architecture so adding Landtag-specific providers later is straightforward.
59+- Ensure we can reuse the same verification across multiple letters until it expires.
60+61+## Outstanding Questions
62+- Do we need different assurance levels for general signatures vs. direct-mandate proof? (e.g., allow bank login for signing, but require eID for constituency-critical interactions?)
63+- How to handle users without access to any supported provider? (Manual override / postal verification?)
64+- Data protection & consent: store only whatโs necessary (likely name + address); ensure GDPR-compliant retention policies.
65+
···1-# Recipient Matching Vision
2-3-## Goal
4-Ensure every letter reaches the most relevant representative by combining precise constituency mapping with topic-awareness and reliable representative metadata.
5-6-## Core Pillars
7-8-1. **Constituency Precision**
9- - Replace postal-prefix heuristics with official boundary data:
10- - Bundestag Wahlkreise (Bundeswahlleiter / BKG GeoJSON)
11- - Landtag electoral districts via state open-data portals or OParl feeds
12- - EU parliament treated as nationwide constituency
13- - Normalise mandate modes:
14- - Direktmandat โ voters in that Wahlkreis
15- - Landesliste โ voters in the state
16- - Bundes/EU list โ national constituencies
17- - Centralise the logic in a โconstituency routerโ so each parliamentโs data source is pluggable.
18-19-2. **Topic Understanding**
20- - Analyse title + body to classify concerns into a canonical taxonomy (reuse committee topics, extend as needed).
21- - Infer the responsible level (EU / Bund / Land / Kommune) from topic metadata.
22- - Keep the topic model extensible (keyword heuristics today, embeddings or classifiers tomorrow).
23-24-3. **Rich Representative Profiles**
25- - Build a `RepresentativeProfile` table to store per-source enrichments:
26- - Source (ABGEORDNETENWATCH, BUNDESTAG, LANDTAG_*)
27- - Normalised fields: focus areas, biography, external links, responsiveness
28- - Raw metadata + sync timestamps
29- - Importers:
30- - Abgeordnetenwatch: `/politicians/{id}` (issues, responsiveness, social links)
31- - Bundestag: official vita JSON (`mdbId`) for biography + spokesperson roles
32- - Landtage: state-specific data feeds (OParl, CSV, or one-off scrapers)
33- - Profiles coexist; the merging service resolves conflicts and picks the best available data.
34-35-## Matching Pipeline
36-1. **Constituency filter**: Use the router and mandate rules to determine eligible reps.
37-2. **Topic filter**: Narrow to the inferred level and portfolio.
38-3. **Scoring**: Blend signalsโconstituency proximity, topic match (committee โ topic), activity (votes, questions), responsiveness stats, optional user preferences.
39-4. **Explanation**: Provide human-readable reasons (โDirect MP for WK 123; sits on Verkehrsausschuss; answered 90% of Abgeordnetenwatch questionsโ).
40-41-## Data Sources Reference
42-43-| Use Case | Federal | State | EU |
44-|-------------------------|-------------------------------------|---------------------------------------------|--------------------------------|
45-| Mandates & committees | Abgeordnetenwatch API | Abgeordnetenwatch, OParl, Landtag portals | EU Parliament REST API |
46-| Constituency boundaries | Bundeswahlleiter GeoJSON, BKG | Landeswahlleitungen, state GIS datasets | Whole-of-Germany (single geom) |
47-| Biography / focus | Bundestag vita JSON, Abgeordnetenwatch issues | Landtag bios (open data) | Europarl member profiles |
48-49-## Implementation Notes
50-- Expose `sync_representative_profiles` commands per source; schedule separately from mandate sync.
51-- Track `source_version`/`hash` to avoid redundant imports.
52-- View layer consumes a `RepresentativeProfileService` that aggregates focus areas, biography, links, responsiveness.
53-- Keep a roadmap for future sources (party press, DIP21 votes, Europarl โfilesโ).
54-55-## Next Steps
56-- Implement `RepresentativeProfile` model + importers for Abgeordnetenwatch and Bundestag.
57-- Integrate boundary datasets and swap the PLZ router.
58-- Wire the matching pipeline into the letter form suggestions and automated routing.
59-- Add logging/monitoring for profile freshness and matching success.
60-
···1-# Identity Verification Vision
2-3-## Objective
4-Guarantee that only real, uniquely verified individuals can sign letters and that their constituency is provenโideally by reusing identities users already hold (e.g., Verimi, BundID, bank login) rather than forcing a new verification flow.
5-6-## Core Requirements
7-- **Proof of personhood:** Ensure each signature is tied to a real individual (no throwaway accounts).
8-- **Proof of constituency:** Capture verified address (street, PLZ, city) and map it to the correct Wahlkreis / Landtag district.
9-- **Reusable identity:** Prefer providers where users can consent to sharing existing verified attributes (lower friction than video calls).
10-- **Evidence retention:** Store cryptographically signed responses or verification references so we can prove the verification later.
11-- **Expiry / refresh:** Verification should have a validity window (e.g., re-check every 6โ12 months or when user updates address).
12-13-## Recommended Providers (Germany)
14-15-### Identity Wallets (best reuse experience)
16-- **Verimi** (OAuth2/OIDC)
17- - Users already have a Verimi wallet โ grant consent โ we receive name + address.
18- - Supports multiple underlying methods (eID, VideoIdent, bank sources).
19-- **BundID / BundesIdent** (official government ID)
20- - OIDC-based access to Personalausweis attributes via government portal.
21- - Gold standard for address proof; onboarding limited to approved use cases.
22-- **yesยฎ (yes.com)**
23- - Bank login to participating institutions; returns bank-verified identity/address via OpenID Connect.
24- - No new verification, just consent.
25-- **Signicat Identity Platform**
26- - Aggregator: supports Verimi, yesยฎ, BankID, eIDAS. Useful if expansion beyond Germany is planned.
27-- **Nect Ident**
28- - After an initial automated verification, users can re-share their identity from a wallet.
29-30-### Alternative Methods
31-- **BankIdent / PSD2 providers** (WebID BankIdent, yesยฎ, Deutsche Bank BankIdent)
32- - Users log into their bank; returns name/address. High trust, no video.
33-- **eID solutions (AUTHADA, D-Trust)**
34- - NFC-based Personalausweis reading; some provide reusable tokens after first use.
35-- **VideoIdent (IDnow, WebID VideoIdent, POSTIDENT)**
36- - Higher friction; use as fallback when wallet/bank options fail.
37-38-## Integration Architecture
39-1. **Abstraction layer:** Implement a `VerificationProvider` interface with methods like `start_verification(user)` and `handle_callback(payload)`.
40-2. **Provider adapters:** Build adapters for Verimi, yesยฎ, BundID, etc., each handling OAuth2/OIDC flows, token validation, and attribute extraction.
41-3. **Verification storage:** Extend `IdentityVerification` to store provider name, verification reference, timestamp, address, and provider response hash/signature.
42-4. **Constituency mapping:** After receiving address data, run it through the constituency router (GeoJSON-based once available) to attach the exact direct-mandate seat/state.
43-5. **Expiry handling:** Add `expires_at`โprompt users to re-verify when outdated or on address change.
44-6. **Audit trail:** Log provider responses; maintain a verification history per user.
45-7. **Fallback/manual path:** Offer manual verification (moderator-reviewed documents) only if all automated providers fail, clearly flagging such signatures.
46-47-## User Flow Blueprint
48-1. User chooses โVerify identityโ.
49-2. We present available providers (Verimi, yesยฎ, BundIDโฆ).
50-3. User authenticates/consents with chosen provider.
51-4. Provider redirects back / sends webhook with verification result + attributes.
52-5. We validate response, persist identity data, and map PLZ โ constituency.
53-6. Signatures now display โVerified constituentโ (and reinforce direct mandates with proof).
54-55-## Implementation Priorities
56-- Start with a wallet provider (Verimi or yesยฎ) for minimal friction.
57-- Add BundID for maximum trust where accessible.
58-- Abstract architecture so adding Landtag-specific providers later is straightforward.
59-- Ensure we can reuse the same verification across multiple letters until it expires.
60-61-## Outstanding Questions
62-- Do we need different assurance levels for general signatures vs. direct-mandate proof? (e.g., allow bank login for signing, but require eID for constituency-critical interactions?)
63-- How to handle users without access to any supported provider? (Manual override / postal verification?)
64-- Data protection & consent: store only whatโs necessary (likely name + address); ensure GDPR-compliant retention policies.
65-
···1-"""
2-Management command to test topic-to-constituency mapping.
3-4-Provides examples of how the topic suggestion service works.
5-"""
6-7-from django.core.management.base import BaseCommand
8-from letters.services import TopicSuggestionService
9-10-11-class Command(BaseCommand):
12- help = 'Test topic-to-constituency mapping with example concerns'
13-14- def add_arguments(self, parser):
15- parser.add_argument(
16- '--concern',
17- type=str,
18- help='Custom concern text to test',
19- )
20-21- def handle(self, *args, **options):
22- custom_concern = options.get('concern')
23-24- if custom_concern:
25- # Test custom concern
26- self.test_concern(custom_concern)
27- else:
28- # Test predefined examples
29- self.stdout.write(self.style.MIGRATE_HEADING('\nTesting Topic-to-Constituency Mapping\n'))
30-31- test_cases = [
32- "I want to see better train connections between cities",
33- "We need more affordable housing and rent control",
34- "Our school curriculum needs reform",
35- "Climate protection and CO2 emissions must be addressed",
36- "Better bus services in my town",
37- "Deutsche Bahn is always late",
38- "University tuition fees should be abolished",
39- "We need stronger EU trade agreements",
40- "Police funding in our state is too low",
41- "Renewable energy expansion is too slow",
42- ]
43-44- for concern in test_cases:
45- self.test_concern(concern)
46- self.stdout.write('') # Blank line
47-48- def test_concern(self, concern_text: str):
49- """Test a single concern and display results."""
50- self.stdout.write(self.style.SUCCESS(f'Concern: "{concern_text}"'))
51-52- # Get topic suggestions only (lightweight)
53- topics = TopicSuggestionService.get_topic_suggestions(concern_text)
54-55- if topics:
56- self.stdout.write(self.style.WARNING(' Matched Topics:'))
57- for topic in topics[:3]: # Show top 3
58- self.stdout.write(f' โข {topic["name"]} ({topic["level"]}) - Score: {topic["match_score"]}')
59- self.stdout.write(f' {topic["description"]}')
60- else:
61- self.stdout.write(self.style.WARNING(' No specific topics matched'))
62-63- # Get full suggestions with representatives
64- result = TopicSuggestionService.suggest_representatives_for_concern(
65- concern_text,
66- limit=3
67- )
68-69- self.stdout.write(self.style.WARNING(f' Suggested Level: {result["suggested_level"]}'))
70- self.stdout.write(self.style.WARNING(f' Explanation: {result["explanation"]}'))
71-72- if result['suggested_constituencies']:
73- self.stdout.write(self.style.WARNING(' Suggested Constituencies:'))
74- for const in result['suggested_constituencies'][:3]:
75- self.stdout.write(f' โข {const.name} ({const.level})')
76-77- if result['suggested_representatives']:
78- self.stdout.write(self.style.WARNING(' Suggested Representatives:'))
79- for rep in result['suggested_representatives']:
80- party = f' ({rep.party})' if rep.party else ''
81- constituency = rep.primary_constituency
82- constituency_label = constituency.name if constituency else rep.parliament.name
83- self.stdout.write(f' โข {rep.full_name}{party} - {constituency_label}')
84- else:
85- self.stdout.write(self.style.WARNING(' (No representatives found - run sync_representatives first)'))
···768769 def __str__(self):
770 return f"Report on '{self.letter.title}' - {self.get_reason_display()}"
771+772+773+class GeocodeCache(models.Model):
774+ """Cache geocoding results to minimize API calls."""
775+776+ address_hash = models.CharField(
777+ max_length=64,
778+ unique=True,
779+ db_index=True,
780+ help_text="SHA256 hash of normalized address for fast lookup"
781+ )
782+ street = models.CharField(max_length=255, blank=True)
783+ postal_code = models.CharField(max_length=10, blank=True)
784+ city = models.CharField(max_length=100, blank=True)
785+ country = models.CharField(max_length=2, default='DE')
786+787+ latitude = models.FloatField(null=True, blank=True)
788+ longitude = models.FloatField(null=True, blank=True)
789+790+ success = models.BooleanField(
791+ default=True,
792+ help_text="False if geocoding failed, to avoid repeated failed lookups"
793+ )
794+ error_message = models.TextField(blank=True)
795+796+ created_at = models.DateTimeField(auto_now_add=True)
797+ updated_at = models.DateTimeField(auto_now=True)
798+799+ class Meta:
800+ verbose_name = "Geocode Cache Entry"
801+ verbose_name_plural = "Geocode Cache Entries"
802+ ordering = ['-created_at']
803+804+ def __str__(self):
805+ if self.latitude and self.longitude:
806+ return f"{self.city} ({self.latitude}, {self.longitude})"
807+ return f"{self.city} (failed)"
+537-18
website/letters/services.py
···1112from __future__ import annotations
13014import json
15import logging
16import re
017import mimetypes
18from datetime import datetime, date
19from dataclasses import dataclass
···34 Committee,
35 CommitteeMembership,
36 Constituency,
037 Parliament,
38 ParliamentTerm,
39 Representative,
···125126127# ---------------------------------------------------------------------------
000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000128# Constituency / address helper
129# ---------------------------------------------------------------------------
130···141 postal_code: Optional[str]
142 state: Optional[str]
143 constituencies: List[Constituency]
000144145 @property
146 def has_constituencies(self) -> bool:
···176177178class ConstituencyLocator:
179- """Heuristic mapping from postal codes to broad constituencies."""
0000000180181- # Rough PLZ -> state mapping (first two digits).
182 STATE_BY_PLZ_PREFIX: Dict[str, str] = {
183 **{prefix: 'Berlin' for prefix in ['10', '11']},
184 **{prefix: 'Bayern' for prefix in ['80', '81', '82', '83', '84', '85', '86', '87', '88', '89', '90', '91']} ,
···188 **{prefix: 'Niedersachsen' for prefix in ['26', '27', '28', '29', '30', '31', '32', '33', '37', '38', '49']},
189 }
19000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000191 @classmethod
192- def locate(cls, postal_code: str) -> LocatedConstituencies:
000000193 postal_code = (postal_code or '').strip()
194 if len(postal_code) < 2:
195 return LocatedConstituencies(None, None, None)
···1170 from .models import IdentityVerification
11711172 postal_code = (verification_data.get('postal_code') or '').strip()
1173- located = ConstituencyLocator.locate(postal_code) if postal_code else LocatedConstituencies(None, None, None)
1174 constituency = located.local or located.state or located.federal
11751176 expires_at_value = verification_data.get('expires_at')
···13541355 @classmethod
1356 def _resolve_location(cls, user_location: Dict[str, str]) -> LocationContext:
01357 postal_code = (user_location.get('postal_code') or '').strip()
00001358 constituencies: List[Constituency] = []
135901360 provided_constituencies = user_location.get('constituencies')
1361 if provided_constituencies:
1362 iterable = provided_constituencies if isinstance(provided_constituencies, (list, tuple, set)) else [provided_constituencies]
···1374 if constituency and all(c.id != constituency.id for c in constituencies):
1375 constituencies.append(constituency)
13761377- if not constituencies and postal_code:
1378- located = ConstituencyLocator.locate(postal_code)
1379- constituencies.extend(
1380- constituency
1381- for constituency in (located.local, located.state, located.federal)
1382- if constituency
1383- )
1384- else:
1385- located = LocatedConstituencies(None, None, None)
000000000000000000000138601387 explicit_state = normalize_german_state(user_location.get('state')) if user_location.get('state') else None
1388 inferred_state = None
01389 for constituency in constituencies:
1390 metadata_state = (constituency.metadata or {}).get('state') if constituency.metadata else None
1391 if metadata_state:
···1393 if inferred_state:
1394 break
13951396- if not inferred_state and postal_code and not constituencies and located.state:
1397- metadata_state = (located.state.metadata or {}).get('state') if located.state and located.state.metadata else None
1398- if metadata_state:
1399- inferred_state = normalize_german_state(metadata_state)
1400-1401 state = explicit_state or inferred_state
14021403 return LocationContext(
1404 postal_code=postal_code or None,
1405 state=state,
1406 constituencies=constituencies,
0001407 )
14081409 @classmethod
···1112from __future__ import annotations
1314+import hashlib
15import json
16import logging
17import re
18+import time
19import mimetypes
20from datetime import datetime, date
21from dataclasses import dataclass
···36 Committee,
37 CommitteeMembership,
38 Constituency,
39+ GeocodeCache,
40 Parliament,
41 ParliamentTerm,
42 Representative,
···128129130# ---------------------------------------------------------------------------
131+# Address Geocoding with OSM Nominatim
132+# ---------------------------------------------------------------------------
133+134+135+class AddressGeocoder:
136+ """
137+ Geocode German addresses using OpenStreetMap Nominatim API.
138+139+ Features:
140+ - Caches results using GeocodeCache model
141+ - Rate limits to 1 request/second for public API compliance
142+ - Handles errors gracefully
143+ - Caches both successful and failed lookups to avoid repeated failures
144+ """
145+146+ NOMINATIM_ENDPOINT = 'https://nominatim.openstreetmap.org/search'
147+ USER_AGENT = 'WriteThem.eu/0.1 (civic engagement platform)'
148+ RATE_LIMIT_SECONDS = 1.0
149+150+ def __init__(self):
151+ self._last_request_time = 0
152+153+ def geocode(
154+ self,
155+ street: str,
156+ postal_code: str,
157+ city: str,
158+ country: str = 'DE'
159+ ) -> Tuple[Optional[float], Optional[float], bool, Optional[str]]:
160+ """
161+ Geocode a German address to latitude/longitude coordinates.
162+163+ Args:
164+ street: Street name and number
165+ postal_code: Postal code (PLZ)
166+ city: City name
167+ country: Country code (default: 'DE')
168+169+ Returns:
170+ Tuple of (latitude, longitude, success, error_message)
171+ - On success: (lat, lon, True, None)
172+ - On failure: (None, None, False, error_message)
173+ """
174+ # Normalize inputs
175+ street = (street or '').strip()
176+ postal_code = (postal_code or '').strip()
177+ city = (city or '').strip()
178+ country = (country or 'DE').upper()
179+180+ # Generate cache key
181+ address_hash = self._generate_cache_key(street, postal_code, city, country)
182+183+ # Check cache first
184+ cached = self._get_from_cache(address_hash)
185+ if cached is not None:
186+ return cached
187+188+ # Make API request with rate limiting
189+ try:
190+ self._apply_rate_limit()
191+ result = self._query_nominatim(street, postal_code, city, country)
192+193+ if result:
194+ lat, lon = result
195+ self._store_in_cache(
196+ address_hash, street, postal_code, city, country,
197+ lat, lon, success=True, error_message=None
198+ )
199+ return lat, lon, True, None
200+ else:
201+ error_msg = 'Address not found'
202+ self._store_in_cache(
203+ address_hash, street, postal_code, city, country,
204+ None, None, success=False, error_message=error_msg
205+ )
206+ return None, None, False, error_msg
207+208+ except Exception as e:
209+ error_msg = f'Geocoding API error: {str(e)}'
210+ logger.warning('Geocoding failed for %s, %s %s: %s', street, postal_code, city, error_msg)
211+212+ # Cache the failure to avoid repeated attempts
213+ self._store_in_cache(
214+ address_hash, street, postal_code, city, country,
215+ None, None, success=False, error_message=error_msg
216+ )
217+ return None, None, False, error_msg
218+219+ def _generate_cache_key(
220+ self,
221+ street: str,
222+ postal_code: str,
223+ city: str,
224+ country: str
225+ ) -> str:
226+ """Generate SHA256 hash of normalized address for cache lookup."""
227+ # Normalize address components for consistent hashing
228+ normalized = f"{street}|{postal_code}|{city}|{country}"
229+ return hashlib.sha256(normalized.encode('utf-8')).hexdigest()
230+231+ def _get_from_cache(
232+ self,
233+ address_hash: str
234+ ) -> Optional[Tuple[Optional[float], Optional[float], bool, Optional[str]]]:
235+ """Check cache for existing geocoding result."""
236+ try:
237+ cache_entry = GeocodeCache.objects.get(address_hash=address_hash)
238+ if cache_entry.success:
239+ return cache_entry.latitude, cache_entry.longitude, True, None
240+ else:
241+ return None, None, False, cache_entry.error_message
242+ except GeocodeCache.DoesNotExist:
243+ return None
244+245+ def _store_in_cache(
246+ self,
247+ address_hash: str,
248+ street: str,
249+ postal_code: str,
250+ city: str,
251+ country: str,
252+ latitude: Optional[float],
253+ longitude: Optional[float],
254+ success: bool,
255+ error_message: Optional[str]
256+ ) -> None:
257+ """Store geocoding result in cache."""
258+ GeocodeCache.objects.update_or_create(
259+ address_hash=address_hash,
260+ defaults={
261+ 'street': street,
262+ 'postal_code': postal_code,
263+ 'city': city,
264+ 'country': country,
265+ 'latitude': latitude,
266+ 'longitude': longitude,
267+ 'success': success,
268+ 'error_message': error_message or '',
269+ }
270+ )
271+272+ def _apply_rate_limit(self) -> None:
273+ """Ensure we don't exceed 1 request per second."""
274+ current_time = time.time()
275+ time_since_last = current_time - self._last_request_time
276+277+ if time_since_last < self.RATE_LIMIT_SECONDS:
278+ sleep_time = self.RATE_LIMIT_SECONDS - time_since_last
279+ time.sleep(sleep_time)
280+281+ self._last_request_time = time.time()
282+283+ def _query_nominatim(
284+ self,
285+ street: str,
286+ postal_code: str,
287+ city: str,
288+ country: str
289+ ) -> Optional[Tuple[float, float]]:
290+ """
291+ Query Nominatim API for address coordinates.
292+293+ Returns:
294+ (latitude, longitude) on success, None if not found
295+296+ Raises:
297+ requests.RequestException on API errors
298+ """
299+ # Build query string
300+ query_parts = []
301+ if street:
302+ query_parts.append(street)
303+ if postal_code:
304+ query_parts.append(postal_code)
305+ if city:
306+ query_parts.append(city)
307+308+ query = ', '.join(query_parts)
309+310+ params = {
311+ 'q': query,
312+ 'format': 'json',
313+ 'addressdetails': 1,
314+ 'limit': 1,
315+ 'countrycodes': country.lower(),
316+ }
317+318+ headers = {
319+ 'User-Agent': self.USER_AGENT
320+ }
321+322+ response = requests.get(
323+ self.NOMINATIM_ENDPOINT,
324+ params=params,
325+ headers=headers,
326+ timeout=10
327+ )
328+ response.raise_for_status()
329+330+ results = response.json()
331+332+ if results and len(results) > 0:
333+ result = results[0]
334+ lat = float(result['lat'])
335+ lon = float(result['lon'])
336+ return lat, lon
337+338+ return None
339+340+341+# ---------------------------------------------------------------------------
342+# Wahlkreis (Constituency) Locator using Point-in-Polygon matching
343+# ---------------------------------------------------------------------------
344+345+346+class WahlkreisLocator:
347+ """Locate which Wahlkreis (constituency) a coordinate falls within using Shapely."""
348+349+ # Class-level cache for parsed constituencies
350+ _cached_constituencies = None
351+ _cached_path = None
352+353+ def __init__(self, geojson_path=None):
354+ """
355+ Load and parse GeoJSON constituencies.
356+357+ Args:
358+ geojson_path: Path to the GeoJSON file. If None, uses settings.CONSTITUENCY_BOUNDARIES_PATH
359+ """
360+ from shapely.geometry import shape
361+362+ if geojson_path is None:
363+ geojson_path = settings.CONSTITUENCY_BOUNDARIES_PATH
364+365+ # Use cached constituencies if available and path matches
366+ if (WahlkreisLocator._cached_constituencies is not None and
367+ WahlkreisLocator._cached_path == geojson_path):
368+ self.constituencies = WahlkreisLocator._cached_constituencies
369+ return
370+371+ # Load and parse GeoJSON file
372+ self.constituencies = []
373+ with open(geojson_path, 'r', encoding='utf-8') as f:
374+ data = json.load(f)
375+376+ # Parse each feature and store geometry with properties
377+ for feature in data.get('features', []):
378+ properties = feature.get('properties', {})
379+ wkr_nr = properties.get('WKR_NR')
380+ wkr_name = properties.get('WKR_NAME', '')
381+ land_name = properties.get('LAND_NAME', '')
382+383+ # Parse geometry using Shapely
384+ geometry = shape(feature['geometry'])
385+386+ # Store as tuple: (wkr_nr, wkr_name, land_name, geometry)
387+ self.constituencies.append((wkr_nr, wkr_name, land_name, geometry))
388+389+ # Cache the parsed constituencies
390+ WahlkreisLocator._cached_constituencies = self.constituencies
391+ WahlkreisLocator._cached_path = geojson_path
392+393+ def locate(self, latitude, longitude):
394+ """
395+ Find constituency containing the given coordinates.
396+397+ Args:
398+ latitude: Latitude coordinate
399+ longitude: Longitude coordinate
400+401+ Returns:
402+ tuple: (wkr_nr, wkr_name, land_name) or None if not found
403+ """
404+ from shapely.geometry import Point
405+406+ # Create point from coordinates
407+ point = Point(longitude, latitude)
408+409+ # Iterate through constituencies and check containment
410+ for wkr_nr, wkr_name, land_name, geometry in self.constituencies:
411+ if geometry.contains(point):
412+ return (wkr_nr, wkr_name, land_name)
413+414+ # No match found
415+ return None
416+417+418+# ---------------------------------------------------------------------------
419# Constituency / address helper
420# ---------------------------------------------------------------------------
421···432 postal_code: Optional[str]
433 state: Optional[str]
434 constituencies: List[Constituency]
435+ street: Optional[str] = None
436+ city: Optional[str] = None
437+ country: str = 'DE'
438439 @property
440 def has_constituencies(self) -> bool:
···470471472class ConstituencyLocator:
473+ """
474+ Locate representatives by address or postal code.
475+476+ Features:
477+ - Address-based lookup: Uses AddressGeocoder + WahlkreisLocator for accurate constituency matching
478+ - PLZ-based fallback: Falls back to PLZ-prefix matching when geocoding fails
479+ - Backward compatible: Still accepts PLZ-only queries
480+ """
481482+ # Rough PLZ -> state mapping (first two digits) for fallback
483 STATE_BY_PLZ_PREFIX: Dict[str, str] = {
484 **{prefix: 'Berlin' for prefix in ['10', '11']},
485 **{prefix: 'Bayern' for prefix in ['80', '81', '82', '83', '84', '85', '86', '87', '88', '89', '90', '91']} ,
···489 **{prefix: 'Niedersachsen' for prefix in ['26', '27', '28', '29', '30', '31', '32', '33', '37', '38', '49']},
490 }
491492+ def __init__(self):
493+ """Initialize geocoder and wahlkreis locator services."""
494+ self._geocoder = None
495+ self._wahlkreis_locator = None
496+497+ @property
498+ def geocoder(self):
499+ """Lazy-load AddressGeocoder."""
500+ if self._geocoder is None:
501+ self._geocoder = AddressGeocoder()
502+ return self._geocoder
503+504+ @property
505+ def wahlkreis_locator(self):
506+ """Lazy-load WahlkreisLocator."""
507+ if self._wahlkreis_locator is None:
508+ self._wahlkreis_locator = WahlkreisLocator()
509+ return self._wahlkreis_locator
510+511+ def locate(
512+ self,
513+ street: Optional[str] = None,
514+ postal_code: Optional[str] = None,
515+ city: Optional[str] = None,
516+ country: str = 'DE'
517+ ) -> List[Representative]:
518+ """
519+ Locate representatives by address or postal code.
520+521+ Args:
522+ street: Street name and number (optional)
523+ postal_code: Postal code / PLZ (optional)
524+ city: City name (optional)
525+ country: Country code (default: 'DE')
526+527+ Returns:
528+ List of Representative objects for the located constituency
529+530+ Strategy:
531+ 1. If full address provided (street + postal_code + city):
532+ - Geocode address to coordinates
533+ - Use WahlkreisLocator to find constituency
534+ - Return Representatives for that constituency
535+ 2. Fallback to PLZ-prefix matching if:
536+ - No street provided
537+ - Geocoding fails
538+ - WahlkreisLocator returns no result
539+ """
540+ street = (street or '').strip()
541+ postal_code = (postal_code or '').strip()
542+ city = (city or '').strip()
543+544+ # Try full address-based lookup if we have all components
545+ if street and postal_code and city:
546+ try:
547+ lat, lon, success, error = self.geocoder.geocode(street, postal_code, city, country)
548+549+ if success and lat is not None and lon is not None:
550+ # Find constituency using coordinates
551+ result = self.wahlkreis_locator.locate(lat, lon)
552+553+ if result:
554+ wkr_nr, wkr_name, land_name = result
555+ logger.info(
556+ "Address geocoded to constituency: %s (WK %s, %s)",
557+ wkr_name, wkr_nr, land_name
558+ )
559+560+ # Find Representatives for this Wahlkreis
561+ representatives = self._find_representatives_by_wahlkreis(
562+ wkr_nr, wkr_name, land_name
563+ )
564+565+ if representatives:
566+ return representatives
567+568+ # If no representatives found for direct constituency,
569+ # fall through to PLZ-based lookup
570+ logger.warning(
571+ "No representatives found for WK %s, falling back to PLZ",
572+ wkr_nr
573+ )
574+ else:
575+ logger.debug(
576+ "Geocoding failed for %s, %s %s: %s",
577+ street, postal_code, city, error
578+ )
579+ except Exception as e:
580+ logger.warning(
581+ "Error during address-based lookup for %s, %s %s: %s",
582+ street, postal_code, city, e
583+ )
584+585+ # Fallback to PLZ-based lookup
586+ if postal_code:
587+ return self._locate_by_plz(postal_code)
588+589+ # No parameters provided
590+ return []
591+592+ def _find_representatives_by_wahlkreis(
593+ self,
594+ wkr_nr: int,
595+ wkr_name: str,
596+ land_name: str
597+ ) -> List[Representative]:
598+ """
599+ Find representatives for a given Wahlkreis.
600+601+ Strategy:
602+ 1. Look for constituencies with matching WKR_NR in metadata
603+ 2. Look for constituencies with matching name
604+ 3. Return active representatives from matched constituencies
605+ """
606+ # Try to find constituency by WKR_NR in metadata
607+ constituencies = Constituency.objects.filter(
608+ metadata__WKR_NR=wkr_nr,
609+ scope='FEDERAL_DISTRICT'
610+ ).prefetch_related('representatives')
611+612+ if not constituencies.exists():
613+ # Try by name matching
614+ constituencies = Constituency.objects.filter(
615+ name__icontains=str(wkr_nr),
616+ scope='FEDERAL_DISTRICT'
617+ ).prefetch_related('representatives')
618+619+ if not constituencies.exists():
620+ # Try finding by state and scope
621+ normalized_state = normalize_german_state(land_name)
622+ if normalized_state:
623+ constituencies = Constituency.objects.filter(
624+ metadata__state=normalized_state,
625+ scope__in=['FEDERAL_DISTRICT', 'FEDERAL_STATE_LIST']
626+ ).prefetch_related('representatives')
627+628+ # Collect all representatives from matched constituencies
629+ representatives = []
630+ for constituency in constituencies:
631+ reps = list(constituency.representatives.filter(is_active=True))
632+ representatives.extend(reps)
633+634+ # Remove duplicates while preserving order
635+ seen = set()
636+ unique_reps = []
637+ for rep in representatives:
638+ if rep.id not in seen:
639+ seen.add(rep.id)
640+ unique_reps.append(rep)
641+642+ return unique_reps
643+644+ def _locate_by_plz(self, postal_code: str) -> List[Representative]:
645+ """
646+ Fallback: Locate representatives using PLZ-prefix matching.
647+648+ Returns list of Representatives instead of LocatedConstituencies.
649+ """
650+ if len(postal_code) < 2:
651+ return []
652+653+ prefix = postal_code[:2]
654+ state_name = self.STATE_BY_PLZ_PREFIX.get(prefix)
655+ normalized_state = normalize_german_state(state_name) if state_name else None
656+657+ federal = self._match_federal(normalized_state)
658+ state = self._match_state(normalized_state)
659+660+ # Convert constituencies to representatives
661+ representatives = []
662+ for constituency in [federal, state]:
663+ if constituency:
664+ reps = list(constituency.representatives.filter(is_active=True))
665+ representatives.extend(reps)
666+667+ # Remove duplicates
668+ seen = set()
669+ unique_reps = []
670+ for rep in representatives:
671+ if rep.id not in seen:
672+ seen.add(rep.id)
673+ unique_reps.append(rep)
674+675+ return unique_reps
676+677 @classmethod
678+ def locate_legacy(cls, postal_code: str) -> LocatedConstituencies:
679+ """
680+ Legacy method: Returns LocatedConstituencies for backward compatibility.
681+682+ This method maintains the old API for existing code that expects
683+ LocatedConstituencies instead of List[Representative].
684+ """
685 postal_code = (postal_code or '').strip()
686 if len(postal_code) < 2:
687 return LocatedConstituencies(None, None, None)
···1662 from .models import IdentityVerification
16631664 postal_code = (verification_data.get('postal_code') or '').strip()
1665+ located = ConstituencyLocator.locate_legacy(postal_code) if postal_code else LocatedConstituencies(None, None, None)
1666 constituency = located.local or located.state or located.federal
16671668 expires_at_value = verification_data.get('expires_at')
···18461847 @classmethod
1848 def _resolve_location(cls, user_location: Dict[str, str]) -> LocationContext:
1849+ # Extract address components
1850 postal_code = (user_location.get('postal_code') or '').strip()
1851+ street = (user_location.get('street') or '').strip()
1852+ city = (user_location.get('city') or '').strip()
1853+ country = (user_location.get('country') or 'DE').upper()
1854+1855 constituencies: List[Constituency] = []
18561857+ # First, check if constituencies are provided directly
1858 provided_constituencies = user_location.get('constituencies')
1859 if provided_constituencies:
1860 iterable = provided_constituencies if isinstance(provided_constituencies, (list, tuple, set)) else [provided_constituencies]
···1872 if constituency and all(c.id != constituency.id for c in constituencies):
1873 constituencies.append(constituency)
18741875+ # If no constituencies provided, try address-based or PLZ-based lookup
1876+ if not constituencies:
1877+ locator = ConstituencyLocator()
1878+1879+ # Try full address lookup if available
1880+ if street and postal_code and city:
1881+ # Use new address-based API
1882+ representatives = locator.locate(
1883+ street=street,
1884+ postal_code=postal_code,
1885+ city=city,
1886+ country=country
1887+ )
1888+1889+ # Extract unique constituencies from representatives
1890+ constituency_ids_seen = set()
1891+ for rep in representatives:
1892+ for constituency in rep.constituencies.all():
1893+ if constituency.id not in constituency_ids_seen:
1894+ constituencies.append(constituency)
1895+ constituency_ids_seen.add(constituency.id)
1896+1897+ # Fallback to PLZ-only if no full address or if address lookup failed
1898+ if not constituencies and postal_code:
1899+ located = ConstituencyLocator.locate_legacy(postal_code)
1900+ constituencies.extend(
1901+ constituency
1902+ for constituency in (located.local, located.state, located.federal)
1903+ if constituency
1904+ )
19051906+ # Determine state from various sources
1907 explicit_state = normalize_german_state(user_location.get('state')) if user_location.get('state') else None
1908 inferred_state = None
1909+1910 for constituency in constituencies:
1911 metadata_state = (constituency.metadata or {}).get('state') if constituency.metadata else None
1912 if metadata_state:
···1914 if inferred_state:
1915 break
1916000001917 state = explicit_state or inferred_state
19181919 return LocationContext(
1920 postal_code=postal_code or None,
1921 state=state,
1922 constituencies=constituencies,
1923+ street=street or None,
1924+ city=city or None,
1925+ country=country,
1926 )
19271928 @classmethod
+33
website/letters/templates/letters/profile.html
···52 </div>
5354 <div class="mt-4">
00000000000000000000000000000000055 <h3>{% trans "Self-declare your constituency" %}</h3>
56 <p class="text-muted">
57 {% trans "Select the constituencies you live in so we can prioritise the right representatives." %}
···52 </div>
5354 <div class="mt-4">
55+ <h3>{% trans "Ihre Adresse" %}</h3>
56+ <p class="text-muted">
57+ {% trans "Geben Sie Ihre vollstรคndige Adresse ein, um prรคzise Wahlkreis- und Abgeordnetenempfehlungen zu erhalten." %}
58+ </p>
59+ {% if verification and verification.street_address %}
60+ <div class="alert alert-info" role="status">
61+ <strong>{% trans "Gespeicherte Adresse:" %}</strong><br>
62+ {{ verification.street_address }}<br>
63+ {{ verification.postal_code }} {{ verification.city }}
64+ </div>
65+ {% endif %}
66+ <form method="post" class="mt-3">
67+ {% csrf_token %}
68+ {% if address_form.non_field_errors %}
69+ <div class="alert alert-danger">{{ address_form.non_field_errors }}</div>
70+ {% endif %}
71+ {% for field in address_form %}
72+ <div class="form-group mb-3">
73+ <label for="{{ field.id_for_label }}" class="form-label">{{ field.label }}</label>
74+ {{ field }}
75+ {% if field.errors %}
76+ <div class="text-danger small">{{ field.errors|join:', ' }}</div>
77+ {% endif %}
78+ {% if field.help_text %}
79+ <small class="form-text text-muted">{{ field.help_text }}</small>
80+ {% endif %}
81+ </div>
82+ {% endfor %}
83+ <button type="submit" name="address_form_submit" class="btn btn-primary">{% trans "Adresse speichern" %}</button>
84+ </form>
85+ </div>
86+87+ <div class="mt-4">
88 <h3>{% trans "Self-declare your constituency" %}</h3>
89 <p class="text-muted">
90 {% trans "Select the constituencies you live in so we can prioritise the right representatives." %}
+2
website/letters/tests/__init__.py
···00
···1+# ABOUTME: Test package for letters app.
2+# ABOUTME: Contains tests for address matching, topic mapping, and constituency suggestions.
···1+# ABOUTME: Test topic suggestion and matching based on letter content.
2+# ABOUTME: Covers TopicSuggestionService keyword matching and level suggestion logic.
3+4+from django.test import TestCase
5+from letters.services import TopicSuggestionService
6+from letters.models import TopicArea
7+8+9+class TopicMatchingTests(TestCase):
10+ """Test topic keyword matching and scoring."""
11+12+ def setUp(self):
13+ """Check if topic data is available."""
14+ self.has_topics = TopicArea.objects.exists()
15+16+ def test_transport_keywords_match_verkehr_topic(self):
17+ """Test that transport-related keywords match Verkehr topic."""
18+ if not self.has_topics:
19+ self.skipTest("TopicArea data not loaded")
20+21+ concern = "I want to see better train connections between cities"
22+ result = TopicSuggestionService.suggest_representatives_for_concern(concern)
23+24+ # Should find at least one topic
25+ matched_topics = result.get('matched_topics', [])
26+ self.assertGreater(len(matched_topics), 0)
27+28+ def test_housing_keywords_match_wohnen_topic(self):
29+ """Test that housing keywords match Wohnen topic."""
30+ if not self.has_topics:
31+ self.skipTest("TopicArea data not loaded")
32+33+ concern = "We need more affordable housing and rent control"
34+ result = TopicSuggestionService.suggest_representatives_for_concern(concern)
35+36+ matched_topics = result.get('matched_topics', [])
37+ self.assertGreater(len(matched_topics), 0)
38+39+ def test_education_keywords_match_bildung_topic(self):
40+ """Test that education keywords match Bildung topic."""
41+ if not self.has_topics:
42+ self.skipTest("TopicArea data not loaded")
43+44+ concern = "Our school curriculum needs reform"
45+ result = TopicSuggestionService.suggest_representatives_for_concern(concern)
46+47+ matched_topics = result.get('matched_topics', [])
48+ self.assertGreater(len(matched_topics), 0)
49+50+ def test_climate_keywords_match_umwelt_topic(self):
51+ """Test that climate keywords match environment topic."""
52+ if not self.has_topics:
53+ self.skipTest("TopicArea data not loaded")
54+55+ concern = "Climate protection and CO2 emissions must be addressed"
56+ result = TopicSuggestionService.suggest_representatives_for_concern(concern)
57+58+ matched_topics = result.get('matched_topics', [])
59+ self.assertGreater(len(matched_topics), 0)
60+61+ def test_no_match_returns_empty_list(self):
62+ """Test that completely unrelated text returns empty list."""
63+ concern = "xyzabc nonsense gibberish"
64+ result = TopicSuggestionService.suggest_representatives_for_concern(concern)
65+66+ matched_topics = result.get('matched_topics', [])
67+ # Should return empty list for gibberish
68+ self.assertEqual(len(matched_topics), 0)
69+70+71+class LevelSuggestionTests(TestCase):
72+ """Test government level suggestion logic."""
73+74+ def test_federal_transport_suggests_federal_level(self):
75+ """Test that long-distance transport suggests federal level."""
76+ result = TopicSuggestionService.suggest_representatives_for_concern(
77+ "Deutsche Bahn is always late",
78+ limit=5
79+ )
80+81+ self.assertIn('suggested_level', result)
82+ self.assertIn('explanation', result)
83+ # Federal issues should suggest FEDERAL level
84+ suggested_level = result['suggested_level']
85+ self.assertIsNotNone(suggested_level)
86+ self.assertIn('FEDERAL', suggested_level)
87+88+ def test_local_bus_suggests_state_or_local(self):
89+ """Test that local transport suggests state/local level."""
90+ result = TopicSuggestionService.suggest_representatives_for_concern(
91+ "Better bus services in my town",
92+ limit=5
93+ )
94+95+ self.assertIn('suggested_level', result)
96+ self.assertIn('explanation', result)
97+ # Should have an explanation
98+ self.assertIsNotNone(result['explanation'])
99+100+101+# End of file