#!/usr/bin/env python3 """ Landvex Produktionsagent — kor_agent_kimi.py Använder Kimi (Moonshot) via OpenClaw's interna API Skriver artiklar på ENGELSKA """ import argparse import json import os import sys import time from datetime import datetime, timezone from pathlib import Path # 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" def log(msg): ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") print(f"[{ts}] {msg}", flush=True) import fcntl def ladda_ko(ko_path): with open(ko_path, "r", encoding="utf-8") as f: fcntl.flock(f, fcntl.LOCK_SH) try: return json.load(f) finally: fcntl.flock(f, fcntl.LOCK_UN) def spara_ko(ko_path, data): with open(ko_path, "r+", encoding="utf-8") as f: fcntl.flock(f, fcntl.LOCK_EX) try: f.seek(0) json.dump(data, f, ensure_ascii=False, indent=2) f.truncate() finally: fcntl.flock(f, fcntl.LOCK_UN) 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 generera_artikel_med_kimi(amne, doman, system_prompt): """ Generera en komplett artikel med Kimi (Moonshot) via OpenClaw API. Skriver på ENGELSKA. """ import anthropic # Använd Anthropic som proxy (OpenClaw har Kimi konfigurerat internt) client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY")) user_prompt = f"""Generate a comprehensive technical article about the following infrastructure object. Write in ENGLISH. Object: {amne} Domain: {doman} Return the response as JSON in this exact format: {{ "slug": "url-friendly-slug-in-english", "namn": {{ "sv": "Swedish name", "en": "English name" }}, "beskrivning": "Detailed description of the object, its function and applications (at least 300 words in ENGLISH)", "teknik": {{ "material": ["Materials used"], "dimensioner": "Typical dimensions", "standarder": ["Applicable standards and norms"], "livslangd": "Expected lifespan" }}, "anvandningsomraden": ["List of applications"], "relaterade_objekt": ["List of related infrastructure objects"], "bilder": [ {{ "beskrivning": "Description of what the image should show", "alt_text": "Alt text for accessibility" }} ], "kallor": [ {{ "titel": "Source title", "url": "https://example.com", "typ": "webbsida|standard|dokument" }} ] }} IMPORTANT: 1. Return ONLY JSON, no other text before or after. 2. Description must be in ENGLISH and at least 300 words. 3. Include technical details specific to the object. 4. Use domain expertise: for VAT (water) include pressure classes, for TRP (traffic) include load classes, etc.""" log(f"Calling Claude API for: {amne} (ENGLISH)") response = client.messages.create( model="claude-sonnet-5", max_tokens=4000, system=system_prompt + "\n\nYou are writing technical documentation for an international infrastructure database. Write all content in ENGLISH.", 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("No text found in response") # 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 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): """Produktion av komplett kandidatpost med LLM.""" ko_id = post["ko_id"] amne = post["mal"] doman = post["doman"] log(f"Production starting: {ko_id} - {amne}") # Generera artikel med LLM artikel = generera_artikel_med_kimi(amne, doman, system_prompt) # 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} — infrastructure component") }, "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-generated post for {amne} with technical details and source references" } # 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"✓ Candidate post ready: {kandidat_id}") return kandidat def kor_agent(agent_id, cap, ko_path, prompt_path, output_dir): """Huvudloop för agenten.""" log(f"Agent {agent_id} starting (cap={cap})") if not os.environ.get("ANTHROPIC_API_KEY"): log("ERROR: ANTHROPIC_API_KEY missing") return 0 # Ladda system prompt system_prompt = "You are an expert in infrastructure and technical documentation." if Path(prompt_path).exists(): with open(prompt_path, "r", encoding="utf-8") as f: system_prompt = f.read() ko = ladda_ko(ko_path) kandidater = [] producerade = 0 while producerade < cap: post = hitta_nasta(ko, agent_id) if not post: log("No more open posts in queue.") break # Lås posten las_las(post, agent_id) spara_ko(ko_path, ko) log(f"Locked {post['ko_id']}: {post['mal']} (prio={post['prio']})") try: # Produktion med LLM kandidat = producera_kandidatpost(post, agent_id, system_prompt) kandidater.append(kandidat) producerade += 1 # Markera som klar markera_klar(post) log(f"Done {post['ko_id']}: {post['mal']}") except Exception as e: log(f"ERROR {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"Saved {len(kandidater)} candidates to {fil}") log(f"Agent {agent_id} finished. Produced: {producerade}/{cap}") return producerade def main(): parser = argparse.ArgumentParser(description="Landvex Production Agent v3.0 - ENGLISH") parser.add_argument("--agent-id", required=True, help="Unique agent ID") parser.add_argument("--cap", type=int, default=5, help="Max posts per run") parser.add_argument("--ko", type=Path, default=DEFAULT_KO, help="Queue path") parser.add_argument("--prompt", type=Path, default=DEFAULT_PROMPT, help="System prompt") parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR, help="Output dir") args = parser.parse_args() kor_agent( agent_id=args.agent_id, cap=args.cap, ko_path=args.ko, prompt_path=args.prompt, output_dir=args.output_dir ) if __name__ == "__main__": main()