landvex: IOL v3 + Identify API + Neo4j-graf + byggpipeline

- FastAPI Identify API (port 8081)
- Neo4j propertygraf (64 noder, 80 kanter)
- Ingest-pipeline (Python)
- Byggscript + integrationstester
- Docker Compose setup
This commit is contained in:
Bernt
2026-07-05 05:55:48 +00:00
parent 8dcca96185
commit 7d40cf95bf
61 changed files with 22330 additions and 0 deletions
@@ -0,0 +1,18 @@
import json, hashlib
from pathlib import Path
ROT = Path("/mnt/user-data/outputs/landvex-produktionspaket-v1")
ROLL = {"00-styrning":"styrning","10-data":"data (enda sanningskälla)","20-automation":"automation","30-produkt":"produkt","40-strategi":"strategi & rapporter"}
filer = []
for p in sorted(ROT.rglob("*")):
if p.is_file() and p.name != "MANIFEST.json":
rel = p.relative_to(ROT).as_posix()
filer.append({"sokvag": rel, "roll": ROLL[rel.split("/")[0]],
"bytes": p.stat().st_size,
"sha256": hashlib.sha256(p.read_bytes()).hexdigest()})
man = {"paket":"landvex-produktionspaket","version":"1.0","genererad":"2026-07-04",
"statistik":{"objektklasser":37,"kallbelagda_klasser":35,"produktmodeller":27,
"grafkanter":176,"aktiva_bounties":16,"bounty_forslag":17,"valideringsfel":0},
"kanonisk_not":"10-data/ ersätter alla tidigare versioner (master v1/v2, delfiler). Arkiv kvar i arbetsytan utanför paketet.",
"antal_filer":len(filer),"filer":filer}
(ROT/"00-styrning/MANIFEST.json").write_text(json.dumps(man,ensure_ascii=False,indent=2),encoding="utf-8")
print(f"MANIFEST: {len(filer)} filer checksummerade")
+328
View File
@@ -0,0 +1,328 @@
const pptxgen = require("pptxgenjs");
const React = require("react");
const ReactDOMServer = require("react-dom/server");
const sharp = require("sharp");
const {
FaSearch, FaClipboardList, FaExclamationTriangle, FaLayerGroup, FaTree, FaIndustry,
FaTint, FaQuestionCircle, FaBullseye, FaCamera, FaCheckCircle, FaSyncAlt, FaShieldAlt,
FaExchangeAlt, FaHistory, FaRocket, FaChartLine, FaBuilding, FaLink, FaDatabase
} = require("react-icons/fa");
// Palett: asfalt + galvstål + signalgul (vägmarkering)
const DARK = "23272B", CARD_D = "2E343B", INK = "2A3138", BODY = "3A4149";
const MUTED = "6B7480", MUTED_L = "AEB6BF", LIGHT = "F3F5F7", WHITE = "FFFFFF";
const YEL = "F5B301", YEL_TINT = "FFF6DC", LINE = "D9DEE4";
const H = "Arial", B = "Calibri", MONO = "Courier New";
const mkShadow = () => ({ type: "outer", color: "000000", blur: 7, offset: 2, angle: 45, opacity: 0.14 });
async function icon(Comp, color, size = 256) {
const svg = ReactDOMServer.renderToStaticMarkup(React.createElement(Comp, { color, size: String(size) }));
const png = await sharp(Buffer.from(svg)).png().toBuffer();
return "image/png;base64," + png.toString("base64");
}
(async () => {
const P = {};
P.search = await icon(FaSearch, "#" + INK);
P.clip = await icon(FaClipboardList, "#" + INK);
P.warn = await icon(FaExclamationTriangle, "#" + INK);
P.layer = await icon(FaLayerGroup, "#" + INK);
P.tree = await icon(FaTree, "#" + INK);
P.ind = await icon(FaIndustry, "#" + INK);
P.tint = await icon(FaTint, "#" + INK);
P.q = await icon(FaQuestionCircle, "#" + WHITE);
P.bull = await icon(FaBullseye, "#" + WHITE);
P.cam = await icon(FaCamera, "#" + WHITE);
P.check = await icon(FaCheckCircle, "#" + WHITE);
P.sync = await icon(FaSyncAlt, "#" + INK);
P.shield = await icon(FaShieldAlt, "#" + INK);
P.exch = await icon(FaExchangeAlt, "#" + INK);
P.hist = await icon(FaHistory, "#" + INK);
P.rocket = await icon(FaRocket, "#" + INK);
P.chart = await icon(FaChartLine, "#" + INK);
P.build = await icon(FaBuilding, "#" + INK);
P.link = await icon(FaLink, "#" + INK);
P.db = await icon(FaDatabase, "#" + INK);
P.clipY = await icon(FaClipboardList, "#" + INK);
P.warnY = await icon(FaExclamationTriangle, "#" + INK);
P.checkY = await icon(FaCheckCircle, "#" + INK);
const pres = new pptxgen();
pres.layout = "LAYOUT_16x9";
pres.author = "Landvex";
pres.title = "Landvex — Infrastructure Object Library, pilotpitch";
const title = (s, t, dark = false) =>
s.addText(t, { x: 0.6, y: 0.42, w: 8.8, h: 0.7, fontFace: H, fontSize: 36, bold: true, color: dark ? WHITE : INK, margin: 0 });
const circleIcon = (s, data, cx, cy, d, fill) => {
s.addShape(pres.shapes.OVAL, { x: cx, y: cy, w: d, h: d, fill: { color: fill } });
const pad = d * 0.26;
s.addImage({ data, x: cx + pad, y: cy + pad, w: d - 2 * pad, h: d - 2 * pad });
};
// ---------- S1 · Titel (mörk) ----------
let s = pres.addSlide();
s.background = { color: DARK };
s.addText("LANDVEX × QUIXZOOM", { x: 0.6, y: 0.55, w: 5.8, h: 0.35, fontFace: H, fontSize: 13, bold: true, color: YEL, charSpacing: 4, margin: 0 });
s.addText([
{ text: "Infrastructure", options: { breakLine: true } },
{ text: "Object Library" }
], { x: 0.6, y: 1.05, w: 5.9, h: 1.8, fontFace: H, fontSize: 42, bold: true, color: WHITE, margin: 0, lineSpacing: 46 });
s.addText("Vet vad som står där ute — varje objekt i gaturummet, med källa på varje faktum.", { x: 0.6, y: 2.95, w: 5.5, h: 0.75, fontFace: B, fontSize: 15, color: MUTED_L, margin: 0 });
s.addText("37 klasser · 26 modeller · 167 grafkanter · 0 valideringsfel", { x: 0.6, y: 3.85, w: 5.7, h: 0.35, fontFace: H, fontSize: 13, bold: true, color: YEL, margin: 0 });
s.addText("Pilotpitch · juli 2026", { x: 0.6, y: 5.05, w: 4, h: 0.3, fontFace: B, fontSize: 11, color: MUTED, margin: 0 });
const chips = [
["LVX-TRP-0102", "Vägbelysningsarmatur"],
["LVX-P-0000026", "Tesla Supercharger V4"],
["LVX-VAT-0103", "Brandpost / hydrant"],
["LVX-ELN-0101", "Kabelskåp lågspänning"]
];
chips.forEach((c, i) => {
const y = 1.05 + i * 0.82;
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x: 6.35, y, w: 3.05, h: 0.62, rectRadius: 0.06, fill: { color: CARD_D } });
s.addText([
{ text: c[0], options: { fontFace: MONO, fontSize: 11.5, bold: true, color: YEL, breakLine: true } },
{ text: c[1], options: { fontFace: B, fontSize: 10.5, color: WHITE } }
], { x: 6.55, y: y + 0.05, w: 2.75, h: 0.52, margin: 0, lineSpacing: 13 });
});
s.addNotes("Landvex bygger referensbiblioteket för fysisk infrastruktur. QUIXZOOM är fältarmen. Siffrorna är dagens bibliotek — validerat, inte vision.");
// ---------- S2 · Problemet ----------
s = pres.addSlide();
s.background = { color: WHITE };
title(s, "Gatan saknar identitet");
const rows2 = [
[P.clip, "Inventering är manuell och dyr", "Beståndsdata byggs om från noll vid varje upphandling och ägarbyte."],
[P.warn, "Utgångna modeller upptäcks för sent", "Reservdelsrisken syns först när armaturen slocknat eller locket spruckit."],
[P.search, "\u201dVad är detta?\u201d saknar svar i fält", "Tekniker, system och AI gissar — det finns ingen gemensam referens."]
];
rows2.forEach((r, i) => {
const y = 1.4 + i * 1.15;
circleIcon(s, r[0], 0.6, y, 0.52, YEL);
s.addText(r[1], { x: 1.35, y: y - 0.06, w: 4.15, h: 0.35, fontFace: H, fontSize: 15, bold: true, color: INK, margin: 0 });
s.addText(r[2], { x: 1.35, y: y + 0.3, w: 4.15, h: 0.62, fontFace: B, fontSize: 12, color: MUTED, margin: 0 });
});
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x: 5.85, y: 1.35, w: 3.55, h: 3.4, rectRadius: 0.09, fill: { color: WHITE }, line: { color: LINE, width: 1 }, shadow: mkShadow() });
s.addText("60M+", { x: 6.1, y: 1.6, w: 3.05, h: 0.95, fontFace: H, fontSize: 58, bold: true, color: INK, margin: 0 });
s.addText("elstolpar enbart i USA — de flesta bär identiteten inbränd i godset.", { x: 6.1, y: 2.65, w: 3.05, h: 0.85, fontFace: B, fontSize: 12.5, color: MUTED, margin: 0 });
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x: 6.1, y: 3.7, w: 3.05, h: 0.62, rectRadius: 0.06, fill: { color: YEL_TINT } });
s.addText("birthmark: år · klass · impregnering", { x: 6.28, y: 3.78, w: 2.75, h: 0.46, fontFace: MONO, fontSize: 10.5, bold: true, color: INK, margin: 0, valign: "middle" });
s.addNotes("Problemet är inte brist på data — det är brist på identitet. Ingen gemensam nyckel mellan fält, system och leverantörer.");
// ---------- S3 · Lösningen: fem nivåer ----------
s = pres.addSlide();
s.background = { color: WHITE };
title(s, "Ett bibliotek — fem nivåer");
const steps = [
["Domän", "TRP · VAT · ELN…"],
["Objektklass", "LVX-TRP-0102"],
["Typ", "Cobrahead-LED"],
["Produktmodell", "LVX-P-0000003"],
["Individ", "LVX-I-… (pilot)"]
];
steps.forEach((st, i) => {
const x = 0.6 + i * 1.9;
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x, y: 1.35, w: 1.55, h: 0.95, rectRadius: 0.08, fill: { color: LIGHT } });
s.addText([
{ text: st[0], options: { fontFace: H, fontSize: 12.5, bold: true, color: INK, breakLine: true } },
{ text: st[1], options: { fontFace: MONO, fontSize: 9.5, color: MUTED } }
], { x: x + 0.12, y: 1.47, w: 1.31, h: 0.75, margin: 0, lineSpacing: 14 });
if (i < 4) s.addText("\u203A", { x: x + 1.55, y: 1.55, w: 0.35, h: 0.5, fontFace: H, fontSize: 20, color: MUTED, align: "center", margin: 0 });
});
const cards3 = [
[P.link, "Permanenta LVX-ID", "Identiteten överlever ägarbyten, versioner och systembyten — referensen alla kan peka på."],
[P.db, "Källa på varje faktum", "Problem utan källa publiceras aldrig. PLATSHÅLLARE-regeln är hård — juridik by design."],
[P.hist, "Bitemporal fakta", "Vad som gällde där ute — och när vi visste det. Ersättningskedjor blir spårbara."]
];
cards3.forEach((c, i) => {
const x = 0.6 + i * 3.07;
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x, y: 2.85, w: 2.87, h: 2.15, rectRadius: 0.09, fill: { color: LIGHT } });
circleIcon(s, c[0], x + 0.22, y3(i), 0.46, YEL);
s.addText(c[1], { x: x + 0.85, y: 3.05, w: 1.95, h: 0.6, fontFace: H, fontSize: 13.5, bold: true, color: INK, margin: 0 });
s.addText(c[2], { x: x + 0.22, y: 3.75, w: 2.45, h: 1.1, fontFace: B, fontSize: 11.5, color: BODY, margin: 0 });
});
function y3() { return 3.05; }
s.addNotes("Fem nivåer: domän ned till individ. Piloten fyller individlagret för kundens geografi. ID:na är permanenta — det är vallgraven.");
// ---------- S4 · Bevis ----------
s = pres.addSlide();
s.background = { color: WHITE };
title(s, "Byggt — och validerat");
const stats = [
["37", "objektklasser — 31 källbelagda"],
["26", "produktmodeller — alla källbelagda"],
["167", "grafkanter i konsolidering v2"],
["0", "valideringsfel mot schemat"]
];
stats.forEach((st, i) => {
const x = 0.6 + i * 2.25;
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x, y: 1.3, w: 2.05, h: 1.45, rectRadius: 0.09, fill: { color: WHITE }, line: { color: LINE, width: 1 }, shadow: mkShadow() });
s.addText(st[0], { x: x + 0.18, y: 1.42, w: 1.7, h: 0.7, fontFace: H, fontSize: 40, bold: true, color: i === 3 ? YEL : INK, margin: 0 });
s.addText(st[1], { x: x + 0.18, y: 2.14, w: 1.72, h: 0.55, fontFace: B, fontSize: 10.5, color: MUTED, margin: 0 });
});
s.addText("Verifieringstrappan — varje fältfoto lyfter posten ett steg", { x: 0.6, y: 3.12, w: 8.8, h: 0.3, fontFace: B, fontSize: 12.5, italic: true, color: MUTED, margin: 0 });
const stair = ["Obekräftad", "Källbelagd", "Fältverifierad", "Tillverkarbekräftad"];
stair.forEach((t, i) => {
const h = 0.55 + i * 0.28, x = 0.6 + i * 2.25;
s.addShape(pres.shapes.RECTANGLE, { x, y: 5.05 - h, w: 2.05, h, fill: { color: i === 3 ? YEL : ["E7EAEE", "D5DAE0", "BFC7CF"][i] } });
s.addText(t, { x: x + 0.1, y: 5.05 - h + 0.08, w: 1.85, h: 0.35, fontFace: H, fontSize: 11.5, bold: true, color: INK, margin: 0 });
});
s.addNotes("Detta är inte en idé — biblioteket finns, är källbelagt och maskinvaliderat. Trappan är kvalitetsmodellen: piloten flyttar poster till fältverifierad.");
// ---------- S5 · OCR-guldet ----------
s = pres.addSlide();
s.background = { color: WHITE };
title(s, "Identiteten sitter i godset");
const cards5 = [
[P.tree, "Stolpens birthmark", "Inbränd märkning i ögonhöjd: år, längd, klass och impregneringsmedel.", "år · klass · CCA/penta"],
[P.ind, "Gjutmärket", "Tillverkare, funktion och lastklass gjuts in i lock och ram.", "ULEFOS · EN124-2 · D400"],
[P.tint, "Hydrantens kropp", "AWWA kräver gjuten märkning — tillverkningsår och öppningsriktning.", "AMERICAN-DARLING · 1998"]
];
cards5.forEach((c, i) => {
const x = 0.6 + i * 3.07;
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x, y: 1.3, w: 2.87, h: 3.15, rectRadius: 0.09, fill: { color: LIGHT } });
circleIcon(s, c[0], x + 0.22, 1.55, 0.5, YEL);
s.addText(c[1], { x: x + 0.88, y: 1.6, w: 1.95, h: 0.6, fontFace: H, fontSize: 14, bold: true, color: INK, margin: 0 });
s.addText(c[2], { x: x + 0.22, y: 2.35, w: 2.45, h: 1.05, fontFace: B, fontSize: 11.5, color: BODY, margin: 0 });
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x: x + 0.22, y: 3.6, w: 2.43, h: 0.55, rectRadius: 0.06, fill: { color: WHITE }, line: { color: LINE, width: 1 } });
s.addText(c[3], { x: x + 0.36, y: 3.66, w: 2.2, h: 0.43, fontFace: MONO, fontSize: 10, bold: true, color: INK, margin: 0, valign: "middle" });
});
s.addText("Ingen databas krävs på plats — objektet bär sin egen identitet. OCR-först är protokollets kärna.", { x: 0.6, y: 4.75, w: 8.8, h: 0.35, fontFace: B, fontSize: 13, italic: true, color: MUTED, margin: 0 });
s.addNotes("Läsbarheten är affärsidén: birthmarks, gjutmärken och typskyltar gör varje foto till en databaspost.");
// ---------- S6 · Flyhjulet ----------
s = pres.addSlide();
s.background = { color: WHITE };
title(s, "Flyhjulet: QUIXZOOM");
const nodes = [
[2.3, 1.12, P.q, "Kundfråga", "identify → okänd"],
[3.85, 2.38, P.bull, "Bounty", "skapas automatiskt"],
[2.3, 3.6, P.cam, "Zoomer-foto", "OCR + position"],
[0.75, 2.38, P.check, "Fältverifierad", "post + individ"]
];
nodes.forEach(n => {
circleIcon(s, n[2], n[0], n[1], 0.95, DARK);
s.addText(n[3], { x: n[0] - 0.45, y: n[1] + 0.92, w: 1.85, h: 0.28, fontFace: H, fontSize: 11.5, bold: true, color: INK, align: "center", margin: 0 });
s.addText(n[4], { x: n[0] - 0.45, y: n[1] + 1.16, w: 1.85, h: 0.24, fontFace: B, fontSize: 9.5, color: MUTED, align: "center", margin: 0 });
});
circleIcon(s, P.sync, 2.5, 2.6, 0.55, YEL);
s.addText("\u2198", { x: 3.5, y: 2.0, w: 0.4, h: 0.4, fontFace: H, fontSize: 16, color: MUTED, margin: 0 });
s.addText("\u2199", { x: 3.5, y: 3.3, w: 0.4, h: 0.4, fontFace: H, fontSize: 16, color: MUTED, margin: 0 });
s.addText("\u2196", { x: 1.85, y: 3.3, w: 0.4, h: 0.4, fontFace: H, fontSize: 16, color: MUTED, margin: 0 });
s.addText("\u2197", { x: 1.85, y: 2.0, w: 0.4, h: 0.4, fontFace: H, fontSize: 16, color: MUTED, margin: 0 });
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x: 5.85, y: 1.3, w: 3.55, h: 3.2, rectRadius: 0.09, fill: { color: LIGHT } });
s.addText("Bounty-motorn", { x: 6.1, y: 1.5, w: 3.05, h: 0.35, fontFace: H, fontSize: 14, bold: true, color: INK, margin: 0 });
s.addText([
{ text: "16 aktiva bounties ur posternas OCR-fält", options: { bullet: true, breakLine: true } },
{ text: "Poäng: bas · först-på-individ ×2 · full OCR +50 %", options: { bullet: true, breakLine: true } },
{ text: "Hårda säkerhetsregler — allt fotas från gångyta", options: { bullet: true, breakLine: true } },
{ text: "Varje \u201dobekräftad\u201d i API:t skapar en bounty", options: { bullet: true } }
], { x: 6.1, y: 1.95, w: 3.1, h: 2.4, fontFace: B, fontSize: 11.5, color: BODY, paraSpaceAfter: 8, margin: 0 });
s.addText("Kundens fråga finansierar datan som gör nästa svar bättre.", { x: 0.6, y: 5.12, w: 8.8, h: 0.35, fontFace: B, fontSize: 13, italic: true, color: MUTED, margin: 0 });
s.addNotes("Flyhjulet: obesvarade kundfrågor blir bounties, Zoomers fotograferar, posten fältverifieras, svaret blir bättre. Andelen kunddrivna bounties är hälsotalet.");
// ---------- S7 · API:t ----------
s = pres.addSlide();
s.background = { color: WHITE };
title(s, "Produkten: Object API v0");
const eps = [
["POST", "/v0/identify", "bild in → modell ut"],
["GET", "/v0/objects/{lvx_id}", "hela posten + källor"],
["GET", "/v0/objects/{id}/succession", "ersättningskedjan"],
["GET", "/v0/search", "fritext + filter"],
["GET", "/v0/standards/{beteckning}", "objekt per standard"],
["POST", "/v0/feedback", "fel/okänt → bounty"]
];
eps.forEach((e, i) => {
const y = 1.3 + i * 0.58;
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x: 0.6, y, w: 0.62, h: 0.32, rectRadius: 0.05, fill: { color: e[0] === "POST" ? YEL : CARD_D } });
s.addText(e[0], { x: 0.6, y, w: 0.62, h: 0.32, fontFace: H, fontSize: 9, bold: true, color: e[0] === "POST" ? INK : WHITE, align: "center", valign: "middle", margin: 0 });
s.addText(e[1], { x: 1.35, y: y + 0.01, w: 2.62, h: 0.32, fontFace: MONO, fontSize: 10.5, bold: true, color: INK, margin: 0, valign: "middle" });
s.addText(e[2], { x: 4.02, y: y + 0.01, w: 1.72, h: 0.32, fontFace: B, fontSize: 9.5, color: MUTED, margin: 0, valign: "middle" });
});
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x: 5.85, y: 1.3, w: 3.55, h: 3.45, rectRadius: 0.09, fill: { color: WHITE }, line: { color: LINE, width: 1 }, shadow: mkShadow() });
circleIcon(s, P.shield, 6.1, 1.52, 0.46, YEL);
s.addText("Svarskontraktet", { x: 6.72, y: 1.58, w: 2.6, h: 0.35, fontFace: H, fontSize: 14, bold: true, color: INK, margin: 0 });
s.addText([
{ text: "verifieringsnivå — alltid med", options: { bullet: true, breakLine: true } },
{ text: "konfidens 01 — aldrig dold", options: { bullet: true, breakLine: true } },
{ text: "källor[] på varje påstående", options: { bullet: true, breakLine: true } },
{ text: "PLATSHÅLLARE filtreras bort vid serialisering", options: { bullet: true, breakLine: true } },
{ text: "svagt svar ⇒ bounty-ID följer med", options: { bullet: true } }
], { x: 6.1, y: 2.15, w: 3.1, h: 2.4, fontFace: B, fontSize: 11.5, color: BODY, paraSpaceAfter: 8, margin: 0 });
s.addText("Juridiken sitter i kontraktet — referenser och beteckningar, aldrig standardtext.", { x: 0.6, y: 5.05, w: 8.8, h: 0.35, fontFace: B, fontSize: 13, italic: true, color: MUTED, margin: 0 });
s.addNotes("Sex endpoints räcker för piloten. Kontraktet är differentieringen: ärlighet och proveniens är obligatoriska fält, inte fotnoter.");
// ---------- S8 · Golden cases ----------
s = pres.addSlide();
s.background = { color: WHITE };
title(s, "Tre svar som säljer");
const g = [
[P.exch, "Ersättningskedjan", "GE ERS identifieras → status: ersatt → ersättare: ERL-serien. Reservdelsrisken syns innan lagret tar slut.", "identify → succession"],
[P.hist, "Årsmodell ur godset", "American-Darling B-84-B, gjutet 1998, öppnar vänster — individdata utan register.", "konfidens 0,97 · källbelagd"],
[P.bull2 || P.search, "Ärlig när den inte vet", "Okänt UK-skåp → bästa klass, konfidens 0,71 — och en bounty skapas automatiskt.", "svar + BNTY-A0001"]
];
g.forEach((c, i) => {
const x = 0.6 + i * 3.07;
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x, y: 1.3, w: 2.87, h: 3.15, rectRadius: 0.09, fill: { color: LIGHT } });
circleIcon(s, c[0], x + 0.22, 1.55, 0.5, YEL);
s.addText(c[1], { x: x + 0.88, y: 1.6, w: 1.95, h: 0.6, fontFace: H, fontSize: 14, bold: true, color: INK, margin: 0 });
s.addText(c[2], { x: x + 0.22, y: 2.35, w: 2.45, h: 1.15, fontFace: B, fontSize: 11.5, color: BODY, margin: 0 });
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x: x + 0.22, y: 3.6, w: 2.43, h: 0.55, rectRadius: 0.06, fill: { color: WHITE }, line: { color: LINE, width: 1 } });
s.addText(c[3], { x: x + 0.36, y: 3.66, w: 2.2, h: 0.43, fontFace: MONO, fontSize: 10, bold: true, color: INK, margin: 0, valign: "middle" });
});
s.addText("Alla tre körs på riktiga poster — demodatat ingår i pilotpaketet.", { x: 0.6, y: 4.75, w: 8.8, h: 0.35, fontFace: B, fontSize: 13, italic: true, color: MUTED, margin: 0 });
s.addNotes("Tre golden cases som demoas live: succession, individdata ur gjutgods, och ärlighet + bounty vid okänt objekt.");
// ---------- S9 · Paketering ----------
s = pres.addSlide();
s.background = { color: WHITE };
title(s, "Paketering");
const pk = [
[P.rocket, "Pilot", "13 designpartners", ["Fast månadsavgift, generös kvot", "Gemensam backlog styr utvecklingen", "Betalar även med fältaccess"], LIGHT],
[P.chart, "Pro", "entreprenad · besiktning · försäkring", ["Usage-based per identify", "Billiga uppslag driver LVX-ID-inlåsning", "Volymtrappa"], YEL_TINT],
[P.build, "Enterprise", "nätbolag · förvaltare · tillverkare", ["SLA + eget individlager (LVX-I)", "Bulkexport och integrationer", "Riktade bounties i egen geografi"], LIGHT]
];
pk.forEach((c, i) => {
const x = 0.6 + i * 3.07;
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x, y: 1.3, w: 2.87, h: 3.1, rectRadius: 0.09, fill: { color: c[4] }, shadow: i === 1 ? mkShadow() : undefined });
circleIcon(s, c[0], x + 0.22, 1.52, 0.46, WHITE === c[4] ? LIGHT : WHITE);
s.addText(c[1], { x: x + 0.82, y: 1.55, w: 1.95, h: 0.35, fontFace: H, fontSize: 16, bold: true, color: INK, margin: 0 });
s.addText(c[2], { x: x + 0.22, y: 2.12, w: 2.45, h: 0.3, fontFace: B, fontSize: 10.5, italic: true, color: MUTED, margin: 0 });
s.addText(c[3].map((t, j) => ({ text: t, options: { bullet: true, breakLine: j < 2 } })), { x: x + 0.22, y: 2.5, w: 2.5, h: 1.75, fontFace: B, fontSize: 11, color: BODY, paraSpaceAfter: 7, margin: 0 });
});
s.addText("Identifieringen är värdet · uppslaget är räckvidden · bounty-krediter köper fältverifiering där kunden behöver den.", { x: 0.6, y: 4.75, w: 8.8, h: 0.35, fontFace: B, fontSize: 12.5, italic: true, color: MUTED, margin: 0 });
s.addNotes("SaaS-logiken: identify prissätts per värdefullt svar, uppslag hålls billiga för att göra LVX-ID till branschreferens, Enterprise får eget individlager.");
// ---------- S10 · Piloten (mörk) ----------
s = pres.addSlide();
s.background = { color: DARK };
title(s, "Piloten: 90 dagar till bevis", true);
const uc = [
[P.clipY, "Inventering", "Fota beståndet → klass, modell och status per objekt."],
[P.warnY, "Utgånget-listan", "Allt med status ersatt/utgången — med ersättare och källa."],
[P.checkY, "Problemflagg", "Källbelagda klassproblem matchade mot ert bestånd."]
];
uc.forEach((r, i) => {
const y = 1.45 + i * 1.1;
circleIcon(s, r[0], 0.6, y, 0.52, YEL);
s.addText(r[1], { x: 1.35, y: y - 0.05, w: 4.1, h: 0.35, fontFace: H, fontSize: 15, bold: true, color: WHITE, margin: 0 });
s.addText(r[2], { x: 1.35, y: y + 0.31, w: 4.1, h: 0.6, fontFace: B, fontSize: 12, color: MUTED_L, margin: 0 });
});
s.addShape(pres.shapes.ROUNDED_RECTANGLE, { x: 5.85, y: 1.35, w: 3.55, h: 3.35, rectRadius: 0.09, fill: { color: CARD_D } });
s.addText("Erbjudandet", { x: 6.1, y: 1.55, w: 3.05, h: 0.35, fontFace: H, fontSize: 14, bold: true, color: WHITE, margin: 0 });
s.addText([
{ text: "Avgränsat område, 90 dagar", options: { bullet: true, breakLine: true } },
{ text: "Fast pilotavgift — kvittas mot årsavtal", options: { bullet: true, breakLine: true } },
{ text: "Gemensam backlog styr bounties", options: { bullet: true, breakLine: true } },
{ text: "Ni behåller export av ert individlager", options: { bullet: true } }
], { x: 6.1, y: 2.0, w: 3.1, h: 1.85, fontFace: B, fontSize: 11.5, color: MUTED_L, paraSpaceAfter: 8, margin: 0 });
s.addText("Boka pilotworkshop \u2192", { x: 6.1, y: 4.05, w: 3.05, h: 0.45, fontFace: H, fontSize: 17, bold: true, color: YEL, margin: 0 });
s.addText("LANDVEX × QUIXZOOM · juli 2026", { x: 0.6, y: 5.08, w: 5, h: 0.3, fontFace: B, fontSize: 11, color: MUTED, charSpacing: 2, margin: 0 });
s.addNotes("Stäng med erbjudandet: 90 dagar, avgränsat område, tre användningsfall, fast avgift som kvittas. Nästa steg är en workshop där området och backloggen väljs.");
await pres.writeFile({ fileName: "/home/claude/landvex-pilotpitch.pptx" });
console.log("deck skriven");
})().catch(e => { console.error(e); process.exit(1); });
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
# Landvex IOL — våg 4: datastädning, validering och grafexport
import json, re, sys
from pathlib import Path
OUT = Path("/mnt/user-data/outputs")
PREDIKAT = {"monteras_pa","star_pa","ansluts_med","matas_fran","ingar_i","del_af" if False else "del_av",
"ersatter","ersatts_av","kompatibel_med","forvaxlas_med","tillverkas_av","foljer_standard","variant_av"}
VERIF = {"obekraftad","kallbelagd","faltverifierad","tillverkarbekraftad"}
DOMANER = {"TRP","ELN","VAT","TEL","BYG","PRK","JVG","HMN","FLG"}
ID_RE = re.compile(r"^LVX-(TRP|ELN|VAT|TEL|BYG|PRK|JVG|HMN|FLG)-[0-9]{4}$|^LVX-P-[0-9]{7}$")
def load(name):
with open(OUT / name, encoding="utf-8") as f:
return json.load(f)
# ---------- 1. Ladda klassposter ----------
klass_filer = ["lvx-klassposter-trp.json","lvx-klassposter-vat.json",
"lvx-klassposter-eln.json","lvx-klassposter-tel-prk.json"]
poster = {}
for fn in klass_filer:
for p in load(fn)["poster"]:
poster[p["lvx_id"]] = p
# Våg 2-uppdateringar ersätter
uppdaterade = 0
for p in load("lvx-klassposter-vag2-uppdaterade.json")["poster"]:
poster[p["lvx_id"]] = p
uppdaterade += 1
# ---------- 2. Relationskarta ----------
t2 = load("lvx-taxonomi-tier2.json")
karta = {}
for rad in t2["relationskarta"]:
nytt = rad["nytt_id"]
if "/" in nytt: # dubbelmappning: skyltstolpe/portal
karta[rad["platshallare"]] = ("LVX-TRP-0201", "portalvariant → LVX-TRP-0205")
else:
karta[rad["platshallare"]] = (nytt, None)
for txt in ["Fundament/fotplatta", "Fundament/stolpe", "Direkt i mark / fundament"]:
karta[txt] = ("LVX-TRP-0203", None)
def multi_lvx(mal):
return " / " in mal and all(ID_RE.match(x.strip()) for x in mal.split(" / "))
remappade, olosta = 0, {}
for p in poster.values():
for rel in p.get("relationer", []):
mal = rel.get("mal","")
if mal in karta:
nytt, notis = karta[mal]
rel["mal"] = nytt
if notis:
rel["beskrivning"] = (rel.get("beskrivning","") + " | " + notis).strip(" |")
remappade += 1
elif rel.get("predikat") == "foljer_standard":
pass # standardnod — hanteras i grafexporten
elif multi_lvx(mal):
pass # multipla LVX-mål — splittas i grafexporten
elif not ID_RE.match(mal):
olosta.setdefault(mal, 0)
olosta[mal] += 1
# ---------- 3. Ladda produktmodeller ----------
modeller = {}
for fn in ["lvx-produktmodeller-vag2.json","lvx-produktmodeller-vag3.json"]:
for m in load(fn)["poster"]:
modeller[m["lvx_id"]] = m
# ---------- 4. Validering ----------
fel = []
def kolla(rec, typ):
rid = rec.get("lvx_id","?")
if not ID_RE.match(rid): fel.append(f"{rid}: ogiltigt ID-format")
if rec.get("doman") not in DOMANER: fel.append(f"{rid}: ogiltig domän")
namn = rec.get("namn",{})
if not (namn.get("sv") and namn.get("en")): fel.append(f"{rid}: namn.sv/en saknas")
if rec.get("posttyp") not in {"objektklass","produktmodell"}: fel.append(f"{rid}: posttyp")
prov = rec.get("proveniens",{})
if prov.get("verifieringsniva") not in VERIF: fel.append(f"{rid}: verifieringsnivå")
if not prov.get("skapad"): fel.append(f"{rid}: proveniens.skapad saknas")
for pr in rec.get("problem",[]):
if not pr.get("kalla"): fel.append(f"{rid}: problem utan källa")
for rel in rec.get("relationer",[]):
if rel.get("predikat") not in PREDIKAT: fel.append(f"{rid}: okänt predikat {rel.get('predikat')}")
if typ=="modell" and not rec.get("parent"): fel.append(f"{rid}: parent saknas")
if prov.get("verifieringsniva")=="kallbelagd" and not prov.get("kallor"):
fel.append(f"{rid}: källbelagd utan kallor")
for p in poster.values(): kolla(p,"klass")
for m in modeller.values(): kolla(m,"modell")
# ---------- 5. Grafkanter ----------
kanter = []
def kant(s, pred, obj, niva, beskr=""):
typ = "lvx" if ID_RE.match(obj or "") else ("standard" if pred=="foljer_standard" else "text")
kanter.append({"subjekt": s, "predikat": pred, "objekt": obj, "objekt_typ": typ,
"niva": niva, **({"beskrivning": beskr} if beskr else {})})
def emit(s, pred, mal, niva, beskr=""):
if multi_lvx(mal):
for x in mal.split(" / "):
kant(s, pred, x.strip(), niva, beskr)
else:
kant(s, pred, mal, niva, beskr)
for p in poster.values():
for rel in p.get("relationer", []):
emit(p["lvx_id"], rel["predikat"], rel["mal"], "klass", rel.get("beskrivning",""))
for st in p.get("standarder", []):
if st.get("beteckning"):
kant(p["lvx_id"], "foljer_standard", st["beteckning"], "klass", st.get("roll",""))
for m in modeller.values():
kant(m["lvx_id"], "tillhor_klass", m["parent"], "modell") # ontologibeslut v1.1
hist = m.get("historik", {})
for e in hist.get("ersatter", []): kant(m["lvx_id"], "ersatter", e, "modell")
for e in hist.get("ersatts_av", []): kant(m["lvx_id"], "ersatts_av", e, "modell")
for rel in m.get("relationer", []):
emit(m["lvx_id"], rel["predikat"], rel["mal"], "modell", rel.get("beskrivning",""))
lvx_kanter = sum(1 for k in kanter if k["objekt_typ"]=="lvx")
std_kanter = sum(1 for k in kanter if k["objekt_typ"]=="standard")
# ---------- 6. Skriv ut ----------
master = {
"beskrivning": "Landvex IOL — konsoliderade Tier 1-klassposter v1. Våg 2-uppdateringar inarbetade, relationer ommappade enligt Tier 2-relationskartan. Ersätter de fyra våg 1-filerna + vag2-uppdaterade som arbetskopia.",
"genererad": "2026-07-04",
"antal_poster": len(poster),
"poster": [poster[k] for k in sorted(poster)]
}
with open(OUT/"lvx-klassposter-master-v1.json","w",encoding="utf-8") as f:
json.dump(master,f,ensure_ascii=False,indent=2)
graf = {
"beskrivning": "Landvex IOL — grafexport v1. Kanter ur klass- och modellposter. objekt_typ: lvx=upplöst nod, standard=standardbeteckning, text=olöst platshållare (framtida klass). Predikatet tillhor_klass (modell→klass) är ontologibeslut v1.1.",
"genererad": "2026-07-04",
"noder": {"objektklasser_tier1": len(poster), "objektklasser_tier2": len(t2["objektklasser"]), "produktmodeller": len(modeller)},
"statistik": {"kanter_totalt": len(kanter), "upplosta_lvx_kanter": lvx_kanter, "standardkanter": std_kanter,
"textnoder_olosta": len(olosta)},
"olosta_platshallare": [{"text": k, "forekomster": v} for k,v in sorted(olosta.items())],
"kanter": kanter
}
with open(OUT/"lvx-graf-kanter-v1.json","w",encoding="utf-8") as f:
json.dump(graf,f,ensure_ascii=False,indent=2)
print(f"Klassposter: {len(poster)} (varav {uppdaterade} våg2-uppdaterade inarbetade)")
print(f"Produktmodeller: {len(modeller)}")
print(f"Relationer ommappade till Tier 2-ID: {remappade}")
print(f"Grafkanter: {len(kanter)} totalt | {lvx_kanter} upplösta LVX-kanter | {std_kanter} standardkanter")
print(f"Olösta textnoder: {len(olosta)}")
for k,v in sorted(olosta.items()): print(f" - {k} ({v})")
print(f"Valideringsfel: {len(fel)}")
for e in fel: print(f" ! {e}")
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
# Landvex IOL — våg 5: konsolidering v2 (Tier 1 + Tier 2), validering, grafexport
import json, re
from pathlib import Path
OUT = Path("/mnt/user-data/outputs")
PREDIKAT = {"monteras_pa","star_pa","ansluts_med","matas_fran","ingar_i","del_av",
"ersatter","ersatts_av","kompatibel_med","forvaxlas_med","tillverkas_av","foljer_standard","variant_av"}
VERIF = {"obekraftad","kallbelagd","faltverifierad","tillverkarbekraftad"}
DOMANER = {"TRP","ELN","VAT","TEL","BYG","PRK","JVG","HMN","FLG"}
ID_RE = re.compile(r"^LVX-(TRP|ELN|VAT|TEL|BYG|PRK|JVG|HMN|FLG)-[0-9]{4}$|^LVX-P-[0-9]{7}$")
def load(name):
with open(OUT / name, encoding="utf-8") as f:
return json.load(f)
poster = {}
for fn in ["lvx-klassposter-master-v1.json","lvx-klassposter-tier2-trp.json","lvx-klassposter-tier2-ovriga.json"]:
for p in load(fn)["poster"]:
poster[p["lvx_id"]] = p
def multi_lvx(mal):
return " / " in mal and all(ID_RE.match(x.strip()) for x in mal.split(" / "))
olosta = {}
for p in poster.values():
for rel in p.get("relationer", []):
mal = rel.get("mal","")
if rel.get("predikat") == "foljer_standard" or multi_lvx(mal) or ID_RE.match(mal):
continue
olosta[mal] = olosta.get(mal, 0) + 1
modeller = {}
for fn in ["lvx-produktmodeller-vag2.json","lvx-produktmodeller-vag3.json"]:
for m in load(fn)["poster"]:
modeller[m["lvx_id"]] = m
fel = []
def kolla(rec, typ):
rid = rec.get("lvx_id","?")
if not ID_RE.match(rid): fel.append(f"{rid}: ogiltigt ID-format")
if rec.get("doman") not in DOMANER: fel.append(f"{rid}: ogiltig domän")
namn = rec.get("namn",{})
if not (namn.get("sv") and namn.get("en")): fel.append(f"{rid}: namn.sv/en saknas")
if rec.get("posttyp") not in {"objektklass","produktmodell"}: fel.append(f"{rid}: posttyp")
prov = rec.get("proveniens",{})
if prov.get("verifieringsniva") not in VERIF: fel.append(f"{rid}: verifieringsnivå")
if not prov.get("skapad"): fel.append(f"{rid}: proveniens.skapad saknas")
for pr in rec.get("problem",[]):
if not pr.get("kalla"): fel.append(f"{rid}: problem utan källa")
for rel in rec.get("relationer",[]):
if rel.get("predikat") not in PREDIKAT: fel.append(f"{rid}: okänt predikat {rel.get('predikat')}")
if typ=="modell" and not rec.get("parent"): fel.append(f"{rid}: parent saknas")
if prov.get("verifieringsniva")=="kallbelagd" and not prov.get("kallor"):
fel.append(f"{rid}: källbelagd utan kallor")
for p in poster.values(): kolla(p,"klass")
for m in modeller.values(): kolla(m,"modell")
kanter = []
def kant(s, pred, obj, niva, beskr=""):
typ = "lvx" if ID_RE.match(obj or "") else ("standard" if pred=="foljer_standard" else "text")
kanter.append({"subjekt": s, "predikat": pred, "objekt": obj, "objekt_typ": typ,
"niva": niva, **({"beskrivning": beskr} if beskr else {})})
def emit(s, pred, mal, niva, beskr=""):
if multi_lvx(mal):
for x in mal.split(" / "):
kant(s, pred, x.strip(), niva, beskr)
else:
kant(s, pred, mal, niva, beskr)
for p in poster.values():
for rel in p.get("relationer", []):
emit(p["lvx_id"], rel["predikat"], rel["mal"], "klass", rel.get("beskrivning",""))
for st in p.get("standarder", []):
if st.get("beteckning"):
kant(p["lvx_id"], "foljer_standard", st["beteckning"], "klass", st.get("roll",""))
for m in modeller.values():
kant(m["lvx_id"], "tillhor_klass", m["parent"], "modell")
hist = m.get("historik", {})
for e in hist.get("ersatter", []): kant(m["lvx_id"], "ersatter", e, "modell")
for e in hist.get("ersatts_av", []): kant(m["lvx_id"], "ersatts_av", e, "modell")
for rel in m.get("relationer", []):
emit(m["lvx_id"], rel["predikat"], rel["mal"], "modell", rel.get("beskrivning",""))
lvx_k = sum(1 for k in kanter if k["objekt_typ"]=="lvx")
std_k = sum(1 for k in kanter if k["objekt_typ"]=="standard")
verif_count = {}
for p in poster.values():
v = p["proveniens"]["verifieringsniva"]
verif_count[v] = verif_count.get(v,0)+1
master = {
"beskrivning": "Landvex IOL — konsoliderade klassposter v2: Tier 1 + Tier 2 (37 klasser). Ersätter master-v1 och de två tier2-filerna som arbetskopia.",
"genererad": "2026-07-04",
"antal_poster": len(poster),
"verifieringsfordelning": verif_count,
"poster": [poster[k] for k in sorted(poster)]
}
with open(OUT/"lvx-klassposter-master-v2.json","w",encoding="utf-8") as f:
json.dump(master,f,ensure_ascii=False,indent=2)
graf = {
"beskrivning": "Landvex IOL — grafexport v2. Tier 1 + Tier 2-klasser samt produktmodeller. objekt_typ: lvx=upplöst nod, standard=standardbeteckning, text=olöst platshållare (framtida klass).",
"genererad": "2026-07-04",
"noder": {"objektklasser": len(poster), "produktmodeller": len(modeller)},
"statistik": {"kanter_totalt": len(kanter), "upplosta_lvx_kanter": lvx_k, "standardkanter": std_k, "textnoder_olosta": len(olosta)},
"olosta_platshallare": [{"text": k, "forekomster": v} for k,v in sorted(olosta.items())],
"kanter": kanter
}
with open(OUT/"lvx-graf-kanter-v2.json","w",encoding="utf-8") as f:
json.dump(graf,f,ensure_ascii=False,indent=2)
print(f"Klassposter: {len(poster)} | fördelning: {verif_count}")
print(f"Produktmodeller: {len(modeller)}")
print(f"Grafkanter: {len(kanter)} totalt | {lvx_k} LVX-upplösta | {std_k} standardkanter")
print(f"Olösta textnoder: {len(olosta)}")
for k,v in sorted(olosta.items()): print(f" - {k} ({v})")
print(f"Valideringsfel: {len(fel)}")
for e in fel: print(f" ! {e}")