Files
boc/projects/landvex/20-automation/kor_agent.py
T
Bernt 6de2455917 v1.2.0: Add Global Markets footer, translated to 9 languages
- 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
2026-07-08 19:56:03 +00:00

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()