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
342 lines
11 KiB
Python
342 lines
11 KiB
Python
#!/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()
|