6de2455917
- Added GLOBAL_MARKETS_TITLE to all translation files - Updated footer with 12 markets (4 active + 8 upcoming) - Translated market section to: zh-cn, zh-tw, ja, ko, th, vi, id, ms, hi - Built and deployed to production - CloudFront invalidation: I3RTMXVFDJXWLG3SYX208OP1CC
504 lines
16 KiB
Python
504 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Landvex Produktionsagent — kor_agent.py v3.0
|
|
Full LLM-integration med webbsökning, SEO-generering, och AI-discoverability.
|
|
Använder Anthropic Claude API.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import anthropic
|
|
|
|
# Konfiguration
|
|
DEFAULT_KO = Path(__file__).parent / "intag" / "artikelko.json"
|
|
DEFAULT_PROMPT = Path(__file__).parent / "lvx-artikelautomation-prompt.md"
|
|
DEFAULT_OUTPUT_DIR = Path(__file__).parent / "intag"
|
|
DEFAULT_SEO_DIR = Path(__file__).parent / ".." / ".." / "landvex-site" / "public"
|
|
LEASE_MIN = 60 # minuter
|
|
|
|
# API-konfiguration
|
|
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY")
|
|
DEFAULT_MODEL = "claude-sonnet-5"
|
|
|
|
|
|
def log(msg):
|
|
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
print(f"[{ts}] {msg}", flush=True)
|
|
|
|
|
|
def ladda_ko(ko_path):
|
|
with open(ko_path, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
|
|
|
|
def spara_ko(ko_path, data):
|
|
with open(ko_path, "w", encoding="utf-8") as f:
|
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
|
|
|
|
def hitta_nasta(ko, agent_id):
|
|
"""Hitta översta olåsta posten (lägst prio = högst först)."""
|
|
oppen = [p for p in ko["ko"] if p.get("status") == "oppen"]
|
|
if not oppen:
|
|
return None
|
|
oppen.sort(key=lambda x: x["prio"])
|
|
return oppen[0]
|
|
|
|
|
|
def las_las(post, agent_id):
|
|
"""Lås en post för arbete."""
|
|
post["status"] = "in_arbete"
|
|
post["agent_id"] = agent_id
|
|
post["las_tid"] = datetime.now(timezone.utc).isoformat()
|
|
return post
|
|
|
|
|
|
def markera_klar(post):
|
|
"""Markera post som klar."""
|
|
post["status"] = "klar"
|
|
post["klar_tid"] = datetime.now(timezone.utc).isoformat()
|
|
if "las_tid" in post:
|
|
del post["las_tid"]
|
|
|
|
|
|
def skapa_kandidatfil(kandidater, agent_id, output_dir):
|
|
"""Skriv kandidatposter till fil."""
|
|
datum = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
filnamn = f"kandidatposter-{datum}-{agent_id}.json"
|
|
sokvag = output_dir / filnamn
|
|
|
|
payload = {
|
|
"beskrivning": f"Landvex produktionsagent — kandidatposter {datum}",
|
|
"extraktor": "landvex-produktionsagent",
|
|
"genererad": datum,
|
|
"kandidater": kandidater
|
|
}
|
|
|
|
with open(sokvag, "w", encoding="utf-8") as f:
|
|
json.dump(payload, f, ensure_ascii=False, indent=2)
|
|
|
|
return sokvag
|
|
|
|
|
|
def ladda_prompt(sokvag):
|
|
"""Ladda systemprompt från fil."""
|
|
if not Path(sokvag).exists():
|
|
return "Du är en expert på infrastruktur och teknisk dokumentation."
|
|
with open(sokvag, "r", encoding="utf-8") as f:
|
|
return f.read()
|
|
|
|
|
|
def generera_artikel_med_llm(amne, doman, system_prompt, client):
|
|
"""
|
|
Generera en komplett artikel med Anthropic Claude.
|
|
"""
|
|
user_prompt = f"""Generera en komplett artikel om följande infrastrukturobjekt:
|
|
|
|
Objekt: {amne}
|
|
Domän: {doman}
|
|
|
|
Returnera SVARET SOM JSON i följande format:
|
|
{{
|
|
"slug": "url-vanlig-slug-pa-engelska",
|
|
"namn": {{
|
|
"sv": "Svenskt namn",
|
|
"en": "Engelskt namn"
|
|
}},
|
|
"beskrivning": "Detaljerad beskrivning av objektet, dess funktion och användningsområden (minst 300 ord på svenska)",
|
|
"teknik": {{
|
|
"material": ["Material som används"],
|
|
"dimensioner": "Typiska dimensioner",
|
|
"standarder": ["Tillämpliga standarder och normer"],
|
|
"livslangd": "Förväntad livslängd"
|
|
}},
|
|
"anvandningsomraden": ["Lista över användningsområden"],
|
|
"relaterade_objekt": ["Lista över relaterade infrastrukturobjekt"],
|
|
"bilder": [
|
|
{{
|
|
"beskrivning": "Beskrivning av vad bilden ska visa",
|
|
"alt_text": "Alt-text för tillgänglighet"
|
|
}}
|
|
],
|
|
"kallor": [
|
|
{{
|
|
"titel": "Källans titel",
|
|
"url": "https://example.com",
|
|
"typ": "webbsida|standard|dokument"
|
|
}}
|
|
]
|
|
}}
|
|
|
|
VIKTIGT:
|
|
1. Returnera ENDAST JSON, ingen annan text före eller efter.
|
|
2. Beskrivningen ska vara på SVENSKA och minst 300 ord.
|
|
3. Inkludera tekniska detaljer som är specifika för objektet.
|
|
4. Använd domän-expertis: för VAT (vatten) inkludera tryckklasser, för TRP (trafik) inkludera belastningsklasser, etc."""
|
|
|
|
log(f"Anropar Claude API för: {amne}")
|
|
|
|
response = client.messages.create(
|
|
model=DEFAULT_MODEL,
|
|
max_tokens=4000,
|
|
system=system_prompt,
|
|
messages=[{"role": "user", "content": user_prompt}]
|
|
)
|
|
|
|
# Parsa JSON-svar
|
|
content = None
|
|
for block in response.content:
|
|
if hasattr(block, 'text'):
|
|
content = block.text
|
|
break
|
|
|
|
if not content:
|
|
raise ValueError("Ingen text hittades i svaret")
|
|
|
|
# Extrahera JSON om det är omgivet av markdown
|
|
if "```json" in content:
|
|
content = content.split("```json")[1].split("```")[0].strip()
|
|
elif "```" in content:
|
|
content = content.split("```")[1].split("```")[0].strip()
|
|
|
|
# Rensa kontrolltecken som kan förstöra JSON
|
|
import re
|
|
content = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', '', content)
|
|
|
|
return json.loads(content)
|
|
|
|
|
|
def producera_kandidatpost(post, agent_id, system_prompt, client):
|
|
"""Produktion av komplett kandidatpost med LLM."""
|
|
ko_id = post["ko_id"]
|
|
amne = post["mal"]
|
|
doman = post["doman"]
|
|
|
|
log(f"Produktion startar: {ko_id} - {amne}")
|
|
|
|
# Generera artikel med LLM
|
|
artikel = generera_artikel_med_llm(amne, doman, system_prompt, client)
|
|
|
|
# Bygg kandidatpost
|
|
kandidat_id = f"K-{datetime.now(timezone.utc).strftime('%Y%m%d')}-{agent_id}-{ko_id}"
|
|
|
|
kandidat = {
|
|
"kandidat_id": kandidat_id,
|
|
"atgard": "ny",
|
|
"serie": post.get("serie", {"typ": "klass", "tier": 2}),
|
|
"paket_ref": [],
|
|
"patch": {},
|
|
"post": {
|
|
"posttyp": "objektklass" if post["serie"]["typ"] == "klass" else "produktmodell",
|
|
"slug": artikel.get("slug", amne.lower().replace(" ", "-")),
|
|
"namn": artikel.get("namn", {"sv": amne, "en": amne}),
|
|
"doman": doman,
|
|
"identitet": {
|
|
"status": "aktiv",
|
|
"beskrivning": artikel.get("beskrivning", f"{amne} — infrastrukturkomponent")
|
|
},
|
|
"teknik": artikel.get("teknik", {}),
|
|
"geografi": {
|
|
"marknader": ["US", "EU", "UK", "CA", "AU"]
|
|
},
|
|
"standarder": [],
|
|
"ai": {
|
|
"ocr": {
|
|
"falt": []
|
|
}
|
|
},
|
|
"problem": [],
|
|
"relationer": [
|
|
{"typ": "relaterad_till", "mal": rel}
|
|
for rel in artikel.get("relaterade_objekt", [])
|
|
],
|
|
"proveniens": {
|
|
"skapad": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
|
|
"uppdaterad": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
|
|
"verifieringsniva": "obekraftad",
|
|
"konfidens": 0.7,
|
|
"kallor": artikel.get("kallor", [])
|
|
}
|
|
},
|
|
"verifieringsniva_forslag": "obekraftad",
|
|
"motivering": f"LLM-genererad post för {amne} med tekniska detaljer och källhänvisningar"
|
|
}
|
|
|
|
# Lägg till tier eller parent
|
|
if post["serie"]["typ"] == "klass":
|
|
kandidat["post"]["tier"] = post["serie"]["tier"]
|
|
else:
|
|
kandidat["post"]["parent"] = post.get("parent", "")
|
|
|
|
# Lägg till standarder från artikel
|
|
if "standarder" in artikel.get("teknik", {}):
|
|
for std in artikel["teknik"]["standarder"]:
|
|
kandidat["post"]["standarder"].append({
|
|
"beteckning": std,
|
|
"organ": "Standardiseringsorgan",
|
|
"roll": "referens"
|
|
})
|
|
|
|
# Lägg till ledtrådar som standarder om tillgängliga
|
|
if "ledtrad_standard" in post:
|
|
kandidat["post"]["standarder"].append({
|
|
"beteckning": post["ledtrad_standard"],
|
|
"organ": "Standardiseringsorgan",
|
|
"roll": "referens"
|
|
})
|
|
|
|
log(f"✓ Kandidatpost klar: {kandidat_id}")
|
|
return kandidat
|
|
|
|
|
|
def generera_seo_filer(seo_dir, master_data):
|
|
"""Generera SEO-filer för webbsidan."""
|
|
seo_dir = Path(seo_dir)
|
|
seo_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# robots.txt
|
|
robots_txt = """User-agent: *
|
|
Allow: /
|
|
Disallow: /admin/
|
|
Disallow: /api/internal/
|
|
|
|
# AI-agents och sökmotorer
|
|
User-agent: GPTBot
|
|
Allow: /
|
|
|
|
User-agent: ChatGPT-User
|
|
Allow: /
|
|
|
|
User-agent: Claude-Web
|
|
Allow: /
|
|
|
|
User-agent: PerplexityBot
|
|
Allow: /
|
|
|
|
User-agent: Google-Extended
|
|
Allow: /
|
|
|
|
Sitemap: https://landvex.com/sitemap.xml
|
|
"""
|
|
|
|
with open(seo_dir / "robots.txt", "w", encoding="utf-8") as f:
|
|
f.write(robots_txt)
|
|
|
|
log("Genererade robots.txt")
|
|
|
|
# llms.txt — för AI-discoverability
|
|
llms_txt = """# Landvex Infrastructure Object Library
|
|
|
|
> Landvex bygger den globala infrastrukturen för samhällsinsikt. Genom att samla, standardisera och tillgängliggöra data om visuellt identifierbar fysisk infrastruktur skapar vi en gemensam faktabas för journalister, forskare, företag, investerare och beslutsfattare.
|
|
|
|
## Om Landvex IOL
|
|
|
|
Landvex Infrastructure Object Library (IOL) är en kunskapsgraf över visuellt identifierbar fysisk infrastruktur. Biblioteket innehåller:
|
|
|
|
- **Objektklasser** (tier 1-3): Generiska infrastrukturkategorier
|
|
- **Produktmodeller**: Specifika tillverkarmodeller med unika egenskaper
|
|
- **Relationer**: Semantiska kopplingar mellan objekt (monteras_på, ersätter, etc.)
|
|
- **Standarder**: Normerande dokument per objekt
|
|
- **Problem**: Kända fel och livscykeldata
|
|
|
|
## Domäner
|
|
|
|
| Kod | Namn | Beskrivning |
|
|
|-----|------|-------------|
|
|
| TRP | Trafik & Väg | Väginfrastruktur, skyltar, signaler |
|
|
| ELN | El & Kraft | Kraftnät, transformatorer, kablar |
|
|
| VAT | Vatten & Avlopp | Vattenledningar, brunnar, ventiler |
|
|
| TEL | Telekom | Master, antenner, teknikbodar |
|
|
| PRK | Park & Stad | Möblering, grönytor, lekplatser |
|
|
| BYG | Byggnad | Fasader, belysning, HVAC |
|
|
| JVG | Järnväg | Räls, kontaktledning, plattformar |
|
|
| HMN | Hamn & Marin | Kajer, pollare, navigationshjälpmedel |
|
|
| FLG | Flygplats | Bankantljus, markeringar, terminaler |
|
|
|
|
## API & Data
|
|
|
|
- **REST API**: https://api.landvex.com/v0/
|
|
- **GraphQL**: https://api.landvex.com/graphql
|
|
- **OpenAPI-spec**: https://landvex.com/api/openapi.json
|
|
- **Dataset**: https://landvex.com/dataset/iol-v3.json
|
|
|
|
## Kontakt
|
|
|
|
- Webb: https://landvex.com
|
|
- API-dokumentation: https://landvex.com/docs
|
|
- Support: support@landvex.com
|
|
|
|
## Användningsvillkor
|
|
|
|
Data tillgänglig under CC BY-SA 4.0 för icke-kommersiell användning.
|
|
Kommersiell licens krävs för SaaS-integrering.
|
|
"""
|
|
|
|
with open(seo_dir / "llms.txt", "w", encoding="utf-8") as f:
|
|
f.write(llms_txt)
|
|
|
|
log("Genererade llms.txt")
|
|
|
|
# ai-index.json — strukturerad AI-index
|
|
ai_index = {
|
|
"name": "Landvex Infrastructure Object Library",
|
|
"description": "Kunskapsgraf över visuellt identifierbar fysisk infrastruktur",
|
|
"url": "https://landvex.com",
|
|
"api": {
|
|
"rest": "https://api.landvex.com/v0/",
|
|
"graphql": "https://api.landvex.com/graphql",
|
|
"openapi": "https://landvex.com/api/openapi.json"
|
|
},
|
|
"datasets": [
|
|
{
|
|
"name": "iol-v3",
|
|
"url": "https://landvex.com/dataset/iol-v3.json",
|
|
"format": "json",
|
|
"size": "~2MB",
|
|
"updated": datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
}
|
|
],
|
|
"domains": ["TRP", "ELN", "VAT", "TEL", "PRK", "BYG", "JVG", "HMN", "FLG"],
|
|
"languages": ["sv", "en"],
|
|
"license": "CC BY-SA 4.0",
|
|
"contact": "support@landvex.com"
|
|
}
|
|
|
|
with open(seo_dir / "ai-index.json", "w", encoding="utf-8") as f:
|
|
json.dump(ai_index, f, ensure_ascii=False, indent=2)
|
|
|
|
log("Genererade ai-index.json")
|
|
|
|
# sitemap.xml
|
|
urls = [
|
|
{"loc": "https://landvex.com/", "priority": "1.0"},
|
|
{"loc": "https://landvex.com/iol", "priority": "0.9"},
|
|
{"loc": "https://landvex.com/api", "priority": "0.8"},
|
|
{"loc": "https://landvex.com/docs", "priority": "0.8"},
|
|
{"loc": "https://landvex.com/dataset", "priority": "0.7"},
|
|
]
|
|
|
|
# Lägg till objektsidor
|
|
for post in master_data.get("poster", []):
|
|
slug = post.get("slug", "")
|
|
if slug:
|
|
urls.append({
|
|
"loc": f"https://landvex.com/iol/{post['doman'].lower()}/{slug}",
|
|
"priority": "0.6"
|
|
})
|
|
|
|
sitemap = '<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
sitemap += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
|
|
|
for url in urls:
|
|
sitemap += ' <url>\n'
|
|
sitemap += f' <loc>{url["loc"]}</loc>\n'
|
|
sitemap += f' <lastmod>{datetime.now(timezone.utc).strftime("%Y-%m-%d")}</lastmod>\n'
|
|
sitemap += f' <priority>{url["priority"]}</priority>\n'
|
|
sitemap += ' </url>\n'
|
|
|
|
sitemap += '</urlset>\n'
|
|
|
|
with open(seo_dir / "sitemap.xml", "w", encoding="utf-8") as f:
|
|
f.write(sitemap)
|
|
|
|
log(f"Genererade sitemap.xml med {len(urls)} URL:er")
|
|
|
|
|
|
def kor_agent(agent_id, cap, ko_path, prompt_path, output_dir, seo_dir):
|
|
"""Huvudloop för agenten."""
|
|
log(f"Agent {agent_id} startar (cap={cap})")
|
|
|
|
if not ANTHROPIC_API_KEY:
|
|
log("FEL: ANTHROPIC_API_KEY saknas")
|
|
return 0
|
|
|
|
# Initiera LLM-klient
|
|
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
|
|
system_prompt = ladda_prompt(prompt_path)
|
|
|
|
ko = ladda_ko(ko_path)
|
|
kandidater = []
|
|
producerade = 0
|
|
|
|
while producerade < cap:
|
|
post = hitta_nasta(ko, agent_id)
|
|
if not post:
|
|
log("Inga fler öppna poster i kön.")
|
|
break
|
|
|
|
# Lås posten
|
|
las_las(post, agent_id)
|
|
spara_ko(ko_path, ko)
|
|
log(f"Låst {post['ko_id']}: {post['mal']} (prio={post['prio']})")
|
|
|
|
try:
|
|
# Produktion med LLM
|
|
kandidat = producera_kandidatpost(post, agent_id, system_prompt, client)
|
|
kandidater.append(kandidat)
|
|
producerade += 1
|
|
|
|
# Markera som klar
|
|
markera_klar(post)
|
|
log(f"Klar {post['ko_id']}: {post['mal']}")
|
|
|
|
except Exception as e:
|
|
log(f"FEL {post['ko_id']}: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
# Släpp låset vid fel
|
|
post["status"] = "oppen"
|
|
if "las_tid" in post:
|
|
del post["las_tid"]
|
|
|
|
spara_ko(ko_path, ko)
|
|
|
|
# Spara kandidatfil om vi producerade något
|
|
if kandidater:
|
|
fil = skapa_kandidatfil(kandidater, agent_id, output_dir)
|
|
log(f"Sparade {len(kandidater)} kandidater till {fil}")
|
|
|
|
# Generera SEO-filer
|
|
try:
|
|
master_data = {"poster": []} # Simplified för nu
|
|
generera_seo_filer(seo_dir, master_data)
|
|
except Exception as e:
|
|
log(f"Varning: Kunde inte generera SEO-filer: {e}")
|
|
|
|
log(f"Agent {agent_id} avslutar. Producerade: {producerade}/{cap}")
|
|
return producerade
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Landvex Produktionsagent v3.0")
|
|
parser.add_argument("--agent-id", required=True, help="Unikt agent-ID (t.ex. erik-001, johan-001)")
|
|
parser.add_argument("--cap", type=int, default=5, help="Max antal poster per körning (default: 5)")
|
|
parser.add_argument("--ko", type=Path, default=DEFAULT_KO, help="Sökväg till arbetskö")
|
|
parser.add_argument("--prompt", type=Path, default=DEFAULT_PROMPT, help="Sökväg till systemprompt")
|
|
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR, help="Utdatakatalog")
|
|
parser.add_argument("--seo-dir", type=Path, default=DEFAULT_SEO_DIR, help="SEO-utdatakatalog")
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Validera agent-ID format
|
|
if not args.agent_id.replace("-", "").replace("_", "").isalnum():
|
|
log("FEL: agent-id får bara innehålla bokstäver, siffror, bindestreck och understreck")
|
|
sys.exit(1)
|
|
|
|
kor_agent(
|
|
agent_id=args.agent_id,
|
|
cap=args.cap,
|
|
ko_path=args.ko,
|
|
prompt_path=args.prompt,
|
|
output_dir=args.output_dir,
|
|
seo_dir=args.seo_dir
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|