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:
Bernt
2026-07-05 06:25:51 +00:00
parent 7d40cf95bf
commit f4f853d94b
20 changed files with 2631 additions and 63 deletions
+31
View File
@@ -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()