#!/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 = '\n' sitemap += '\n' for url in urls: sitemap += ' \n' sitemap += f' {url["loc"]}\n' sitemap += f' {datetime.now(timezone.utc).strftime("%Y-%m-%d")}\n' sitemap += f' {url["priority"]}\n' sitemap += ' \n' sitemap += '\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()