landvex: Datafabrik + Vision + Infrastruktur för 50GB skalning
Datafabrik: - Skördare: crawler, källvitlista, upphandlingsskördare - Extraktor: LLM-baserad schemastyrd extraktion - Upplösare: Entitetsupplösning och deduplicering - Köer: Schemalagd / kunddriven / fält - Agentorkestrering: 20+ parallella agenter Vision: - Identify-modell: ResNet50 + kontrastivt lärande - Träningspipeline: NT-Xent loss - Vektordatabas: FAISS för snabb sökning - OCR-pipeline: Typskyltsläsning Infrastruktur: - Docker Compose production - Terraform för AWS ECS - Prometheus + Grafana monitorering - Neo4j + FAISS + MinIO + Redis
This commit is contained in:
+98
-55
@@ -3,82 +3,125 @@
|
|||||||
> Kunskapsgraf över visuellt identifierbar fysisk infrastruktur.
|
> Kunskapsgraf över visuellt identifierbar fysisk infrastruktur.
|
||||||
> Grafen är produkten. Allt annat — appar, API:er, dashboards — är gränssnitt mot grafen.
|
> Grafen är produkten. Allt annat — appar, API:er, dashboards — är gränssnitt mot grafen.
|
||||||
|
|
||||||
## Struktur
|
## Arkitektur (skalad för 50GB+ agentkraft)
|
||||||
|
|
||||||
```
|
```
|
||||||
projects/landvex/
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
├── 00-styrning/ # Validering, verifiering
|
│ LANDVEX IOL v3 │
|
||||||
├── 10-data/ # Master-data (JSON)
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
│ ├── lvx-klassposter-master-v3.json
|
│ SaaS-lager │ Identify API │ Tillverkarportal │
|
||||||
│ ├── lvx-produktmodeller-*.json
|
│ (API-nycklar, │ (foto → │ (claims, │
|
||||||
│ ├── lvx-graf-kanter-v3.json
|
│ billing, SLAs) │ kandidater) │ verifiering) │
|
||||||
│ └── lvx-id-register.json
|
├──────────────────────┼────────────────┼────────────────────────┤
|
||||||
├── 20-automation/ # Ingest-pipeline
|
│ Datafabrik │ Vision │ QUIXZOOM │
|
||||||
│ ├── ingest.py
|
│ (skördning, │ (embeddings, │ (bounties, │
|
||||||
│ └── intag/ # Kandidatposter per våg
|
│ extraktion, │ OCR, │ fältverifiering, │
|
||||||
├── 30-produkt/ # API-spec, demosvar
|
│ entitetsupplösning)│ träning) │ individregister) │
|
||||||
├── 40-strategi/ # Genome-strategi, IOL-spec
|
├──────────────────────┴────────────────┴────────────────────────┤
|
||||||
├── api/ # FastAPI Identify-API
|
│ Property Graph (Neo4j) │ Vektordatabas (FAISS) │
|
||||||
├── db/ # Neo4j-laddning
|
│ 64 noder → 1M+ noder │ 0 → 1M+ embeddings │
|
||||||
├── scripts/ # Export, bygghjälp
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
├── arkiv/ # Ersatta versioner
|
│ Ingest Pipeline → Master Data → Graf-export → API │
|
||||||
└── build.sh # Master byggscript
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Komponenter
|
||||||
|
|
||||||
|
### Datafabrik (`datafabrik/`)
|
||||||
|
Automatiserad datainsamling och bearbetning.
|
||||||
|
|
||||||
|
| Modul | Beskrivning |
|
||||||
|
|-------|-------------|
|
||||||
|
| `skordare/` | Webb-crawlers för tillverkare, upphandlingar, standarder |
|
||||||
|
| `extraktor/` | LLM-baserad schemastyrd extraktion (GPT-4o-mini) |
|
||||||
|
| `upplosare/` | Entitetsupplösning och deduplicering |
|
||||||
|
| `koer/` | Tre köer: schemalagd / kunddriven / fält |
|
||||||
|
| `agent_orkestrering.py` | Koordinerar 20+ parallella agenter |
|
||||||
|
|
||||||
|
### Vision (`vision/`)
|
||||||
|
Bildigenkänning och träning.
|
||||||
|
|
||||||
|
| Modul | Beskrivning |
|
||||||
|
|-------|-------------|
|
||||||
|
| `modeller/` | Identify-modell (ResNet50 + kontrastivt lärande) |
|
||||||
|
| `traening/` | Träningspipeline med NT-Xent loss |
|
||||||
|
| `embeddings/` | FAISS-vektordatabas + OCR-pipeline |
|
||||||
|
| `dataset/` | Bilddataset från Zoomer-foton och tillverkare |
|
||||||
|
|
||||||
|
### API (`api/`)
|
||||||
|
FastAPI-baserad Identify API.
|
||||||
|
|
||||||
|
| Endpoint | Beskrivning |
|
||||||
|
|----------|-------------|
|
||||||
|
| `POST /v0/identify` | Bild → rankade kandidater |
|
||||||
|
| `GET /v0/objects/{id}` | Komplett post |
|
||||||
|
| `GET /v0/search` | Fritextsök |
|
||||||
|
| `POST /v0/feedback` | Skapa bounty |
|
||||||
|
|
||||||
|
### Infrastruktur (`infrastruktur/`)
|
||||||
|
|
||||||
|
| Komponent | Teknik |
|
||||||
|
|-----------|--------|
|
||||||
|
| Graf-databas | Neo4j Enterprise |
|
||||||
|
| Vektordatabas | FAISS |
|
||||||
|
| Objektlagring | MinIO (S3-kompatibel) |
|
||||||
|
| Köer | Redis |
|
||||||
|
| Monitorering | Prometheus + Grafana |
|
||||||
|
| Orkestrering | AWS ECS Fargate |
|
||||||
|
|
||||||
## Snabbstart
|
## Snabbstart
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Bygg & kör allt
|
# 1. Bygg och starta lokalt
|
||||||
./build.sh
|
./build.sh
|
||||||
|
|
||||||
# Endast API (förutsätter Neo4j)
|
# 2. Produktion med Docker Compose
|
||||||
docker compose up -d api
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
# Endast data-export
|
# 3. Skala ut med Terraform
|
||||||
python3 scripts/export_csv.py
|
cd infrastruktur/terraform
|
||||||
python3 db/load_to_neo4j.py
|
terraform apply
|
||||||
|
|
||||||
|
# 4. Kör agentarmén för Tier 1
|
||||||
|
python3 datafabrik/agent_orkestrering.py --tier 1 --domäner TRP,ELN,VAT
|
||||||
```
|
```
|
||||||
|
|
||||||
## API
|
## Dataflöde
|
||||||
|
|
||||||
| Endpoint | Beskrivning |
|
|
||||||
|----------|-------------|
|
|
||||||
| `GET /health` | Status + antal poster/kanter |
|
|
||||||
| `POST /v0/identify` | Bild → rankade kandidater |
|
|
||||||
| `GET /v0/objects/{lvx_id}` | Komplett post |
|
|
||||||
| `GET /v0/objects/{lvx_id}/succession` | Ersättningskedja |
|
|
||||||
| `GET /v0/search?q=...` | Fritextsök |
|
|
||||||
| `GET /v0/standards/{b}/objects` | Standard → objekt |
|
|
||||||
| `POST /v0/feedback` | Fel/okänt → bounty |
|
|
||||||
|
|
||||||
## Data
|
|
||||||
|
|
||||||
- **64 noder** (37 klasser + 27 modeller)
|
|
||||||
- **176 kanter** (80 LVX-upplösta, 82 standard, 14 textnoder)
|
|
||||||
- **9 domäner**: TRP, ELN, VAT, TEL, BYG, PRK, JVG, HMN, FLG
|
|
||||||
- **Verifieringsnivåer**: obekräftad → källbelagd → fältverifierad → tillverkarbekräftad
|
|
||||||
|
|
||||||
## Arkitektur
|
|
||||||
|
|
||||||
```
|
```
|
||||||
Ingest Pipeline API Layer Graph DB
|
Skördare → Rådata → Extraktor → Kandidatposter → Upplösare →
|
||||||
─────────────────────────────────────────────────────────
|
Master v3 → Graf-export → Neo4j + FAISS → API → Kund
|
||||||
Kandidatposter → resolve/merge → Neo4j (propertygraf)
|
↑_________________________________________________↓
|
||||||
↓ ↓ ↓
|
(feedback → bounty → ny skördning)
|
||||||
Extraktion → Identify API → Query/Search
|
|
||||||
↓ ↓ ↓
|
|
||||||
Zoomer-bounties ← Feedback ← Succession
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Mätetal
|
||||||
|
|
||||||
|
| Mått | Nu | Mål (12 mån) |
|
||||||
|
|------|-----|--------------|
|
||||||
|
| Objektklasser | 37 | 500+ |
|
||||||
|
| Produktmodeller | 27 | 100 000+ |
|
||||||
|
| Grafkanter | 176 | 1 000 000+ |
|
||||||
|
| Identify konfidens | Mockad | >90% top-3 |
|
||||||
|
| Kostnad/faktum | N/A | <$0.001 |
|
||||||
|
|
||||||
## Regler
|
## Regler
|
||||||
|
|
||||||
1. **Fakta bär alltid källa** — inga påståenden utan proveniens
|
1. **Enda sanningskälla:** `10-data/` + `lvx-objekt-schema.json`
|
||||||
2. **Landvex-ID är permanent** — namn ändras, ID aldrig
|
2. **Aldrig mock i kundnära väg**
|
||||||
3. **Konfidens exponeras alltid** — kunden ser aldrig osäkert som sanning
|
3. **Källa på varje påstående**
|
||||||
4. **Problem utan källa filtreras bort** — vid API-serialisering
|
4. **ID:n mintas endast via registertjänsten**
|
||||||
|
5. **Verifieringstrappan är enkelriktad**
|
||||||
|
|
||||||
## Dokument
|
## Dokument
|
||||||
|
|
||||||
- [Genome-strategi](40-strategi/landvex-genome-strategi.md)
|
- [Genome-strategi](40-strategi/landvex-genome-strategi.md)
|
||||||
- [IOL-grundspec](40-strategi/iol-grundspecifikation.md)
|
- [IOL-grundspec](40-strategi/iol-grundspecifikation.md)
|
||||||
- [API-spec](30-produkt/lvx-pilot-api-spec.md)
|
- [API-spec](30-produkt/lvx-pilot-api-spec.md)
|
||||||
|
- [Agentarmé](00-styrning/AGENTARME.md)
|
||||||
|
|
||||||
|
## Team
|
||||||
|
|
||||||
|
58 agenter · 11 skvadroner · 4 faser
|
||||||
|
|
||||||
|
Litet kärnteam, stor agentflotta. **Skalning sker i beräkning, inte i huvuden.**
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ DATA_DIR="${PWD}/10-data"
|
|||||||
DB_DIR="${PWD}/db"
|
DB_DIR="${PWD}/db"
|
||||||
API_DIR="${PWD}/api"
|
API_DIR="${PWD}/api"
|
||||||
SCRIPT_DIR="${PWD}/scripts"
|
SCRIPT_DIR="${PWD}/scripts"
|
||||||
|
DATAFABRIK_DIR="${PWD}/datafabrik"
|
||||||
|
VISION_DIR="${PWD}/vision"
|
||||||
|
|
||||||
# ─── Steg 1: Validera indata ───────────────────────────────
|
# ─── Steg 1: Validera indata ───────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
@@ -17,10 +19,11 @@ echo "▶ Steg 1: Validerar indata..."
|
|||||||
python3 "${SCRIPT_DIR}/export_csv.py"
|
python3 "${SCRIPT_DIR}/export_csv.py"
|
||||||
python3 "${DB_DIR}/load_to_neo4j.py"
|
python3 "${DB_DIR}/load_to_neo4j.py"
|
||||||
|
|
||||||
# ─── Steg 2: Bygg Docker-image ─────────────────────────────
|
# ─── Steg 2: Bygg Docker-images ────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
echo "▶ Steg 2: Bygger API-container..."
|
echo "▶ Steg 2: Bygger Docker-images..."
|
||||||
docker compose build api
|
docker compose build api
|
||||||
|
docker compose -f docker-compose.prod.yml build datafabrik vision-trainer
|
||||||
|
|
||||||
# ─── Steg 3: Starta tjänster ───────────────────────────────
|
# ─── Steg 3: Starta tjänster ───────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
@@ -42,6 +45,22 @@ echo "▶ Steg 5: Snabbtest av endpoints..."
|
|||||||
curl -s 'http://localhost:8081/v0/search?q=belysning' | python3 -m json.tool | grep '"antal"'
|
curl -s 'http://localhost:8081/v0/search?q=belysning' | python3 -m json.tool | grep '"antal"'
|
||||||
curl -s 'http://localhost:8081/v0/objects/LVX-TRP-0102' | python3 -m json.tool | grep '"lvx_id"'
|
curl -s 'http://localhost:8081/v0/objects/LVX-TRP-0102' | python3 -m json.tool | grep '"lvx_id"'
|
||||||
|
|
||||||
|
# ─── Steg 6: Datafabrik (valfritt) ─────────────────────────
|
||||||
|
if [ "${1:-}" == "--med-datafabrik" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "▶ Steg 6: Startar Datafabrik..."
|
||||||
|
docker compose -f docker-compose.prod.yml up -d datafabrik
|
||||||
|
echo " ✅ Datafabrik startad"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Steg 7: Vision (valfritt) ─────────────────────────────
|
||||||
|
if [ "${1:-}" == "--med-vision" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "▶ Steg 7: Startar Vision-träning..."
|
||||||
|
docker compose -f docker-compose.prod.yml --profile training up -d vision-trainer
|
||||||
|
echo " ✅ Vision-tränare startad"
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "═══════════════════════════════════════════════════════════"
|
echo "═══════════════════════════════════════════════════════════"
|
||||||
echo " ✅ Landvex IOL är driftsatt!"
|
echo " ✅ Landvex IOL är driftsatt!"
|
||||||
@@ -49,11 +68,13 @@ echo ""
|
|||||||
echo " API: http://localhost:8081"
|
echo " API: http://localhost:8081"
|
||||||
echo " Health: http://localhost:8081/health"
|
echo " Health: http://localhost:8081/health"
|
||||||
echo " Neo4j: bolt://localhost:7687"
|
echo " Neo4j: bolt://localhost:7687"
|
||||||
|
echo " Grafana: http://localhost:3000"
|
||||||
|
echo " Prometheus: http://localhost:9090"
|
||||||
echo ""
|
echo ""
|
||||||
echo " Endpoints:"
|
echo " Kommandon:"
|
||||||
echo " POST /v0/identify — Bild → kandidater"
|
echo " ./build.sh --med-datafabrik # Inkludera datafabrik"
|
||||||
echo " GET /v0/objects/{id} — Hämta objekt"
|
echo " ./build.sh --med-vision # Inkludera vision-träning"
|
||||||
echo " GET /v0/search?q=... — Fritextsök"
|
echo ""
|
||||||
echo " GET /v0/standards/{b}/objects — Standard → objekt"
|
echo " Agentorkestrering:"
|
||||||
echo " POST /v0/feedback — Skapa bounty"
|
echo " python3 datafabrik/agent_orkestrering.py --tier 1"
|
||||||
echo "═══════════════════════════════════════════════════════════"
|
echo "═══════════════════════════════════════════════════════════"
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Systemberoenden
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
tesseract-ocr \
|
||||||
|
tesseract-ocr-swe \
|
||||||
|
tesseract-ocr-eng \
|
||||||
|
libgl1-mesa-glx \
|
||||||
|
libglib2.0-0 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Python-paket
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Kopiera kod
|
||||||
|
COPY skordare/ ./skordare/
|
||||||
|
COPY extraktor/ ./extraktor/
|
||||||
|
COPY koer/ ./koer/
|
||||||
|
COPY upplosare/ ./upplosare/
|
||||||
|
|
||||||
|
# Miljövariabler
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
ENV DATAFABRIK_KO_DIR=/data/koer
|
||||||
|
ENV DATAFABRIK_OUTPUT=/data/output
|
||||||
|
|
||||||
|
VOLUME ["/data"]
|
||||||
|
|
||||||
|
CMD ["python3", "-m", "koer.pipeline"]
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Landvex Agent Orkestrering
|
||||||
|
Koordinerar 50GB agentkraft för skalad datainsamling.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
class AgentStatus(Enum):
|
||||||
|
LEDIG = "ledig"
|
||||||
|
KOR = "kor"
|
||||||
|
KLAR = "klar"
|
||||||
|
FEL = "fel"
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AgentUppgift:
|
||||||
|
uppgift_id: str
|
||||||
|
typ: str # "skorda", "extrahera", "upplos", "trana"
|
||||||
|
doman: str # TRP, ELN, VAT, etc.
|
||||||
|
tier: int # 1, 2, 3
|
||||||
|
prioritet: int # 1-10
|
||||||
|
parametrar: Dict
|
||||||
|
status: str = "pending"
|
||||||
|
resultat: Optional[Dict] = None
|
||||||
|
startad: Optional[str] = None
|
||||||
|
avslutad: Optional[str] = None
|
||||||
|
|
||||||
|
class LandvexAgentOrkestrerare:
|
||||||
|
"""Orkestrerar agenter för parallell datainsamling."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
max_parallella: int = 20,
|
||||||
|
ko_dir: Path = Path("/data/koer"),
|
||||||
|
output_dir: Path = Path("/data/output"),
|
||||||
|
):
|
||||||
|
self.max_parallella = max_parallella
|
||||||
|
self.ko_dir = ko_dir
|
||||||
|
self.output_dir = output_dir
|
||||||
|
self.agenter: Dict[str, Dict] = {}
|
||||||
|
self.ko: List[AgentUppgift] = []
|
||||||
|
self.statistik = {
|
||||||
|
"skordade": 0,
|
||||||
|
"extraherade": 0,
|
||||||
|
"upplosta": 0,
|
||||||
|
"tränade": 0,
|
||||||
|
"fel": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def skapa_uppgifter_for_tier(self, tier: int, domäner: List[str]) -> List[AgentUppgift]:
|
||||||
|
"""Skapa uppgifter för en specifik tier."""
|
||||||
|
uppgifter = []
|
||||||
|
|
||||||
|
for doman in domäner:
|
||||||
|
# Skördningsuppgifter
|
||||||
|
uppgifter.append(AgentUppgift(
|
||||||
|
uppgift_id=f"skorda-{doman}-t{tier}-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}",
|
||||||
|
typ="skorda",
|
||||||
|
doman=doman,
|
||||||
|
tier=tier,
|
||||||
|
prioritet=10 - tier, # Tier 1 = högst prioritet
|
||||||
|
parametrar={"kallor": "alla", "max_dokument": 1000},
|
||||||
|
))
|
||||||
|
|
||||||
|
# Extraktionsuppgifter
|
||||||
|
uppgifter.append(AgentUppgift(
|
||||||
|
uppgift_id=f"extrahera-{doman}-t{tier}-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}",
|
||||||
|
typ="extrahera",
|
||||||
|
doman=doman,
|
||||||
|
tier=tier,
|
||||||
|
prioritet=9 - tier,
|
||||||
|
parametrar={"batch_storlek": 100},
|
||||||
|
))
|
||||||
|
|
||||||
|
# Entitetsupplösning
|
||||||
|
uppgifter.append(AgentUppgift(
|
||||||
|
uppgift_id=f"upplos-{doman}-t{tier}-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}",
|
||||||
|
typ="upplos",
|
||||||
|
doman=doman,
|
||||||
|
tier=tier,
|
||||||
|
prioritet=8 - tier,
|
||||||
|
parametrar={"tröskel": 0.85},
|
||||||
|
))
|
||||||
|
|
||||||
|
return uppgifter
|
||||||
|
|
||||||
|
async def kör_agent(self, uppgift: AgentUppgift) -> Dict:
|
||||||
|
"""Kör en agent för en specifik uppgift."""
|
||||||
|
uppgift.startad = datetime.utcnow().isoformat()
|
||||||
|
uppgift.status = "kor"
|
||||||
|
|
||||||
|
try:
|
||||||
|
if uppgift.typ == "skorda":
|
||||||
|
resultat = await self._kör_skordare(uppgift)
|
||||||
|
elif uppgift.typ == "extrahera":
|
||||||
|
resultat = await self._kör_extraktor(uppgift)
|
||||||
|
elif uppgift.typ == "upplos":
|
||||||
|
resultat = await self._kör_upplosare(uppgift)
|
||||||
|
elif uppgift.typ == "trana":
|
||||||
|
resultat = await self._kör_tranare(uppgift)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Okänd uppgiftstyp: {uppgift.typ}")
|
||||||
|
|
||||||
|
uppgift.status = "klar"
|
||||||
|
uppgift.resultat = resultat
|
||||||
|
self.statistik[f"{uppgift.typ}ade"] += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
uppgift.status = "fel"
|
||||||
|
uppgift.resultat = {"fel": str(e)}
|
||||||
|
self.statistik["fel"] += 1
|
||||||
|
|
||||||
|
uppgift.avslutad = datetime.utcnow().isoformat()
|
||||||
|
return uppgift.resultat
|
||||||
|
|
||||||
|
async def _kör_skordare(self, uppgift: AgentUppgift) -> Dict:
|
||||||
|
"""Kör skördare för en domän."""
|
||||||
|
# Anropa crawler.py
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
"python3", "skordare/crawler.py",
|
||||||
|
"--doman", uppgift.doman,
|
||||||
|
"--output", str(self.output_dir / "skordat"),
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await process.communicate()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"returncode": process.returncode,
|
||||||
|
"stdout": stdout.decode()[:1000],
|
||||||
|
"stderr": stderr.decode()[:1000],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _kör_extraktor(self, uppgift: AgentUppgift) -> Dict:
|
||||||
|
"""Kör extraktor för skördade dokument."""
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
"python3", "extraktor/extraktor.py",
|
||||||
|
"--input", str(self.output_dir / "skordat"),
|
||||||
|
"--output", str(self.output_dir / "extraherat"),
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await process.communicate()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"returncode": process.returncode,
|
||||||
|
"stdout": stdout.decode()[:1000],
|
||||||
|
"stderr": stderr.decode()[:1000],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _kör_upplosare(self, uppgift: AgentUppgift) -> Dict:
|
||||||
|
"""Kör entitetsupplösare."""
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
"python3", "upplosare/entitetsupplosare.py",
|
||||||
|
"--input", str(self.output_dir / "extraherat"),
|
||||||
|
"--output", str(self.output_dir / "upplost"),
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await process.communicate()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"returncode": process.returncode,
|
||||||
|
"stdout": stdout.decode()[:1000],
|
||||||
|
"stderr": stderr.decode()[:1000],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _kör_tranare(self, uppgift: AgentUppgift) -> Dict:
|
||||||
|
"""Kör vision-träning."""
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
"python3", "../vision/traening/trainer.py",
|
||||||
|
"--dataset", str(self.output_dir / "dataset"),
|
||||||
|
"--output", str(self.output_dir / "modeller"),
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await process.communicate()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"returncode": process.returncode,
|
||||||
|
"stdout": stdout.decode()[:1000],
|
||||||
|
"stderr": stderr.decode()[:1000],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def kör_alla(self, uppgifter: List[AgentUppgift]):
|
||||||
|
"""Kör alla uppgifter med begränsad parallellism."""
|
||||||
|
self.ko = sorted(uppgifter, key=lambda x: x.prioritet, reverse=True)
|
||||||
|
|
||||||
|
print(f"🚀 Startar {len(self.ko)} uppgifter (max {self.max_parallella} parallella)")
|
||||||
|
|
||||||
|
# Kör i batcher
|
||||||
|
while self.ko:
|
||||||
|
batch = self.ko[:self.max_parallella]
|
||||||
|
self.ko = self.ko[self.max_parallella:]
|
||||||
|
|
||||||
|
print(f"\n📦 Kör batch: {len(batch)} uppgifter")
|
||||||
|
tasks = [self.kör_agent(u) for u in batch]
|
||||||
|
resultat = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
for uppgift, res in zip(batch, resultat):
|
||||||
|
status = "✅" if uppgift.status == "klar" else "❌"
|
||||||
|
print(f" {status} {uppgift.typ} {uppgift.doman} T{uppgift.tier}: {uppgift.status}")
|
||||||
|
|
||||||
|
print(f"\n📊 Statistik: {self.statistik}")
|
||||||
|
|
||||||
|
def spara_rapport(self):
|
||||||
|
"""Spara körningsrapport."""
|
||||||
|
rapport = {
|
||||||
|
"tidstampel": datetime.utcnow().isoformat(),
|
||||||
|
"statistik": self.statistik,
|
||||||
|
"uppgifter": [asdict(u) for u in self.ko],
|
||||||
|
}
|
||||||
|
|
||||||
|
fil_path = self.output_dir / f"rapport_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.json"
|
||||||
|
with open(fil_path, 'w') as f:
|
||||||
|
json.dump(rapport, f, indent=2)
|
||||||
|
|
||||||
|
print(f"\n📝 Rapport sparad: {fil_path}")
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Demo: orkestrera agenter för Tier 1."""
|
||||||
|
orkestrerare = LandvexAgentOrkestrerare(max_parallella=5)
|
||||||
|
|
||||||
|
# Skapa uppgifter för Tier 1 (alla 9 domäner)
|
||||||
|
domäner = ["TRP", "ELN", "VAT", "TEL", "BYG", "PRK", "JVG", "HMN", "FLG"]
|
||||||
|
uppgifter = orkestrerare.skapa_uppgifter_for_tier(tier=1, domäner=domäner)
|
||||||
|
|
||||||
|
print(f"📋 Skapade {len(uppgifter)} uppgifter för Tier 1")
|
||||||
|
|
||||||
|
# Kör
|
||||||
|
await orkestrerare.kör_alla(uppgifter)
|
||||||
|
orkestrerare.spara_rapport()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Landvex Extraktor — LLM-baserad schemastyrd extraktion
|
||||||
|
Dokument in, strukturerade fakta ut.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
# Schema för extraktion (från lvx-objekt-schema.json)
|
||||||
|
EXTRAKTIONS_SCHEMA = {
|
||||||
|
"objektklass": {
|
||||||
|
"required": ["lvx_id", "posttyp", "namn", "doman", "proveniens"],
|
||||||
|
"fields": [
|
||||||
|
"lvx_id", "posttyp", "slug", "namn", "doman", "parent", "tier",
|
||||||
|
"identitet", "teknik", "historik", "geografi", "standarder",
|
||||||
|
"ai", "problem", "dokumentation", "relationer", "proveniens"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"produktmodell": {
|
||||||
|
"required": ["lvx_id", "posttyp", "namn", "doman", "parent", "proveniens"],
|
||||||
|
"fields": [
|
||||||
|
"lvx_id", "posttyp", "slug", "namn", "doman", "parent", "tier",
|
||||||
|
"identitet", "teknik", "historik", "geografi", "standarder",
|
||||||
|
"ai", "problem", "dokumentation", "relationer", "proveniens"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EXTRAKTIONS_PROMPT = """Du är Landvex Extraktor — en noggrann forskarassistent specialiserad på infrastrukturobjekt.
|
||||||
|
|
||||||
|
DIN UPPGIFT: Analysera följande dokument och extrahera strukturerade fakta om infrastrukturobjekt.
|
||||||
|
|
||||||
|
REGLER:
|
||||||
|
1. Extrahera ENDAST fakta som finns i dokumentet — uppfinn inget
|
||||||
|
2. Varje faktum måste ha en källa (URL eller dokumentreferens)
|
||||||
|
3. Använd Landvex-schemat strikt
|
||||||
|
4. Om något är osäkert, markera med låg konfidens
|
||||||
|
5. Problem/Kända fel kräver alltid källa
|
||||||
|
|
||||||
|
OUTPUT: JSON enligt följande struktur:
|
||||||
|
{
|
||||||
|
"kandidater": [
|
||||||
|
{
|
||||||
|
"atgard": "ny" | "uppdatera",
|
||||||
|
"posttyp": "objektklass" | "produktmodell",
|
||||||
|
"post": { ... },
|
||||||
|
"patch": { ... },
|
||||||
|
"verifieringsniva_forslag": "obekraftad" | "kallbelagd" | "faltverifierad" | "tillverkarbekraftad",
|
||||||
|
"kallor": [
|
||||||
|
{"titel": "...", "url": "...", "hamtad": "2026-07-05"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
DOKUMENT:
|
||||||
|
{content}
|
||||||
|
"""
|
||||||
|
|
||||||
|
class LandvexExtraktor:
|
||||||
|
def __init__(self, api_key: Optional[str] = None):
|
||||||
|
self.client = AsyncOpenAI(api_key=api_key or os.getenv("OPENAI_API_KEY"))
|
||||||
|
self.model = "gpt-4o-mini" # Kostnadseffektiv för volym
|
||||||
|
|
||||||
|
async def extrahera(self, dokument: str, url: str, doman: str) -> dict:
|
||||||
|
"""Extrahera strukturerade fakta från ett dokument."""
|
||||||
|
|
||||||
|
prompt = EXTRAKTIONS_PROMPT.format(content=dokument[:15000]) # Begränsa längd
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await self.client.chat.completions.create(
|
||||||
|
model=self.model,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": "Du är Landvex Extraktor. Extrahera strukturerade fakta om infrastrukturobjekt."},
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
],
|
||||||
|
response_format={"type": "json_object"},
|
||||||
|
temperature=0.1, # Låg för determinism
|
||||||
|
)
|
||||||
|
|
||||||
|
resultat = json.loads(response.choices[0].message.content)
|
||||||
|
|
||||||
|
# Lägg till proveniens
|
||||||
|
for kand in resultat.get("kandidater", []):
|
||||||
|
kand.setdefault("kallor", []).append({
|
||||||
|
"titel": f"Extraherat från {url}",
|
||||||
|
"url": url,
|
||||||
|
"hamtad": datetime.utcnow().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
return resultat
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Extraktionsfel: {e}")
|
||||||
|
return {"kandidater": [], "fel": str(e)}
|
||||||
|
|
||||||
|
async def extrahera_batch(self, dokument: List[dict]) -> List[dict]:
|
||||||
|
"""Extrahera från flera dokument parallellt."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
uppgifter = [
|
||||||
|
self.extrahera(d["html"], d["url"], d.get("doman", "TRP"))
|
||||||
|
for d in dokument
|
||||||
|
]
|
||||||
|
|
||||||
|
resultat = await asyncio.gather(*uppgifter, return_exceptions=True)
|
||||||
|
return [r for r in resultat if not isinstance(r, Exception)]
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Demo: extrahera från testdokument."""
|
||||||
|
extraktor = LandvexExtraktor()
|
||||||
|
|
||||||
|
test_doc = """
|
||||||
|
ABB Kabeldon CDC-LT är ett lågspänningskabelskåp för utomhusbruk.
|
||||||
|
Material: Varmförzinkad stålplåt, IP34D enligt SS-EN 60529.
|
||||||
|
Standard: SS-EN 61439-5 (provnings- och konstruktionskrav).
|
||||||
|
Tillverkare: ABB Kabeldon, Alingsås.
|
||||||
|
Lås: Trekantslås standard, cylinderlås som tillval.
|
||||||
|
"""
|
||||||
|
|
||||||
|
resultat = await extraktor.extrahera(test_doc, "https://example.com/test", "ELN")
|
||||||
|
print(json.dumps(resultat, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import asyncio
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Landvex Datafabrik — Pipeline-orkestrering
|
||||||
|
Tre köer: schemalagd / kunddriven / fält
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
class KoTyp(Enum):
|
||||||
|
SCHEMALAGD = "schemalagd" # Periodisk skördning
|
||||||
|
KUNDDRIVEN = "kunddriven" # Feedback → bounty → research
|
||||||
|
FALT = "falt" # Zoomer-foton → verifiering
|
||||||
|
|
||||||
|
class PipelineKo:
|
||||||
|
def __init__(self, ko_dir: Path, ko_typ: KoTyp):
|
||||||
|
self.ko_dir = ko_dir / ko_typ.value
|
||||||
|
self.ko_typ = ko_typ
|
||||||
|
self.ko_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Underkataloger
|
||||||
|
(self.ko_dir / "pending").mkdir(exist_ok=True)
|
||||||
|
(self.ko_dir / "processing").mkdir(exist_ok=True)
|
||||||
|
(self.ko_dir / "completed").mkdir(exist_ok=True)
|
||||||
|
(self.ko_dir / "failed").mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
def lagg_till(self, uppgift: dict) -> str:
|
||||||
|
"""Lägg till uppgift i kön."""
|
||||||
|
uppgift_id = hashlib.sha256(
|
||||||
|
json.dumps(uppgift, sort_keys=True).encode()
|
||||||
|
).hexdigest()[:16]
|
||||||
|
|
||||||
|
uppgift["uppgift_id"] = uppgift_id
|
||||||
|
uppgift["skapad"] = datetime.utcnow().isoformat()
|
||||||
|
uppgift["status"] = "pending"
|
||||||
|
|
||||||
|
fil_path = self.ko_dir / "pending" / f"{uppgift_id}.json"
|
||||||
|
with open(fil_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(uppgift, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
return uppgift_id
|
||||||
|
|
||||||
|
def hamta_nasta(self) -> Optional[dict]:
|
||||||
|
"""Hämta nästa uppgift från kön."""
|
||||||
|
pending = sorted(self.ko_dir / "pending" .glob("*.json"))
|
||||||
|
if not pending:
|
||||||
|
return None
|
||||||
|
|
||||||
|
fil_path = pending[0]
|
||||||
|
with open(fil_path, 'r', encoding='utf-8') as f:
|
||||||
|
uppgift = json.load(f)
|
||||||
|
|
||||||
|
# Flytta till processing
|
||||||
|
ny_path = self.ko_dir / "processing" / fil_path.name
|
||||||
|
fil_path.rename(ny_path)
|
||||||
|
uppgift["status"] = "processing"
|
||||||
|
uppgift["startad"] = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
with open(ny_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(uppgift, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
return uppgift
|
||||||
|
|
||||||
|
def markera_klar(self, uppgift_id: str, resultat: dict):
|
||||||
|
"""Markera uppgift som klar."""
|
||||||
|
processing_path = self.ko_dir / "processing" / f"{uppgift_id}.json"
|
||||||
|
if not processing_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(processing_path, 'r', encoding='utf-8') as f:
|
||||||
|
uppgift = json.load(f)
|
||||||
|
|
||||||
|
uppgift["status"] = "completed"
|
||||||
|
uppgift["avslutad"] = datetime.utcnow().isoformat()
|
||||||
|
uppgift["resultat"] = resultat
|
||||||
|
|
||||||
|
klar_path = self.ko_dir / "completed" / f"{uppgift_id}.json"
|
||||||
|
processing_path.rename(klar_path)
|
||||||
|
|
||||||
|
with open(klar_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(uppgift, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
def markera_misslyckad(self, uppgift_id: str, fel: str):
|
||||||
|
"""Markera uppgift som misslyckad."""
|
||||||
|
processing_path = self.ko_dir / "processing" / f"{uppgift_id}.json"
|
||||||
|
if not processing_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(processing_path, 'r', encoding='utf-8') as f:
|
||||||
|
uppgift = json.load(f)
|
||||||
|
|
||||||
|
uppgift["status"] = "failed"
|
||||||
|
uppgift["avslutad"] = datetime.utcnow().isoformat()
|
||||||
|
uppgift["fel"] = fel
|
||||||
|
|
||||||
|
fail_path = self.ko_dir / "failed" / f"{uppgift_id}.json"
|
||||||
|
processing_path.rename(fail_path)
|
||||||
|
|
||||||
|
with open(fail_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(uppgift, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
def statistik(self) -> dict:
|
||||||
|
"""Hämta kö-statistik."""
|
||||||
|
return {
|
||||||
|
"typ": self.ko_typ.value,
|
||||||
|
"pending": len(list((self.ko_dir / "pending").glob("*.json"))),
|
||||||
|
"processing": len(list((self.ko_dir / "processing").glob("*.json"))),
|
||||||
|
"completed": len(list((self.ko_dir / "completed").glob("*.json"))),
|
||||||
|
"failed": len(list((self.ko_dir / "failed").glob("*.json"))),
|
||||||
|
}
|
||||||
|
|
||||||
|
class DatafabrikPipeline:
|
||||||
|
def __init__(self, base_dir: Path):
|
||||||
|
self.base_dir = base_dir
|
||||||
|
self.koer = {
|
||||||
|
KoTyp.SCHEMALAGD: PipelineKo(base_dir, KoTyp.SCHEMALAGD),
|
||||||
|
KoTyp.KUNDDRIVEN: PipelineKo(base_dir, KoTyp.KUNDDRIVEN),
|
||||||
|
KoTyp.FALT: PipelineKo(base_dir, KoTyp.FALT),
|
||||||
|
}
|
||||||
|
|
||||||
|
def lagg_till_skordning(self, doman: str, kallor: List[str], prioritet: int = 5):
|
||||||
|
"""Schemalägg en skördning."""
|
||||||
|
return self.koer[KoTyp.SCHEMALAGD].lagg_till({
|
||||||
|
"typ": "skordning",
|
||||||
|
"doman": doman,
|
||||||
|
"kallor": kallor,
|
||||||
|
"prioritet": prioritet,
|
||||||
|
})
|
||||||
|
|
||||||
|
def lagg_till_bounty(self, lvx_id: str, position: str, beskrivning: str):
|
||||||
|
"""Kunddriven bounty → research-uppgift."""
|
||||||
|
return self.koer[KoTyp.KUNDDRIVEN].lagg_till({
|
||||||
|
"typ": "bounty_research",
|
||||||
|
"lvx_id": lvx_id,
|
||||||
|
"position": position,
|
||||||
|
"beskrivning": beskrivning,
|
||||||
|
})
|
||||||
|
|
||||||
|
def lagg_till_faltverifiering(self, foto_id: str, lvx_id: str, zoomer_id: str):
|
||||||
|
"""Zoomer-foto → verifiering."""
|
||||||
|
return self.koer[KoTyp.FALT].lagg_till({
|
||||||
|
"typ": "faltverifiering",
|
||||||
|
"foto_id": foto_id,
|
||||||
|
"lvx_id": lvx_id,
|
||||||
|
"zoomer_id": zoomer_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
def statistik(self) -> dict:
|
||||||
|
"""Hämta statistik för alla köer."""
|
||||||
|
return {k.value: v.statistik() for k, v in self.koer.items()}
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Demo: skapa pipeline och lägg till uppgifter."""
|
||||||
|
pipeline = DatafabrikPipeline(Path("/tmp/landvex-pipeline"))
|
||||||
|
|
||||||
|
# Schemalagd skördning
|
||||||
|
id1 = pipeline.lagg_till_skordning("TRP", ["https://example.com/vagbelysning"], 1)
|
||||||
|
print(f"Schemalagd: {id1}")
|
||||||
|
|
||||||
|
# Kunddriven bounty
|
||||||
|
id2 = pipeline.lagg_till_bounty("LVX-TRP-0102", "Storgatan 12, Stockholm", "Okänd armaturmodell")
|
||||||
|
print(f"Bounty: {id2}")
|
||||||
|
|
||||||
|
# Fältverifiering
|
||||||
|
id3 = pipeline.lagg_till_faltverifiering("IMG-123", "LVX-TRP-0102", "zoomer-42")
|
||||||
|
print(f"Fält: {id3}")
|
||||||
|
|
||||||
|
print("\nStatistik:")
|
||||||
|
print(json.dumps(pipeline.statistik(), indent=2))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Datafabrik dependencies
|
||||||
|
aiohttp>=3.9
|
||||||
|
beautifulsoup4>=4.12
|
||||||
|
openai>=1.30
|
||||||
|
neo4j>=5.19
|
||||||
|
pydantic>=2.6
|
||||||
|
python-dotenv>=1.0
|
||||||
|
requests>=2.31
|
||||||
|
pillow>=10.0
|
||||||
|
torch>=2.2
|
||||||
|
torchvision>=0.17
|
||||||
|
transformers>=4.40
|
||||||
|
sentence-transformers>=2.7
|
||||||
|
scikit-learn>=1.4
|
||||||
|
numpy>=1.26
|
||||||
|
pandas>=2.2
|
||||||
|
tqdm>=4.66
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Landvex Skördare — Webb-crawler för tillverkardata
|
||||||
|
Respekterar robots.txt, crawl-fördröjning, vitlista.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import hashlib
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urljoin, urlparse
|
||||||
|
from typing import Set, List, Dict, Optional
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
from kallvitlista import validera_url, hamta_kallor_for_doman
|
||||||
|
|
||||||
|
class LandvexCrawler:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
output_dir: Path,
|
||||||
|
delay_seconds: float = 1.0,
|
||||||
|
max_pages_per_domain: int = 100,
|
||||||
|
max_depth: int = 3
|
||||||
|
):
|
||||||
|
self.output_dir = output_dir
|
||||||
|
self.delay = delay_seconds
|
||||||
|
self.max_pages = max_pages_per_domain
|
||||||
|
self.max_depth = max_depth
|
||||||
|
self.visited: Set[str] = set()
|
||||||
|
self.session: Optional[aiohttp.ClientSession] = None
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
self.session = aiohttp.ClientSession(
|
||||||
|
headers={
|
||||||
|
"User-Agent": "LandvexBot/1.0 (Research; https://landvex.io/bot)"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args):
|
||||||
|
if self.session:
|
||||||
|
await self.session.close()
|
||||||
|
|
||||||
|
async def hamta(self, url: str) -> Optional[str]:
|
||||||
|
"""Hämta URL med felhantering."""
|
||||||
|
if not validera_url(url):
|
||||||
|
print(f" ⚠️ URL ej på vitlistan: {url}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with self.session.get(url, timeout=30) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
return await resp.text()
|
||||||
|
print(f" ⚠️ HTTP {resp.status}: {url}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Fel: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extrahera_lankar(self, html: str, base_url: str) -> List[str]:
|
||||||
|
"""Extrahera alla länkar från HTML."""
|
||||||
|
soup = BeautifulSoup(html, 'html.parser')
|
||||||
|
lankar = []
|
||||||
|
for a in soup.find_all('a', href=True):
|
||||||
|
href = urljoin(base_url, a['href'])
|
||||||
|
if validera_url(href):
|
||||||
|
lankar.append(href)
|
||||||
|
return lankar
|
||||||
|
|
||||||
|
def spara_dokument(self, url: str, html: str, metadata: dict):
|
||||||
|
"""Spara skördat dokument med metadata."""
|
||||||
|
doc_id = hashlib.sha256(url.encode()).hexdigest()[:16]
|
||||||
|
timestamp = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
doc = {
|
||||||
|
"doc_id": doc_id,
|
||||||
|
"url": url,
|
||||||
|
"hamtat": timestamp,
|
||||||
|
"metadata": metadata,
|
||||||
|
"html_hash": hashlib.sha256(html.encode()).hexdigest()[:16],
|
||||||
|
"html_length": len(html),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Spara metadata
|
||||||
|
meta_path = self.output_dir / "metadata" / f"{doc_id}.json"
|
||||||
|
meta_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(meta_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(doc, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
# Spara rå HTML (för extraktion)
|
||||||
|
html_path = self.output_dir / "raw" / f"{doc_id}.html"
|
||||||
|
html_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(html_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(html)
|
||||||
|
|
||||||
|
return doc_id
|
||||||
|
|
||||||
|
async def crawla_doman(
|
||||||
|
self,
|
||||||
|
start_url: str,
|
||||||
|
doman: str,
|
||||||
|
djup: int = 0
|
||||||
|
) -> List[dict]:
|
||||||
|
"""Crawla en start-URL och följ länkar."""
|
||||||
|
if djup > self.max_depth:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if start_url in self.visited:
|
||||||
|
return []
|
||||||
|
|
||||||
|
self.visited.add(start_url)
|
||||||
|
|
||||||
|
print(f" 🔍 [{djup}] {start_url}")
|
||||||
|
html = await self.hamta(start_url)
|
||||||
|
if not html:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Spara dokumentet
|
||||||
|
metadata = {
|
||||||
|
"doman": doman,
|
||||||
|
"crawl_djup": djup,
|
||||||
|
"kalla": urlparse(start_url).netloc,
|
||||||
|
}
|
||||||
|
doc_id = self.spara_dokument(start_url, html, metadata)
|
||||||
|
|
||||||
|
resultat = [{"doc_id": doc_id, "url": start_url}]
|
||||||
|
|
||||||
|
# Följ länkar om vi inte nått max
|
||||||
|
if len(self.visited) < self.max_pages:
|
||||||
|
lankar = self.extrahera_lankar(html, start_url)
|
||||||
|
for lank in lankar[:5]: # Begränsa per sida
|
||||||
|
await asyncio.sleep(self.delay)
|
||||||
|
under = await self.crawla_doman(lank, doman, djup + 1)
|
||||||
|
resultat.extend(under)
|
||||||
|
|
||||||
|
return resultat
|
||||||
|
|
||||||
|
async def skorda_doman(self, doman: str) -> dict:
|
||||||
|
"""Skörda alla källor för en domän."""
|
||||||
|
print(f"\n🌾 Skördar domän: {doman}")
|
||||||
|
|
||||||
|
kallor = hamta_kallor_for_doman(doman)
|
||||||
|
alla_dokument = []
|
||||||
|
|
||||||
|
for kategori, data in kallor.items():
|
||||||
|
print(f" 📂 {kategori}: {data['beskrivning']}")
|
||||||
|
|
||||||
|
urls = []
|
||||||
|
if "källor" in data:
|
||||||
|
sources = data["källor"]
|
||||||
|
if isinstance(sources, dict):
|
||||||
|
for land, land_urls in sources.items():
|
||||||
|
urls.extend(land_urls)
|
||||||
|
else:
|
||||||
|
urls.extend(sources)
|
||||||
|
if "exempel" in data:
|
||||||
|
urls.extend(data["exempel"])
|
||||||
|
|
||||||
|
for url in urls[:3]: # Begränsa initialt
|
||||||
|
try:
|
||||||
|
docs = await self.crawla_doman(url, doman)
|
||||||
|
alla_dokument.extend(docs)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Fel vid crawling: {e}")
|
||||||
|
|
||||||
|
rapport = {
|
||||||
|
"doman": doman,
|
||||||
|
"skordad": datetime.utcnow().isoformat(),
|
||||||
|
"dokument": len(alla_dokument),
|
||||||
|
"unika_kallor": len(set(d["url"] for d in alla_dokument)),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Spara rapport
|
||||||
|
rapport_path = self.output_dir / "rapporter" / f"{doman}_{datetime.utcnow().strftime('%Y%m%d')}.json"
|
||||||
|
rapport_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(rapport_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(rapport, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f" ✅ {rapport['dokument']} dokument skördade")
|
||||||
|
return rapport
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Demo: skörda TRP-domanen."""
|
||||||
|
output = Path("/tmp/landvex-skord")
|
||||||
|
output.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
async with LandvexCrawler(output) as crawler:
|
||||||
|
resultat = await crawler.skorda_doman("TRP")
|
||||||
|
print(f"\n📊 Resultat: {resultat}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Landvex Skördare — Källvitlista v1
|
||||||
|
Godkända källor för automatisk skördning per domän.
|
||||||
|
"""
|
||||||
|
|
||||||
|
KALLVITLISTA = {
|
||||||
|
"tillverkare": {
|
||||||
|
"beskrivning": "Tillverkarkataloger, datablad, manualer",
|
||||||
|
"exempel": [
|
||||||
|
"https://www.abb.com/products",
|
||||||
|
"https://www.signify.com/global/products",
|
||||||
|
"https://www.thornlighting.com/en-gb/products",
|
||||||
|
"https://www.schreder.com/en/products",
|
||||||
|
"https://www.hewig.de/produkte",
|
||||||
|
"https://www.lighting.philips.com/",
|
||||||
|
"https://www.cree.com/led-components",
|
||||||
|
"https://www.acuitybrands.com/products",
|
||||||
|
"https://www.hubbell.com/hubbell-lighting",
|
||||||
|
"https://www.lsi-industries.com/products",
|
||||||
|
],
|
||||||
|
"regler": [
|
||||||
|
"Endast publika produktsidor, ej inloggning",
|
||||||
|
"Extrahera fakta, kopiera aldrig hela dokument",
|
||||||
|
"Respektera robots.txt och crawl-fördröjning",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"upphandling": {
|
||||||
|
"beskrivning": "Offentliga upphandlingar — guld för installerad-bas-data",
|
||||||
|
"källor": {
|
||||||
|
"EU": ["https://ted.europa.eu/", "https://ec.europa.eu/growth/single-market/public-procurement"],
|
||||||
|
"Sverige": ["https://www.mercell.com/", "https://www.opic.com/", "https://www.upphandling24.se/"],
|
||||||
|
"Norge": ["https://doffin.no/", "https://www.eu-supply.com/"],
|
||||||
|
"Danmark": ["https://www.ethics.dk/", "https://www.udbudsportalen.dk/"],
|
||||||
|
"Finland": ["https://www.hankintailmoitukset.fi/", "https://www.tarjouspalvelu.fi/"],
|
||||||
|
"Tyskland": ["https://www.vergabe24.de/", "https://www.bund.de/"],
|
||||||
|
"USA": ["https://sam.gov/", "https://www.usaspending.gov/"],
|
||||||
|
"UK": ["https://www.contractsfinder.service.gov.uk/", "https://www.find-tender.service.gov.uk/"],
|
||||||
|
},
|
||||||
|
"regler": [
|
||||||
|
"Extrahera: produkt, tillverkare, plats, tidpunkt, volym",
|
||||||
|
"Lagra referens, aldrig hela dokument",
|
||||||
|
"Offentliga upphandlingar = public domain fakta",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"standarder": {
|
||||||
|
"beskrivning": "Standardiseringsorgan — beteckningar och metadata",
|
||||||
|
"källor": {
|
||||||
|
"IEC": ["https://webstore.iec.ch/"],
|
||||||
|
"CENELEC": ["https://www.cenelec.eu/"],
|
||||||
|
"ISO": ["https://www.iso.org/standards.html"],
|
||||||
|
"EN": ["https://standards.cen.eu/"],
|
||||||
|
"ANSI": ["https://webstore.ansi.org/"],
|
||||||
|
"ASTM": ["https://www.astm.org/standards/"],
|
||||||
|
"IEEE": ["https://standards.ieee.org/"],
|
||||||
|
"BSI": ["https://shop.bsigroup.com/"],
|
||||||
|
"DIN": ["https://www.din.de/"],
|
||||||
|
"SS": ["https://www.sis.se/"],
|
||||||
|
},
|
||||||
|
"regler": [
|
||||||
|
"Endast beteckning, titel, tillämpningsområde",
|
||||||
|
"ALDRIG standardtext — köpta standarder är skyddade",
|
||||||
|
"Referera med URL till standardens sida",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"patent": {
|
||||||
|
"beskrivning": "Patentdatabaser — produkthistorik och generationsskiften",
|
||||||
|
"källor": [
|
||||||
|
"https://patents.google.com/",
|
||||||
|
"https://www.epo.org/searching-for-patents.html",
|
||||||
|
"https://www.uspto.gov/patents/search",
|
||||||
|
"https://www.wipo.int/patents/en/",
|
||||||
|
],
|
||||||
|
"regler": [
|
||||||
|
"Fria att referera och analysera",
|
||||||
|
"Extrahera: uppfinnare, tillverkare, datum, teknik",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"myndigheter": {
|
||||||
|
"beskrivning": "Myndighetsdokument och typgodkännanden",
|
||||||
|
"källor": {
|
||||||
|
"Sverige": ["https://www.transportstyrelsen.se/", "https://www.boverket.se/"],
|
||||||
|
"EU": ["https://eur-lex.europa.eu/", "https://ec.europa.eu/growth/single-market/european-standards/harmonised-standards"],
|
||||||
|
"USA": ["https://www.fhwa.dot.gov/", "https://www.osha.gov/"],
|
||||||
|
},
|
||||||
|
"regler": [
|
||||||
|
"Amerikanska federala dokument ofta public domain",
|
||||||
|
"Extrahera fakta, länka källan",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"gis": {
|
||||||
|
"beskrivning": "Öppna GIS-data och anläggningsregister",
|
||||||
|
"källor": [
|
||||||
|
"https://www.openstreetmap.org/ (ODbL — separat spår)",
|
||||||
|
"https://www.geodata.se/",
|
||||||
|
"https://www.lantmateriet.se/",
|
||||||
|
"https://www.usgs.gov/",
|
||||||
|
],
|
||||||
|
"regler": [
|
||||||
|
"OSM: ODbL kräver attribution och delningsvillkor",
|
||||||
|
"Håll i separat spår tills licensstrategi beslutad",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"forum": {
|
||||||
|
"beskrivning": "Branschforum och installatörscommunities",
|
||||||
|
"exempel": [
|
||||||
|
"https://www.elektrikerna.se/",
|
||||||
|
"https://www.byggnads.se/",
|
||||||
|
"https://www.reddit.com/r/electricians/",
|
||||||
|
"https://www.reddit.com/r/civilengineering/",
|
||||||
|
],
|
||||||
|
"regler": [
|
||||||
|
"Endast verifierade fältobservationer",
|
||||||
|
"Källhänvisning obligatorisk",
|
||||||
|
"AI-moderering för kvalitet",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def validera_url(url: str, kategori: str = None) -> bool:
|
||||||
|
"""Kontrollera om URL finns på vitlistan."""
|
||||||
|
for kat, data in KALLVITLISTA.items():
|
||||||
|
if kategori and kat != kategori:
|
||||||
|
continue
|
||||||
|
sources = data.get("källor", {})
|
||||||
|
if isinstance(sources, dict):
|
||||||
|
for land, urls in sources.items():
|
||||||
|
if any(url.startswith(u) for u in urls):
|
||||||
|
return True
|
||||||
|
elif isinstance(sources, list):
|
||||||
|
if any(url.startswith(u) for u in sources):
|
||||||
|
return True
|
||||||
|
for ex in data.get("exempel", []):
|
||||||
|
if url.startswith(ex):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def hamta_kallor_for_doman(doman: str) -> dict:
|
||||||
|
"""Hämta relevanta källor för en domän."""
|
||||||
|
mapping = {
|
||||||
|
"TRP": ["tillverkare", "upphandling", "standarder", "myndigheter", "gis"],
|
||||||
|
"ELN": ["tillverkare", "upphandling", "standarder", "myndigheter"],
|
||||||
|
"VAT": ["tillverkare", "upphandling", "standarder", "myndigheter", "gis"],
|
||||||
|
"TEL": ["tillverkare", "upphandling", "standarder", "myndigheter"],
|
||||||
|
"BYG": ["tillverkare", "upphandling", "standarder", "myndigheter"],
|
||||||
|
"PRK": ["tillverkare", "upphandling", "standarder", "myndigheter"],
|
||||||
|
"JVG": ["tillverkare", "upphandling", "standarder", "myndigheter"],
|
||||||
|
"HMN": ["tillverkare", "upphandling", "standarder", "myndigheter"],
|
||||||
|
"FLG": ["tillverkare", "upphandling", "standarder", "myndigheter"],
|
||||||
|
}
|
||||||
|
kategorier = mapping.get(doman, [])
|
||||||
|
return {k: KALLVITLISTA[k] for k in kategorier if k in KALLVITLISTA}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Landvex Källvitlista")
|
||||||
|
print("=" * 50)
|
||||||
|
for kat, data in KALLVITLISTA.items():
|
||||||
|
print(f"\n{kat.upper()}: {data['beskrivning']}")
|
||||||
|
print(f" Regler: {len(data['regler'])}")
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Landvex Skördare — Offentliga upphandlingar
|
||||||
|
Guld för installerad-bas-data: produkt, tillverkare, plats, tidpunkt, volym.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UpphandlingsPost:
|
||||||
|
dokument_id: str
|
||||||
|
kalla: str # URL till upphandlingen
|
||||||
|
land: str
|
||||||
|
myndighet: str
|
||||||
|
produkt: str # Vad som upphandlades
|
||||||
|
tillverkare: str # Angiven eller vinnande tillverkare
|
||||||
|
plats: str # Geografisk plats
|
||||||
|
tidpunkt: str # Upphandlingsdatum
|
||||||
|
volym: Optional[str] # Antal, värde, etc.
|
||||||
|
konfidens: float
|
||||||
|
|
||||||
|
class UpphandlingsSkordare:
|
||||||
|
"""Skördar offentliga upphandlingar för Landvex."""
|
||||||
|
|
||||||
|
KALLOR = {
|
||||||
|
"Sverige": [
|
||||||
|
"https://www.mercell.com/",
|
||||||
|
"https://www.opic.com/",
|
||||||
|
],
|
||||||
|
"EU": [
|
||||||
|
"https://ted.europa.eu/",
|
||||||
|
],
|
||||||
|
"Norge": [
|
||||||
|
"https://doffin.no/",
|
||||||
|
],
|
||||||
|
"Danmark": [
|
||||||
|
"https://www.udbudsportalen.dk/",
|
||||||
|
],
|
||||||
|
"Tyskland": [
|
||||||
|
"https://www.vergabe24.de/",
|
||||||
|
],
|
||||||
|
"USA": [
|
||||||
|
"https://sam.gov/",
|
||||||
|
"https://www.usaspending.gov/",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, output_dir: Path):
|
||||||
|
self.output_dir = output_dir
|
||||||
|
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.session: Optional[aiohttp.ClientSession] = None
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
self.session = aiohttp.ClientSession(
|
||||||
|
headers={
|
||||||
|
"User-Agent": "LandvexBot/1.0 (Research; https://landvex.io/bot)"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args):
|
||||||
|
if self.session:
|
||||||
|
await self.session.close()
|
||||||
|
|
||||||
|
async def hamta_sida(self, url: str) -> Optional[str]:
|
||||||
|
"""Hämta HTML från URL."""
|
||||||
|
try:
|
||||||
|
async with self.session.get(url, timeout=30) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
return await resp.text()
|
||||||
|
print(f" ⚠️ HTTP {resp.status}: {url}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Fel: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extrahera_upphandlingar(self, html: str, kalla: str, land: str) -> List[UpphandlingsPost]:
|
||||||
|
"""Extrahera upphandlingsposter från HTML."""
|
||||||
|
soup = BeautifulSoup(html, 'html.parser')
|
||||||
|
poster = []
|
||||||
|
|
||||||
|
# Hitta upphandlingslistningar (anpassas per källa)
|
||||||
|
for item in soup.find_all(['tr', 'div', 'article'], class_=lambda x: x and ('tender' in x.lower() or 'contract' in x.lower() or 'upphandling' in x.lower())):
|
||||||
|
try:
|
||||||
|
# Extrahera fält
|
||||||
|
produkt = self._extrahera_text(item, ['title', 'description', 'objekt'])
|
||||||
|
myndighet = self._extrahera_text(item, ['authority', 'buyer', 'upphandlande'])
|
||||||
|
plats = self._extrahera_text(item, ['place', 'location', 'ort'])
|
||||||
|
datum = self._extrahera_text(item, ['date', 'deadline', 'publicerad'])
|
||||||
|
|
||||||
|
if produkt: # Minst produkt måste finnas
|
||||||
|
post = UpphandlingsPost(
|
||||||
|
dokument_id=self._generera_id(produkt + datum),
|
||||||
|
kalla=kalla,
|
||||||
|
land=land,
|
||||||
|
myndighet=myndighet or "Okänd",
|
||||||
|
produkt=produkt,
|
||||||
|
tillverkare="", # Kräver djupanalys
|
||||||
|
plats=plats or "",
|
||||||
|
tidpunkt=datum or "",
|
||||||
|
volym=None,
|
||||||
|
konfidens=0.7 if myndighet else 0.5,
|
||||||
|
)
|
||||||
|
poster.append(post)
|
||||||
|
except Exception as e:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return poster
|
||||||
|
|
||||||
|
def _extrahera_text(self, element, klasser: List[str]) -> Optional[str]:
|
||||||
|
"""Hjälp: extrahera text från element med matchande klass."""
|
||||||
|
for klass in klasser:
|
||||||
|
found = element.find(class_=lambda x: x and klass in x.lower())
|
||||||
|
if found:
|
||||||
|
return found.get_text(strip=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _generera_id(self, text: str) -> str:
|
||||||
|
"""Generera unikt ID från text."""
|
||||||
|
import hashlib
|
||||||
|
return hashlib.sha256(text.encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
def spara_poster(self, poster: List[UpphandlingsPost], land: str):
|
||||||
|
"""Spara upphandlingsposter."""
|
||||||
|
timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S')
|
||||||
|
fil_path = self.output_dir / f"upphandlingar_{land}_{timestamp}.json"
|
||||||
|
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
"dokument_id": p.dokument_id,
|
||||||
|
"kalla": p.kalla,
|
||||||
|
"land": p.land,
|
||||||
|
"myndighet": p.myndighet,
|
||||||
|
"produkt": p.produkt,
|
||||||
|
"tillverkare": p.tillverkare,
|
||||||
|
"plats": p.plats,
|
||||||
|
"tidpunkt": p.tidpunkt,
|
||||||
|
"volym": p.volym,
|
||||||
|
"konfidens": p.konfidens,
|
||||||
|
"skordad": datetime.utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
for p in poster
|
||||||
|
]
|
||||||
|
|
||||||
|
with open(fil_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f" 💾 {len(poster)} poster sparade: {fil_path}")
|
||||||
|
|
||||||
|
async def skorda_land(self, land: str, max_sidor: int = 5) -> dict:
|
||||||
|
"""Skörda upphandlingar för ett land."""
|
||||||
|
print(f"\n🌾 Skördar upphandlingar: {land}")
|
||||||
|
|
||||||
|
kallor = self.KALLOR.get(land, [])
|
||||||
|
alla_poster = []
|
||||||
|
|
||||||
|
for kalla in kallor[:2]: # Begränsa initialt
|
||||||
|
print(f" 🔍 {kalla}")
|
||||||
|
html = await self.hamta_sida(kalla)
|
||||||
|
if html:
|
||||||
|
poster = self.extrahera_upphandlingar(html, kalla, land)
|
||||||
|
alla_poster.extend(poster)
|
||||||
|
print(f" ✅ {len(poster)} poster")
|
||||||
|
|
||||||
|
# Spara
|
||||||
|
if alla_poster:
|
||||||
|
self.spara_poster(alla_poster, land)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"land": land,
|
||||||
|
"poster": len(alla_poster),
|
||||||
|
"kallor": len(kallor),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Demo: skörda svenska upphandlingar."""
|
||||||
|
output = Path("/tmp/landvex-upphandlingar")
|
||||||
|
|
||||||
|
async with UpphandlingsSkordare(output) as skordare:
|
||||||
|
resultat = await skordare.skorda_land("Sverige", max_sidor=2)
|
||||||
|
print(f"\n📊 Resultat: {resultat}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Landvex Upplösare — Entitetsupplösning
|
||||||
|
Slår ihop dubbletter till kanoniska Landvex-ID:n.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import difflib
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Tuple, Optional
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from sklearn.feature_extraction.text import TfidfVectorizer
|
||||||
|
from sklearn.metrics.pairwise import cosine_similarity
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EntitetsMatch:
|
||||||
|
kandidat_id: str
|
||||||
|
befintlig_id: str
|
||||||
|
likhet: float
|
||||||
|
match_typ: str # "namn", "spec", "bild", "kombinerad"
|
||||||
|
rekommendation: str # "sammanfoga", "granska", "separat"
|
||||||
|
|
||||||
|
class EntitetsUpplosare:
|
||||||
|
"""Upplöser entiteter och föreslår sammanslagningar."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
namn_tröskel: float = 0.85,
|
||||||
|
spec_tröskel: float = 0.90,
|
||||||
|
bild_tröskel: float = 0.95,
|
||||||
|
):
|
||||||
|
self.namn_tröskel = namn_tröskel
|
||||||
|
self.spec_tröskel = spec_tröskel
|
||||||
|
self.bild_tröskel = bild_tröskel
|
||||||
|
|
||||||
|
self.vectorizer = TfidfVectorizer(
|
||||||
|
analyzer='word',
|
||||||
|
ngram_range=(1, 3),
|
||||||
|
min_df=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def namn_likhet(self, namn1: str, namn2: str) -> float:
|
||||||
|
"""Beräkna likhet mellan två namn."""
|
||||||
|
# Exakt match
|
||||||
|
if namn1.lower() == namn2.lower():
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
# Fuzzy match
|
||||||
|
return difflib.SequenceMatcher(None, namn1.lower(), namn2.lower()).ratio()
|
||||||
|
|
||||||
|
def spec_likhet(self, spec1: Dict, spec2: Dict) -> float:
|
||||||
|
"""Beräkna likhet mellan tekniska specifikationer."""
|
||||||
|
# Jämför nyckelfält
|
||||||
|
nyckel_falt = ["material", "ip_klass", "belastningsklass", "dimensioner"]
|
||||||
|
|
||||||
|
matchande = 0
|
||||||
|
totala = 0
|
||||||
|
|
||||||
|
for falt in nyckel_falt:
|
||||||
|
v1 = spec1.get(falt, "")
|
||||||
|
v2 = spec2.get(falt, "")
|
||||||
|
|
||||||
|
if v1 and v2:
|
||||||
|
totala += 1
|
||||||
|
if isinstance(v1, str) and isinstance(v2, str):
|
||||||
|
if v1.lower() == v2.lower():
|
||||||
|
matchande += 1
|
||||||
|
elif v1 == v2:
|
||||||
|
matchande += 1
|
||||||
|
|
||||||
|
return matchande / totala if totala > 0 else 0
|
||||||
|
|
||||||
|
def bild_likhet(self, emb1: np.ndarray, emb2: np.ndarray) -> float:
|
||||||
|
"""Beräkna likhet mellan bildembeddings."""
|
||||||
|
# Cosine similarity
|
||||||
|
return float(np.dot(emb1, emb2) / (np.linalg.norm(emb1) * np.linalg.norm(emb2)))
|
||||||
|
|
||||||
|
def hitta_dubbletter(
|
||||||
|
self,
|
||||||
|
kandidat: Dict,
|
||||||
|
befintliga: List[Dict],
|
||||||
|
) -> List[EntitetsMatch]:
|
||||||
|
"""Hitta potentiella dubbletter för en kandidat."""
|
||||||
|
matcher = []
|
||||||
|
|
||||||
|
for bef in befintliga:
|
||||||
|
likheter = []
|
||||||
|
|
||||||
|
# Namnlikhet
|
||||||
|
namn1 = kandidat.get("namn", {}).get("sv", "")
|
||||||
|
namn2 = bef.get("namn", {}).get("sv", "")
|
||||||
|
if namn1 and namn2:
|
||||||
|
nl = self.namn_likhet(namn1, namn2)
|
||||||
|
if nl >= self.namn_tröskel:
|
||||||
|
likheter.append(("namn", nl))
|
||||||
|
|
||||||
|
# Speclikhet
|
||||||
|
spec1 = kandidat.get("teknik", {})
|
||||||
|
spec2 = bef.get("teknik", {})
|
||||||
|
if spec1 and spec2:
|
||||||
|
sl = self.spec_likhet(spec1, spec2)
|
||||||
|
if sl >= self.spec_tröskel:
|
||||||
|
likheter.append(("spec", sl))
|
||||||
|
|
||||||
|
# Kombinerad likhet
|
||||||
|
if likheter:
|
||||||
|
medel = np.mean([l[1] for l in likheter])
|
||||||
|
|
||||||
|
# Rekommendation
|
||||||
|
if medel > 0.95:
|
||||||
|
rekommendation = "sammanfoga"
|
||||||
|
elif medel > 0.85:
|
||||||
|
rekommendation = "granska"
|
||||||
|
else:
|
||||||
|
rekommendation = "separat"
|
||||||
|
|
||||||
|
matcher.append(EntitetsMatch(
|
||||||
|
kandidat_id=kandidat.get("lvx_id", "?"),
|
||||||
|
befintlig_id=bef.get("lvx_id", "?"),
|
||||||
|
likhet=medel,
|
||||||
|
match_typ=" + ".join([l[0] for l in likheter]),
|
||||||
|
rekommendation=rekommendation,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Sortera efter likhet
|
||||||
|
matcher.sort(key=lambda x: x.likhet, reverse=True)
|
||||||
|
return matcher[:5] # Top 5
|
||||||
|
|
||||||
|
def sammanfoga(self, kandidat: Dict, befintlig: Dict) -> Dict:
|
||||||
|
"""Sammanfoga två poster till en kanonisk post."""
|
||||||
|
resultat = befintlig.copy()
|
||||||
|
|
||||||
|
# Merge källor
|
||||||
|
kallor1 = set(json.dumps(k, sort_keys=True) for k in kandidat.get("proveniens", {}).get("kallor", []))
|
||||||
|
kallor2 = set(json.dumps(k, sort_keys=True) for k in befintlig.get("proveniens", {}).get("kallor", []))
|
||||||
|
alla_kallor = [json.loads(k) for k in kallor1 | kallor2]
|
||||||
|
|
||||||
|
resultat.setdefault("proveniens", {})["kallor"] = alla_kallor
|
||||||
|
|
||||||
|
# Höj verifieringsnivå om möjligt
|
||||||
|
verif_rank = {"obekraftad": 0, "kallbelagd": 1, "faltverifierad": 2, "tillverkarbekraftad": 3}
|
||||||
|
v1 = verif_rank.get(kandidat.get("proveniens", {}).get("verifieringsniva", "obekraftad"), 0)
|
||||||
|
v2 = verif_rank.get(befintlig.get("proveniens", {}).get("verifieringsniva", "obekraftad"), 0)
|
||||||
|
resultat["proveniens"]["verifieringsniva"] = max([v1, v2], key=lambda x: verif_rank.get(x, 0))
|
||||||
|
|
||||||
|
return resultat
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Demo: entitetsupplösning."""
|
||||||
|
print("🔗 Landvex Entitetsupplösare")
|
||||||
|
|
||||||
|
upplosare = EntitetsUpplosare()
|
||||||
|
|
||||||
|
# Testdata
|
||||||
|
kandidat = {
|
||||||
|
"lvx_id": "KAND-001",
|
||||||
|
"namn": {"sv": "ABB Kabeldon CDC-LT", "en": "ABB Kabeldon CDC-LT"},
|
||||||
|
"teknik": {"material": "stål", "ip_klass": "IP34D"},
|
||||||
|
"proveniens": {"verifieringsniva": "kallbelagd", "kallor": [{"titel": "Test"}]},
|
||||||
|
}
|
||||||
|
|
||||||
|
befintliga = [
|
||||||
|
{
|
||||||
|
"lvx_id": "LVX-ELN-0101",
|
||||||
|
"namn": {"sv": "Kabelskåp, lågspänning", "en": "LV distribution cabinet"},
|
||||||
|
"teknik": {"material": "stål", "ip_klass": "IP34D"},
|
||||||
|
"proveniens": {"verifieringsniva": "kallbelagd", "kallor": []},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lvx_id": "LVX-ELN-0102",
|
||||||
|
"namn": {"sv": "Elstolpe", "en": "Utility pole"},
|
||||||
|
"teknik": {"material": "trä", "ip_klass": ""},
|
||||||
|
"proveniens": {"verifieringsniva": "kallbelagd", "kallor": []},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
matcher = upplosare.hitta_dubbletter(kandidat, befintliga)
|
||||||
|
|
||||||
|
print("\n🎯 Matchningar:")
|
||||||
|
for m in matcher:
|
||||||
|
print(f" {m.kandidat_id} ↔ {m.befintlig_id}")
|
||||||
|
print(f" Likhet: {m.likhet:.3f} ({m.match_typ})")
|
||||||
|
print(f" Rekommendation: {m.rekommendation}")
|
||||||
|
|
||||||
|
if matcher and matcher[0].rekommendation == "sammanfoga":
|
||||||
|
sammanfogad = upplosare.sammanfoga(kandidat, befintliga[0])
|
||||||
|
print(f"\n✅ Sammanfogad: {sammanfogad['lvx_id']}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ─── API ───────────────────────────────────────────────
|
||||||
|
api:
|
||||||
|
build: ./api
|
||||||
|
ports:
|
||||||
|
- "8081:8080"
|
||||||
|
volumes:
|
||||||
|
- ./10-data:/app/data:ro
|
||||||
|
- ./vision/modeller:/models:ro
|
||||||
|
environment:
|
||||||
|
- DATA_DIR=/app/data
|
||||||
|
- NEO4J_URI=bolt://neo4j:7687
|
||||||
|
- NEO4J_USER=neo4j
|
||||||
|
- NEO4J_PASSWORD=${NEO4J_PASSWORD}
|
||||||
|
- VISION_MODEL_PATH=/models/landvex-vision-model.pt
|
||||||
|
depends_on:
|
||||||
|
- neo4j
|
||||||
|
- vectordb
|
||||||
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 2G
|
||||||
|
|
||||||
|
# ─── Neo4j (Property Graph) ────────────────────────────
|
||||||
|
neo4j:
|
||||||
|
image: neo4j:5.19-enterprise
|
||||||
|
ports:
|
||||||
|
- "7474:7474"
|
||||||
|
- "7687:7687"
|
||||||
|
environment:
|
||||||
|
- NEO4J_AUTH=neo4j/${NEO4J_PASSWORD}
|
||||||
|
- NEO4J_PLUGINS=["apoc"]
|
||||||
|
- NEO4J_dbms_memory_heap_max__size=8G
|
||||||
|
- NEO4J_dbms_memory_pagecache_size=4G
|
||||||
|
volumes:
|
||||||
|
- neo4j_data:/data
|
||||||
|
- neo4j_logs:/logs
|
||||||
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 12G
|
||||||
|
|
||||||
|
# ─── Vektordatabas (FAISS) ─────────────────────────────
|
||||||
|
vectordb:
|
||||||
|
build: ./vision/embeddings
|
||||||
|
ports:
|
||||||
|
- "8082:8080"
|
||||||
|
volumes:
|
||||||
|
- vectordb_data:/data
|
||||||
|
environment:
|
||||||
|
- FAISS_INDEX_DIR=/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ─── Datafabrik (Skördning + Extraktion) ───────────────
|
||||||
|
datafabrik:
|
||||||
|
build: ./datafabrik
|
||||||
|
volumes:
|
||||||
|
- datafabrik_output:/data/output
|
||||||
|
- datafabrik_koer:/data/koer
|
||||||
|
environment:
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||||
|
- DATAFABRIK_KO_DIR=/data/koer
|
||||||
|
- DATAFABRIK_OUTPUT=/data/output
|
||||||
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 4G
|
||||||
|
|
||||||
|
# ─── Vision-träning (GPU) ──────────────────────────────
|
||||||
|
vision-trainer:
|
||||||
|
build: ./vision
|
||||||
|
volumes:
|
||||||
|
- vision_models:/models
|
||||||
|
- vision_dataset:/data/dataset
|
||||||
|
environment:
|
||||||
|
- CUDA_VISIBLE_DEVICES=0
|
||||||
|
- VISION_MODEL_DIR=/models
|
||||||
|
- VISION_DATASET_DIR=/data/dataset
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities: [gpu]
|
||||||
|
profiles: ["training"]
|
||||||
|
|
||||||
|
# ─── MinIO (Objektlagring för bilder/dokument) ─────────
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
|
environment:
|
||||||
|
- MINIO_ROOT_USER=${MINIO_USER:-landvex}
|
||||||
|
- MINIO_ROOT_PASSWORD=${MINIO_PASSWORD:-landvex2024}
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ─── Redis (Köer och cache) ────────────────────────────
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ─── Prometheus (Metriker) ─────────────────────────────
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:latest
|
||||||
|
ports:
|
||||||
|
- "9090:9090"
|
||||||
|
volumes:
|
||||||
|
- ./infrastruktur/monitorering/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
- prometheus_data:/prometheus
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ─── Grafana (Dashboards) ──────────────────────────────
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:latest
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-landvex}
|
||||||
|
volumes:
|
||||||
|
- grafana_data:/var/lib/grafana
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
neo4j_data:
|
||||||
|
neo4j_logs:
|
||||||
|
vectordb_data:
|
||||||
|
datafabrik_output:
|
||||||
|
datafabrik_koer:
|
||||||
|
vision_models:
|
||||||
|
vision_dataset:
|
||||||
|
minio_data:
|
||||||
|
redis_data:
|
||||||
|
prometheus_data:
|
||||||
|
grafana_data:
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Landvex IOL",
|
||||||
|
"tags": ["landvex", "iol"],
|
||||||
|
"timezone": "UTC",
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"title": "API Requests/min",
|
||||||
|
"type": "graph",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "rate(landvex_api_requests_total[5m])",
|
||||||
|
"legendFormat": "{{method}} {{endpoint}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Identify Konfidens",
|
||||||
|
"type": "graph",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "histogram_quantile(0.95, rate(landvex_identify_confidence_bucket[5m]))",
|
||||||
|
"legendFormat": "p95"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "histogram_quantile(0.50, rate(landvex_identify_confidence_bucket[5m]))",
|
||||||
|
"legendFormat": "p50"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Graf-storlek",
|
||||||
|
"type": "stat",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "landvex_graph_nodes_total",
|
||||||
|
"legendFormat": "Noder"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "landvex_graph_edges_total",
|
||||||
|
"legendFormat": "Kanter"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"gridPos": {"h": 4, "w": 6, "x": 0, "y": 8}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Kö-storlek",
|
||||||
|
"type": "stat",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "landvex_queue_size",
|
||||||
|
"legendFormat": "{{queue}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"gridPos": {"h": 4, "w": 6, "x": 6, "y": 8}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Kostnad per faktum",
|
||||||
|
"type": "graph",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "landvex_cost_per_fact_usd",
|
||||||
|
"legendFormat": "USD/faktum"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 8}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
evaluation_interval: 15s
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: 'landvex-api'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['api:8080']
|
||||||
|
metrics_path: '/metrics'
|
||||||
|
|
||||||
|
- job_name: 'landvex-datafabrik'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['datafabrik:8080']
|
||||||
|
metrics_path: '/metrics'
|
||||||
|
|
||||||
|
- job_name: 'neo4j'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['neo4j:7474']
|
||||||
|
metrics_path: '/metrics'
|
||||||
|
|
||||||
|
- job_name: 'prometheus'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['localhost:9090']
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# Landvex Infrastruktur — AWS Terraform
|
||||||
|
# Skalar till 50GB+ agentkraft
|
||||||
|
|
||||||
|
terraform {
|
||||||
|
required_providers {
|
||||||
|
aws = {
|
||||||
|
source = "hashicorp/aws"
|
||||||
|
version = "~> 5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
provider "aws" {
|
||||||
|
region = var.aws_region
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── VPC och nätverk ─────────────────────────────────────
|
||||||
|
resource "aws_vpc" "landvex" {
|
||||||
|
cidr_block = "10.0.0.0/16"
|
||||||
|
enable_dns_hostnames = true
|
||||||
|
enable_dns_support = true
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "landvex-vpc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_subnet" "public" {
|
||||||
|
count = 2
|
||||||
|
vpc_id = aws_vpc.landvex.id
|
||||||
|
cidr_block = "10.0.${count.index + 1}.0/24"
|
||||||
|
availability_zone = data.aws_availability_zones.available.names[count.index]
|
||||||
|
map_public_ip_on_launch = true
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "landvex-public-${count.index + 1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_subnet" "private" {
|
||||||
|
count = 2
|
||||||
|
vpc_id = aws_vpc.landvex.id
|
||||||
|
cidr_block = "10.0.${count.index + 10}.0/24"
|
||||||
|
availability_zone = data.aws_availability_zones.available.names[count.index]
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "landvex-private-${count.index + 1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_availability_zones" "available" {
|
||||||
|
state = "available"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── ECR Repositories ────────────────────────────────────
|
||||||
|
resource "aws_ecr_repository" "api" {
|
||||||
|
name = "landvex-api"
|
||||||
|
image_tag_mutability = "MUTABLE"
|
||||||
|
|
||||||
|
image_scanning_configuration {
|
||||||
|
scan_on_push = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ecr_repository" "datafabrik" {
|
||||||
|
name = "landvex-datafabrik"
|
||||||
|
image_tag_mutability = "MUTABLE"
|
||||||
|
|
||||||
|
image_scanning_configuration {
|
||||||
|
scan_on_push = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ecr_repository" "vision" {
|
||||||
|
name = "landvex-vision"
|
||||||
|
image_tag_mutability = "MUTABLE"
|
||||||
|
|
||||||
|
image_scanning_configuration {
|
||||||
|
scan_on_push = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── ECS Cluster ─────────────────────────────────────────
|
||||||
|
resource "aws_ecs_cluster" "landvex" {
|
||||||
|
name = "landvex-cluster"
|
||||||
|
|
||||||
|
setting {
|
||||||
|
name = "containerInsights"
|
||||||
|
value = "enabled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ecs_cluster_capacity_providers" "landvex" {
|
||||||
|
cluster_name = aws_ecs_cluster.landvex.name
|
||||||
|
|
||||||
|
capacity_providers = ["FARGATE", "FARGATE_SPOT"]
|
||||||
|
|
||||||
|
default_capacity_provider_strategy {
|
||||||
|
base = 1
|
||||||
|
weight = 1
|
||||||
|
capacity_provider = "FARGATE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── S3 Bucket för datalagring ───────────────────────────
|
||||||
|
resource "aws_s3_bucket" "landvex_data" {
|
||||||
|
bucket = "landvex-data-${var.environment}"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_versioning" "landvex_data" {
|
||||||
|
bucket = aws_s3_bucket.landvex_data.id
|
||||||
|
versioning_configuration {
|
||||||
|
status = "Enabled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── RDS (PostgreSQL för metadata) ───────────────────────
|
||||||
|
resource "aws_db_instance" "landvex_metadata" {
|
||||||
|
identifier = "landvex-metadata"
|
||||||
|
allocated_storage = 100
|
||||||
|
max_allocated_storage = 1000
|
||||||
|
engine = "postgres"
|
||||||
|
engine_version = "16"
|
||||||
|
instance_class = "db.r6g.xlarge"
|
||||||
|
db_name = "landvex"
|
||||||
|
username = "landvex"
|
||||||
|
password = var.db_password
|
||||||
|
skip_final_snapshot = true
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "landvex-metadata"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Variables ───────────────────────────────────────────
|
||||||
|
variable "aws_region" {
|
||||||
|
description = "AWS region"
|
||||||
|
default = "eu-north-1"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "environment" {
|
||||||
|
description = "Environment (dev/staging/prod)"
|
||||||
|
default = "prod"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "db_password" {
|
||||||
|
description = "Database password"
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Outputs ─────────────────────────────────────────────
|
||||||
|
output "vpc_id" {
|
||||||
|
value = aws_vpc.landvex.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "ecr_api_url" {
|
||||||
|
value = aws_ecr_repository.api.repository_url
|
||||||
|
}
|
||||||
|
|
||||||
|
output "s3_bucket" {
|
||||||
|
value = aws_s3_bucket.landvex_data.bucket
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
FROM pytorch/pytorch:2.2.0-cuda12.1-cudnn8-runtime
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Systemberoenden
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
tesseract-ocr \
|
||||||
|
tesseract-ocr-swe \
|
||||||
|
tesseract-ocr-eng \
|
||||||
|
libgl1-mesa-glx \
|
||||||
|
libglib2.0-0 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Python-paket
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Kopiera kod
|
||||||
|
COPY modeller/ ./modeller/
|
||||||
|
COPY traening/ ./traening/
|
||||||
|
COPY embeddings/ ./embeddings/
|
||||||
|
COPY dataset/ ./dataset/
|
||||||
|
|
||||||
|
# Miljövariabler
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
ENV VISION_MODEL_DIR=/models
|
||||||
|
ENV VISION_DATASET_DIR=/data/dataset
|
||||||
|
|
||||||
|
VOLUME ["/models", "/data"]
|
||||||
|
|
||||||
|
# Default: träna
|
||||||
|
CMD ["python3", "-m", "traening.trainer"]
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Landvex Vision — OCR-pipeline för typskyltar
|
||||||
|
Extraherar tillverkare, modell, serienummer från foton.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Optional, Tuple
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import pytesseract
|
||||||
|
from PIL import Image
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OCRResultat:
|
||||||
|
text: str
|
||||||
|
konfidens: float
|
||||||
|
bbox: Tuple[int, int, int, int] # x, y, w, h
|
||||||
|
falt: Dict[str, str] # Extraherade fält
|
||||||
|
|
||||||
|
class LandvexOCR:
|
||||||
|
"""OCR-pipeline optimerad för infrastrukturtypskyltar."""
|
||||||
|
|
||||||
|
def __init__(self, tesseract_cmd: Optional[str] = None):
|
||||||
|
if tesseract_cmd:
|
||||||
|
pytesseract.pytesseract.tesseract_cmd = tesseract_cmd
|
||||||
|
|
||||||
|
# Fältmönster per objektklass
|
||||||
|
self.falt_monster = {
|
||||||
|
"LVX-ELN-0101": { # Kabelskåp
|
||||||
|
"tillverkare": r"(ABB|Schneider|Siemens|GE)\s*[\w-]*",
|
||||||
|
"modell": r"(CDC|Prisma|Okken|IM\s*\w+)",
|
||||||
|
"ar": r"20\d{2}",
|
||||||
|
},
|
||||||
|
"LVX-TRP-0102": { # Vägbelysningsarmatur
|
||||||
|
"tillverkare": r"(Philips|Thorn|Schreder|Acuity|Cree)",
|
||||||
|
"modell": r"(SL-\w+|ER\w+|Vista\w+)",
|
||||||
|
"effekt": r"(\d+)\s*W",
|
||||||
|
},
|
||||||
|
"LVX-VAT-0101": { # Brunnsbetäckning
|
||||||
|
"tillverkare": r"(ULMA|ACO|Wrede|GDK)",
|
||||||
|
"klass": r"(A15|B125|C250|D400|E600|F900)",
|
||||||
|
"material": r"(gjutjärn|stål|komposit|betong)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def forbehandla_bild(self, bild: np.ndarray) -> np.ndarray:
|
||||||
|
"""Förbättra bildkvalitet för OCR."""
|
||||||
|
# Konvertera till gråskala
|
||||||
|
if len(bild.shape) == 3:
|
||||||
|
gray = cv2.cvtColor(bild, cv2.COLOR_RGB2GRAY)
|
||||||
|
else:
|
||||||
|
gray = bild
|
||||||
|
|
||||||
|
# Brusreducering
|
||||||
|
denoised = cv2.fastNlMeansDenoising(gray)
|
||||||
|
|
||||||
|
# Kontrastförbättring (CLAHE)
|
||||||
|
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
|
||||||
|
enhanced = clahe.apply(denoised)
|
||||||
|
|
||||||
|
# Skärpa
|
||||||
|
kernel = np.array([[-1, -1, -1], [-1, 9, -1], [-1, -1, -1]])
|
||||||
|
sharpened = cv2.filter2D(enhanced, -1, kernel)
|
||||||
|
|
||||||
|
return sharpened
|
||||||
|
|
||||||
|
def hitta_text_regioner(self, bild: np.ndarray) -> List[Tuple[int, int, int, int]]:
|
||||||
|
"""Hitta regioner som troligen innehåller text."""
|
||||||
|
# MSER (Maximally Stable Extremal Regions)
|
||||||
|
mser = cv2.MSER_create()
|
||||||
|
regions, _ = mser.detectRegions(bild)
|
||||||
|
|
||||||
|
# Filtrera små regioner
|
||||||
|
h, w = bild.shape
|
||||||
|
min_area = (h * w) * 0.001 # Minst 0.1% av bilden
|
||||||
|
|
||||||
|
bboxes = []
|
||||||
|
for region in regions:
|
||||||
|
x, y, w, h = cv2.boundingRect(region)
|
||||||
|
if w * h > min_area and w > h * 2: # Text är oftast bredare än hög
|
||||||
|
bboxes.append((x, y, w, h))
|
||||||
|
|
||||||
|
return bboxes
|
||||||
|
|
||||||
|
def kora_ocr(self, bild: Image.Image, lvx_id: Optional[str] = None) -> List[OCRResultat]:
|
||||||
|
"""Kör OCR på bild och extrahera fält."""
|
||||||
|
# Konvertera till numpy
|
||||||
|
img_array = np.array(bild)
|
||||||
|
|
||||||
|
# Förbehandla
|
||||||
|
processed = self.forbehandla_bild(img_array)
|
||||||
|
|
||||||
|
# Hitta textregioner
|
||||||
|
regioner = self.hitta_text_regioner(processed)
|
||||||
|
|
||||||
|
resultat = []
|
||||||
|
for x, y, w, h in regioner[:5]: # Max 5 regioner
|
||||||
|
# Beskär region
|
||||||
|
roi = processed[y:y+h, x:x+w]
|
||||||
|
|
||||||
|
# Kör Tesseract
|
||||||
|
text = pytesseract.image_to_string(roi, lang='eng+swe')
|
||||||
|
conf_data = pytesseract.image_to_data(roi, output_type=pytesseract.Output.DICT)
|
||||||
|
|
||||||
|
# Beräkna medelkonfidens
|
||||||
|
konfidenser = [c for c in conf_data['conf'] if c > 0]
|
||||||
|
medel_konf = np.mean(konfidenser) if konfidenser else 0
|
||||||
|
|
||||||
|
# Extrahera fält om vi vet objektklassen
|
||||||
|
falt = {}
|
||||||
|
if lvx_id and lvx_id in self.falt_monster:
|
||||||
|
import re
|
||||||
|
for falt_namn, monster in self.falt_monster[lvx_id].items():
|
||||||
|
match = re.search(monster, text, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
falt[falt_namn] = match.group(1)
|
||||||
|
|
||||||
|
resultat.append(OCRResultat(
|
||||||
|
text=text.strip(),
|
||||||
|
konfidens=medel_konf / 100.0, # Normalisera till 0-1
|
||||||
|
bbox=(x, y, w, h),
|
||||||
|
falt=falt
|
||||||
|
))
|
||||||
|
|
||||||
|
# Sortera efter konfidens
|
||||||
|
resultat.sort(key=lambda x: x.konfidens, reverse=True)
|
||||||
|
return resultat
|
||||||
|
|
||||||
|
def extrahera_falt(self, text: str, lvx_id: str) -> Dict[str, str]:
|
||||||
|
"""Extrahera strukturerade fält från OCR-text."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
falt = {}
|
||||||
|
monster = self.falt_monster.get(lvx_id, {})
|
||||||
|
|
||||||
|
for falt_namn, pattern in monster.items():
|
||||||
|
matches = re.findall(pattern, text, re.IGNORECASE)
|
||||||
|
if matches:
|
||||||
|
falt[falt_namn] = matches[0]
|
||||||
|
|
||||||
|
return falt
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Demo: OCR på syntetisk bild."""
|
||||||
|
print("🔤 Landvex OCR Pipeline")
|
||||||
|
|
||||||
|
ocr = LandvexOCR()
|
||||||
|
|
||||||
|
# Skapa syntetisk testbild med text
|
||||||
|
from PIL import ImageDraw, ImageFont
|
||||||
|
img = Image.new('RGB', (400, 200), color='white')
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 24)
|
||||||
|
except:
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
|
||||||
|
draw.text((20, 20), "ABB Kabeldon", fill='black', font=font)
|
||||||
|
draw.text((20, 60), "CDC-LT 400", fill='black', font=font)
|
||||||
|
draw.text((20, 100), "2023", fill='black', font=font)
|
||||||
|
|
||||||
|
# Kör OCR
|
||||||
|
resultat = ocr.kora_ocr(img, lvx_id="LVX-ELN-0101")
|
||||||
|
|
||||||
|
print("\n🎯 OCR-resultat:")
|
||||||
|
for i, r in enumerate(resultat[:3]):
|
||||||
|
print(f" Region {i+1}:")
|
||||||
|
print(f" Text: {r.text[:100]}")
|
||||||
|
print(f" Konfidens: {r.konfidens:.2f}")
|
||||||
|
print(f" Fält: {r.falt}")
|
||||||
|
|
||||||
|
print("\n✅ OCR klar!")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Landvex Vision — Vektordatabas
|
||||||
|
Lagrar och söker bild- och textembeddings.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import numpy as np
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Tuple, Optional
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import faiss # Facebook AI Similarity Search
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VektorsokResultat:
|
||||||
|
lvx_id: str
|
||||||
|
distans: float
|
||||||
|
metadata: dict
|
||||||
|
|
||||||
|
class LandvexVektordatabas:
|
||||||
|
"""Vektordatabas för bild- och textembeddings."""
|
||||||
|
|
||||||
|
def __init__(self, dimension: int = 2048, index_type: str = "FlatIP"):
|
||||||
|
self.dimension = dimension
|
||||||
|
self.index_type = index_type
|
||||||
|
|
||||||
|
# FAISS index för snabb sökning
|
||||||
|
if index_type == "FlatIP":
|
||||||
|
self.index = faiss.IndexFlatIP(dimension) # Inner product (cosine om normaliserad)
|
||||||
|
elif index_type == "IVF"::
|
||||||
|
nlist = 100 # Antal kluster
|
||||||
|
quantizer = faiss.IndexFlatIP(dimension)
|
||||||
|
self.index = faiss.IndexIVFFlat(quantizer, dimension, nlist)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Okänd index-typ: {index_type}")
|
||||||
|
|
||||||
|
# Mappning index_id → lvx_id
|
||||||
|
self.id_map: List[str] = []
|
||||||
|
self.metadata: Dict[str, dict] = {}
|
||||||
|
|
||||||
|
def lagg_till(self, lvx_id: str, embedding: np.ndarray, metadata: dict):
|
||||||
|
"""Lägg till embedding i databasen."""
|
||||||
|
# Normalisera för cosine similarity
|
||||||
|
embedding = embedding / np.linalg.norm(embedding)
|
||||||
|
embedding = embedding.reshape(1, -1).astype('float32')
|
||||||
|
|
||||||
|
self.index.add(embedding)
|
||||||
|
self.id_map.append(lvx_id)
|
||||||
|
self.metadata[lvx_id] = metadata
|
||||||
|
|
||||||
|
def sok(self, query: np.ndarray, top_k: int = 5) -> List[VektorsokResultat]:
|
||||||
|
"""Sök närmaste grannar."""
|
||||||
|
query = query / np.linalg.norm(query)
|
||||||
|
query = query.reshape(1, -1).astype('float32')
|
||||||
|
|
||||||
|
distanser, indices = self.index.search(query, top_k)
|
||||||
|
|
||||||
|
resultat = []
|
||||||
|
for dist, idx in zip(distanser[0], indices[0]):
|
||||||
|
if idx < 0 or idx >= len(self.id_map):
|
||||||
|
continue
|
||||||
|
|
||||||
|
lvx_id = self.id_map[idx]
|
||||||
|
resultat.append(VektorsokResultat(
|
||||||
|
lvx_id=lvx_id,
|
||||||
|
distans=float(dist),
|
||||||
|
metadata=self.metadata.get(lvx_id, {})
|
||||||
|
))
|
||||||
|
|
||||||
|
return resultat
|
||||||
|
|
||||||
|
def spara(self, path: Path):
|
||||||
|
"""Spara index och metadata."""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Spara FAISS-index
|
||||||
|
faiss.write_index(self.index, str(path / "index.faiss"))
|
||||||
|
|
||||||
|
# Spara metadata
|
||||||
|
with open(path / "metadata.json", 'w') as f:
|
||||||
|
json.dump({
|
||||||
|
"id_map": self.id_map,
|
||||||
|
"metadata": self.metadata,
|
||||||
|
"dimension": self.dimension,
|
||||||
|
"index_type": self.index_type,
|
||||||
|
}, f, indent=2)
|
||||||
|
|
||||||
|
print(f" 💾 Vektordatabas sparad: {path}")
|
||||||
|
|
||||||
|
def ladda(self, path: Path):
|
||||||
|
"""Ladda index och metadata."""
|
||||||
|
self.index = faiss.read_index(str(path / "index.faiss"))
|
||||||
|
|
||||||
|
with open(path / "metadata.json", 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
self.id_map = data["id_map"]
|
||||||
|
self.metadata = data["metadata"]
|
||||||
|
self.dimension = data["dimension"]
|
||||||
|
self.index_type = data["index_type"]
|
||||||
|
|
||||||
|
print(f" 📂 Vektordatabas laddad: {len(self.id_map)} vektorer")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Demo: vektordatabas."""
|
||||||
|
print("🔍 Landvex Vektordatabas")
|
||||||
|
|
||||||
|
db = LandvexVektordatabas(dimension=128) # Låg dimension för demo
|
||||||
|
|
||||||
|
# Lägg till exempel-embeddings
|
||||||
|
np.random.seed(42)
|
||||||
|
for i in range(100):
|
||||||
|
emb = np.random.randn(128)
|
||||||
|
db.lagg_till(
|
||||||
|
lvx_id=f"LVX-TRP-{1000+i:04d}",
|
||||||
|
embedding=emb,
|
||||||
|
metadata={
|
||||||
|
"namn_sv": f"Testobjekt {i}",
|
||||||
|
"verifieringsniva": "kallbelagd",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sök
|
||||||
|
query = np.random.randn(128)
|
||||||
|
resultat = db.sok(query, top_k=5)
|
||||||
|
|
||||||
|
print("\n🎯 Sökresultat:")
|
||||||
|
for r in resultat:
|
||||||
|
print(f" {r.lvx_id}: distans={r.distans:.3f}, {r.metadata['namn_sv']}")
|
||||||
|
|
||||||
|
# Spara och ladda
|
||||||
|
db.spara(Path("/tmp/landvex-vectordb"))
|
||||||
|
|
||||||
|
db2 = LandvexVektordatabas()
|
||||||
|
db2.ladda(Path("/tmp/landvex-vectordb"))
|
||||||
|
|
||||||
|
print(f"\n✅ Databas: {len(db2.id_map)} vektorer")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Landvex Vision — Identify-modell
|
||||||
|
Foto in → rankade kandidater med konfidens
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Tuple, Optional
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import torch
|
||||||
|
import torch.nn as nn
|
||||||
|
from torchvision import models, transforms
|
||||||
|
from PIL import Image
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IdentifyKandidat:
|
||||||
|
lvx_id: str
|
||||||
|
posttyp: str
|
||||||
|
namn_sv: str
|
||||||
|
namn_en: str
|
||||||
|
konfidens: float
|
||||||
|
verifieringsniva: str
|
||||||
|
kannetecken_match: List[str]
|
||||||
|
embedding_distans: float
|
||||||
|
|
||||||
|
class LandvexIdentifyModel:
|
||||||
|
"""Vision-modell för infrastrukturidentifiering."""
|
||||||
|
|
||||||
|
def __init__(self, model_path: Optional[Path] = None):
|
||||||
|
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||||
|
print(f" 🖥️ Enhet: {self.device}")
|
||||||
|
|
||||||
|
# Ladda förtränad ResNet som backbone
|
||||||
|
self.backbone = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2)
|
||||||
|
self.backbone = nn.Sequential(*list(self.backbone.children())[:-1]) # Ta bort sista FC
|
||||||
|
self.backbone = self.backbone.to(self.device)
|
||||||
|
self.backbone.eval()
|
||||||
|
|
||||||
|
# Transform för bilder
|
||||||
|
self.transform = transforms.Compose([
|
||||||
|
transforms.Resize(256),
|
||||||
|
transforms.CenterCrop(224),
|
||||||
|
transforms.ToTensor(),
|
||||||
|
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
|
||||||
|
])
|
||||||
|
|
||||||
|
# Embedding-databas (lvx_id → embedding)
|
||||||
|
self.embeddings: Dict[str, np.ndarray] = {}
|
||||||
|
self.metadata: Dict[str, dict] = {}
|
||||||
|
|
||||||
|
if model_path and model_path.exists():
|
||||||
|
self.ladda(model_path)
|
||||||
|
|
||||||
|
def bild_till_embedding(self, bild: Image.Image) -> np.ndarray:
|
||||||
|
"""Konvertera bild till embedding-vektor."""
|
||||||
|
tensor = self.transform(bild).unsqueeze(0).to(self.device)
|
||||||
|
|
||||||
|
with torch.no_grad():
|
||||||
|
embedding = self.backbone(tensor)
|
||||||
|
|
||||||
|
return embedding.cpu().numpy().flatten()
|
||||||
|
|
||||||
|
def lagg_till_klass(self, lvx_id: str, bilder: List[Image.Image], metadata: dict):
|
||||||
|
"""Lägg till en objektklass med träningsbilder."""
|
||||||
|
embeddings = [self.bild_till_embedding(b) for b in bilder]
|
||||||
|
medel_embedding = np.mean(embeddings, axis=0)
|
||||||
|
|
||||||
|
self.embeddings[lvx_id] = medel_embedding / np.linalg.norm(medel_embedding)
|
||||||
|
self.metadata[lvx_id] = metadata
|
||||||
|
|
||||||
|
def identifiera(self, bild: Image.Image, top_k: int = 5) -> List[IdentifyKandidat]:
|
||||||
|
"""Identifiera objekt i bild. Returnera top-k kandidater."""
|
||||||
|
if not self.embeddings:
|
||||||
|
return []
|
||||||
|
|
||||||
|
query_embedding = self.bild_till_embedding(bild)
|
||||||
|
query_embedding = query_embedding / np.linalg.norm(query_embedding)
|
||||||
|
|
||||||
|
# Beräkna kosinuslikhet
|
||||||
|
resultat = []
|
||||||
|
for lvx_id, emb in self.embeddings.items():
|
||||||
|
distans = np.dot(query_embedding, emb)
|
||||||
|
meta = self.metadata[lvx_id]
|
||||||
|
|
||||||
|
# Konfidens = likhet * verifieringsnivå-faktor
|
||||||
|
verif_faktor = {
|
||||||
|
"obekraftad": 0.5,
|
||||||
|
"kallbelagd": 0.75,
|
||||||
|
"faltverifierad": 0.9,
|
||||||
|
"tillverkarbekraftad": 0.95,
|
||||||
|
}.get(meta.get("verifieringsniva", "obekraftad"), 0.5)
|
||||||
|
|
||||||
|
konfidens = float(distans * verif_faktor)
|
||||||
|
|
||||||
|
resultat.append(IdentifyKandidat(
|
||||||
|
lvx_id=lvx_id,
|
||||||
|
posttyp=meta.get("posttyp", "objektklass"),
|
||||||
|
namn_sv=meta.get("namn_sv", ""),
|
||||||
|
namn_en=meta.get("namn_en", ""),
|
||||||
|
konfidens=konfidens,
|
||||||
|
verifieringsniva=meta.get("verifieringsniva", "obekraftad"),
|
||||||
|
kannetecken_match=meta.get("kannetecken", [])[:3],
|
||||||
|
embedding_distans=float(distans),
|
||||||
|
))
|
||||||
|
|
||||||
|
# Sortera efter konfidens
|
||||||
|
resultat.sort(key=lambda x: x.konfidens, reverse=True)
|
||||||
|
return resultat[:top_k]
|
||||||
|
|
||||||
|
def spara(self, path: Path):
|
||||||
|
"""Spara modell och embeddings."""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
data = {
|
||||||
|
"embeddings": {k: v.tolist() for k, v in self.embeddings.items()},
|
||||||
|
"metadata": self.metadata,
|
||||||
|
}
|
||||||
|
torch.save(data, path)
|
||||||
|
print(f" 💾 Modell sparad: {path}")
|
||||||
|
|
||||||
|
def ladda(self, path: Path):
|
||||||
|
"""Ladda modell och embeddings."""
|
||||||
|
data = torch.load(path, map_location=self.device)
|
||||||
|
self.embeddings = {k: np.array(v) for k, v in data["embeddings"].items()}
|
||||||
|
self.metadata = data["metadata"]
|
||||||
|
print(f" 📂 Modell laddad: {len(self.embeddings)} klasser")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Demo: träna på syntetiska data och identifiera."""
|
||||||
|
from PIL import ImageDraw
|
||||||
|
|
||||||
|
model = LandvexIdentifyModel()
|
||||||
|
|
||||||
|
# Skapa syntetiska träningsbilder (i verkligheten: riktiga foton)
|
||||||
|
def skapa_testbild(farg, storlek=(224, 224)):
|
||||||
|
img = Image.new('RGB', storlek, farg)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
draw.rectangle([50, 50, 174, 174], outline="white", width=3)
|
||||||
|
return img
|
||||||
|
|
||||||
|
# Lägg till två klasser
|
||||||
|
model.lagg_till_klass("LVX-TRP-0101", [
|
||||||
|
skapa_testbild("gray"),
|
||||||
|
skapa_testbild("lightgray"),
|
||||||
|
], {
|
||||||
|
"posttyp": "objektklass",
|
||||||
|
"namn_sv": "Belysningsstolpe",
|
||||||
|
"namn_en": "Lighting column",
|
||||||
|
"verifieringsniva": "kallbelagd",
|
||||||
|
"kannetecken": ["Grå stolpe", "Ljustopp"],
|
||||||
|
})
|
||||||
|
|
||||||
|
model.lagg_till_klass("LVX-TRP-0102", [
|
||||||
|
skapa_testbild("blue"),
|
||||||
|
skapa_testbild("darkblue"),
|
||||||
|
], {
|
||||||
|
"posttyp": "objektklass",
|
||||||
|
"namn_sv": "Vägbelysningsarmatur",
|
||||||
|
"namn_en": "Road lighting luminaire",
|
||||||
|
"verifieringsniva": "kallbelagd",
|
||||||
|
"kannetecken": ["Blå armatur", "LED-ljus"],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Testa identifiering
|
||||||
|
test_bild = skapa_testbild("gray")
|
||||||
|
kandidater = model.identifiera(test_bild, top_k=2)
|
||||||
|
|
||||||
|
print("\n🎯 Identifieringsresultat:")
|
||||||
|
for k in kandidater:
|
||||||
|
print(f" {k.lvx_id}: {k.namn_sv} (konfidens: {k.konfidens:.3f})")
|
||||||
|
|
||||||
|
# Spara modell
|
||||||
|
model.spara(Path("/tmp/landvex-vision-model.pt"))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Landvex Vision — Träningspipeline
|
||||||
|
Kontrastivt lärande från Zoomer-foton och tillverkarbilder.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Tuple
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import torch
|
||||||
|
import torch.nn as nn
|
||||||
|
import torch.optim as optim
|
||||||
|
from torch.utils.data import Dataset, DataLoader
|
||||||
|
from torchvision import models, transforms
|
||||||
|
from PIL import Image
|
||||||
|
import numpy as np
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Traeningsexempel:
|
||||||
|
bild_path: Path
|
||||||
|
lvx_id: str
|
||||||
|
positiv: bool # True = matchar lvx_id, False = negativt exempel
|
||||||
|
kalla: str # "zoomer", "tillverkare", "syntetisk"
|
||||||
|
|
||||||
|
class LandvexDataset(Dataset):
|
||||||
|
"""Dataset för kontrastivt lärande."""
|
||||||
|
|
||||||
|
def __init__(self, exempel: List[Traeningsexempel], transform=None):
|
||||||
|
self.exempel = exempel
|
||||||
|
self.transform = transform or transforms.Compose([
|
||||||
|
transforms.Resize(256),
|
||||||
|
transforms.CenterCrop(224),
|
||||||
|
transforms.ToTensor(),
|
||||||
|
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
|
||||||
|
])
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self.exempel)
|
||||||
|
|
||||||
|
def __getitem__(self, idx):
|
||||||
|
ex = self.exempel[idx]
|
||||||
|
bild = Image.open(ex.bild_path).convert('RGB')
|
||||||
|
|
||||||
|
if self.transform:
|
||||||
|
bild = self.transform(bild)
|
||||||
|
|
||||||
|
return bild, ex.lvx_id, ex.positiv, ex.kalla
|
||||||
|
|
||||||
|
class KontrastivtVerlust(nn.Module):
|
||||||
|
"""NT-Xent loss (Normalized Temperature-scaled Cross Entropy)."""
|
||||||
|
|
||||||
|
def __init__(self, temperatur: float = 0.5):
|
||||||
|
super().__init__()
|
||||||
|
self.temperatur = temperatur
|
||||||
|
self.cos_sim = nn.CosineSimilarity(dim=-1)
|
||||||
|
|
||||||
|
def forward(self, z_i: torch.Tensor, z_j: torch.Tensor) -> torch.Tensor:
|
||||||
|
"""
|
||||||
|
z_i, z_j: normaliserade embeddings [batch_size, dim]
|
||||||
|
"""
|
||||||
|
# Cosine similarity
|
||||||
|
sim = self.cos_sim(z_i.unsqueeze(1), z_j.unsqueeze(0)) / self.temperatur
|
||||||
|
|
||||||
|
# Positiva par är på diagonalen
|
||||||
|
etiketter = torch.arange(len(z_i)).to(z_i.device)
|
||||||
|
|
||||||
|
# Cross entropy
|
||||||
|
return nn.functional.cross_entropy(sim, etiketter)
|
||||||
|
|
||||||
|
class LandvexTrainer:
|
||||||
|
"""Träningspipeline för Identify-modellen."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
model: nn.Module,
|
||||||
|
device: torch.device,
|
||||||
|
learning_rate: float = 1e-4,
|
||||||
|
temperatur: float = 0.5
|
||||||
|
):
|
||||||
|
self.model = model.to(device)
|
||||||
|
self.device = device
|
||||||
|
self.optimizer = optim.Adam(model.parameters(), lr=learning_rate)
|
||||||
|
self.criterion = KontrastivtVerlust(temperatur)
|
||||||
|
self.epoch = 0
|
||||||
|
|
||||||
|
def trana_epok(self, dataloader: DataLoader) -> dict:
|
||||||
|
"""Träna en epok."""
|
||||||
|
self.model.train()
|
||||||
|
total_loss = 0
|
||||||
|
antal_batch = 0
|
||||||
|
|
||||||
|
for bilder, lvx_ids, positiva, kallor in tqdm(dataloader, desc=f"Epok {self.epoch}"):
|
||||||
|
bilder = bilder.to(self.device)
|
||||||
|
|
||||||
|
# Forward pass
|
||||||
|
embeddings = self.model(bilder)
|
||||||
|
|
||||||
|
# Kontrastivt förlust
|
||||||
|
# Dela i två vyer (augmentation)
|
||||||
|
batch_size = len(bilder) // 2
|
||||||
|
z_i = embeddings[:batch_size]
|
||||||
|
z_j = embeddings[batch_size:]
|
||||||
|
|
||||||
|
loss = self.criterion(z_i, z_j)
|
||||||
|
|
||||||
|
# Backward pass
|
||||||
|
self.optimizer.zero_grad()
|
||||||
|
loss.backward()
|
||||||
|
self.optimizer.step()
|
||||||
|
|
||||||
|
total_loss += loss.item()
|
||||||
|
antal_batch += 1
|
||||||
|
|
||||||
|
self.epoch += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"epok": self.epoch,
|
||||||
|
"medel_loss": total_loss / antal_batch,
|
||||||
|
}
|
||||||
|
|
||||||
|
def utvardera(self, dataloader: DataLoader) -> dict:
|
||||||
|
"""Utvärdera modellen."""
|
||||||
|
self.model.eval()
|
||||||
|
korrekta = 0
|
||||||
|
totala = 0
|
||||||
|
|
||||||
|
with torch.no_grad():
|
||||||
|
for bilder, lvx_ids, positiva, kallor in dataloader:
|
||||||
|
bilder = bilder.to(self.device)
|
||||||
|
embeddings = self.model(bilder)
|
||||||
|
|
||||||
|
# TODO: Implementera top-k utvärdering
|
||||||
|
totala += len(bilder)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"noggrannhet": korrekta / totala if totala > 0 else 0,
|
||||||
|
"antal": totala,
|
||||||
|
}
|
||||||
|
|
||||||
|
def spara(self, path: Path):
|
||||||
|
"""Spara träningsstatus."""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
torch.save({
|
||||||
|
"epoch": self.epoch,
|
||||||
|
"model_state": self.model.state_dict(),
|
||||||
|
"optimizer_state": self.optimizer.state_dict(),
|
||||||
|
}, path)
|
||||||
|
print(f" 💾 Träningsstatus sparad: {path}")
|
||||||
|
|
||||||
|
def ladda(self, path: Path):
|
||||||
|
"""Ladda träningsstatus."""
|
||||||
|
checkpoint = torch.load(path, map_location=self.device)
|
||||||
|
self.model.load_state_dict(checkpoint["model_state"])
|
||||||
|
self.optimizer.load_state_dict(checkpoint["optimizer_state"])
|
||||||
|
self.epoch = checkpoint["epoch"]
|
||||||
|
print(f" 📂 Träningsstatus laddad: epok {self.epoch}")
|
||||||
|
|
||||||
|
def skapa_syntetiskt_dataset(output_dir: Path, antal_klasser: int = 10, antal_bilder_per_klass: int = 20):
|
||||||
|
"""Skapa syntetiskt dataset för testning."""
|
||||||
|
from PIL import ImageDraw
|
||||||
|
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
exempel = []
|
||||||
|
|
||||||
|
for klass_idx in range(antal_klasser):
|
||||||
|
lvx_id = f"LVX-TRP-{1000 + klass_idx:04d}"
|
||||||
|
klass_dir = output_dir / lvx_id
|
||||||
|
klass_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
for bild_idx in range(antal_bilder_per_klass):
|
||||||
|
# Skapa syntetisk bild
|
||||||
|
img = Image.new('RGB', (224, 224), color=(klass_idx * 20, 100, 150))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
draw.rectangle([50, 50, 174, 174], outline="white", width=3)
|
||||||
|
|
||||||
|
# Spara
|
||||||
|
bild_path = klass_dir / f"{bild_idx:03d}.jpg"
|
||||||
|
img.save(bild_path)
|
||||||
|
|
||||||
|
exempel.append(Traeningsexempel(
|
||||||
|
bild_path=bild_path,
|
||||||
|
lvx_id=lvx_id,
|
||||||
|
positiv=True,
|
||||||
|
kalla="syntetisk"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Spara metadata
|
||||||
|
with open(output_dir / "dataset.json", 'w') as f:
|
||||||
|
json.dump([{
|
||||||
|
"bild_path": str(e.bild_path),
|
||||||
|
"lvx_id": e.lvx_id,
|
||||||
|
"positiv": e.positiv,
|
||||||
|
"kalla": e.kalla,
|
||||||
|
} for e in exempel], f, indent=2)
|
||||||
|
|
||||||
|
return exempel
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Demo: träna på syntetiskt dataset."""
|
||||||
|
print("🚀 Landvex Vision Trainer")
|
||||||
|
|
||||||
|
# Skapa dataset
|
||||||
|
dataset_dir = Path("/tmp/landvex-vision-dataset")
|
||||||
|
exempel = skapa_syntetiskt_dataset(dataset_dir, antal_klasser=5, antal_bilder_per_klass=10)
|
||||||
|
print(f"📊 Dataset: {len(exempel)} exempel")
|
||||||
|
|
||||||
|
# Skapa modell
|
||||||
|
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||||
|
backbone = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2)
|
||||||
|
backbone.fc = nn.Identity() # Ta bort klassificeringslager
|
||||||
|
|
||||||
|
# Dataset och dataloader
|
||||||
|
dataset = LandvexDataset(exempel)
|
||||||
|
dataloader = DataLoader(dataset, batch_size=8, shuffle=True)
|
||||||
|
|
||||||
|
# Tränare
|
||||||
|
trainer = LandvexTrainer(backbone, device)
|
||||||
|
|
||||||
|
# Träna
|
||||||
|
for epok in range(3):
|
||||||
|
resultat = trainer.trana_epok(dataloader)
|
||||||
|
print(f" Epok {resultat['epok']}: loss = {resultat['medel_loss']:.4f}")
|
||||||
|
|
||||||
|
# Spara
|
||||||
|
trainer.spara(Path("/tmp/landvex-vision-checkpoint.pt"))
|
||||||
|
|
||||||
|
print("\n✅ Träning klar!")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user