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:
@@ -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()
|
||||
Reference in New Issue
Block a user