File: //home/panomity.de/vr.panomity.com/api/generate-scene-metadata.php
<?php
/**
* Batch-Generierung der Szenen-Metadaten über GPUQ (CLI only).
*
* Für jede sichtbare Szene:
* 1. Vision-Beschreibung (en + de) aus dem Equirect-Bild → Kontext
* 2. Eindeutiger Raumname + kurze sichtbare Beschreibung (en + de)
* → pano_phrases.name / .description
* 3. Konversationsstarter (en + de) → ollama_conversation_starters
*
* Usage:
* php generate-scene-metadata.php [--force] [--only-contexts] [--pano=ID]
*/
if (PHP_SAPI !== 'cli') {
die("CLI only\n");
}
require_once __DIR__ . '/gpuq_client.php';
require_once __DIR__ . '/ollama_context_helper.php';
$force = in_array('--force', $argv, true);
$onlyContexts = in_array('--only-contexts', $argv, true);
$onlyPano = null;
foreach ($argv as $arg) {
if (preg_match('/^--pano=(\d+)$/', $arg, $m)) {
$onlyPano = (int)$m[1];
}
}
$dbPath = __DIR__ . '/../admin/data/vrm.db';
$db = new SQLite3($dbPath);
$db->busyTimeout(15000);
ensure_panos_ollama_context_column($db);
ensure_pano_ai_context_table($db);
$languages = [];
$res = $db->query('SELECT id, short FROM languages');
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
$languages[$row['short']] = (int)$row['id'];
}
$panos = [];
$res = $db->query("SELECT id FROM panos WHERE visible = 1 AND (type = 'pano' OR type = '' OR type IS NULL) ORDER BY position, id");
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
if ($onlyPano === null || (int)$row['id'] === $onlyPano) {
$panos[] = (int)$row['id'];
}
}
echo "Panos: " . count($panos) . ", Sprachen: " . implode(',', array_keys($languages)) . "\n";
// ---------- Phase 1: Vision-Kontexte ----------
foreach ($panos as $panoId) {
foreach (array_keys($languages) as $lang) {
$existing = read_pano_context($db, $panoId, $lang);
$looksGeneric = (strpos($existing, '* This appears') === 0) || (strpos($existing, 'Okay,') === 0);
if (!$force && $existing !== '' && !$looksGeneric) {
echo "p$panoId/$lang: vorhanden, übersprungen\n";
continue;
}
$t0 = microtime(true);
$result = generate_ollama_context_for_pano($db, $panoId, $lang, true);
$dt = round(microtime(true) - $t0, 1);
if (!empty($result['success'])) {
echo "p$panoId/$lang: OK ({$dt}s, " . mb_strlen($result['context']) . " Zeichen)\n";
} else {
echo "p$panoId/$lang: FEHLER ({$dt}s): " . ($result['error'] ?? '?') . "\n";
}
}
}
if ($onlyContexts) {
echo "Nur Kontexte angefordert — fertig.\n";
exit(0);
}
// ---------- Phase 2: Namen + sichtbare Beschreibungen ----------
// Kontexte einsammeln (englisch als Basis für konsistente Benennung).
$contexts = [];
foreach ($panos as $panoId) {
$ctx = read_pano_context($db, $panoId, 'en');
$contexts[$panoId] = mb_substr($ctx, 0, 1200);
}
$usedNames = ['en' => [], 'de' => []];
foreach ($panos as $panoId) {
$avoidEn = $usedNames['en'] ? ("Already used English names (do NOT reuse): " . implode('; ', $usedNames['en']) . "\n") : '';
$avoidDe = $usedNames['de'] ? ("Bereits vergebene deutsche Namen (NICHT wiederverwenden): " . implode('; ', $usedNames['de']) . "\n") : '';
$prompt =
"You are naming one scene of a condominium virtual tour based on what is visible.\n" .
"Output ONLY one JSON object, no markdown, no commentary:\n" .
'{"name_en":"...","name_de":"...","desc_en":"...","desc_de":"..."}' . "\n\n" .
"Rules:\n" .
"- name_en/name_de: short distinctive room name (2-4 words), e.g. 'Living Room', 'Kitchen & Dining', 'Primary Bedroom', 'Balcony View'.\n" .
"- All names must be unique across the tour; disambiguate naturally if needed ('Living Room — Fireplace').\n" .
$avoidEn . $avoidDe .
"- desc_en/desc_de: one appealing but factual sentence (max 22 words) for tour visitors.\n" .
"- German must be idiomatic, not a literal translation.\n\n" .
"SCENE p$panoId:\n" . ($contexts[$panoId] ?: '(no description)');
echo "Benenne p$panoId...\n";
$chat = gpuq_chat([['role' => 'user', 'content' => $prompt]], ['priority' => 'normal', 'timeout' => 240, 'max_wait' => 280]);
if (empty($chat['success'])) {
echo " FEHLER: " . ($chat['error'] ?? '?') . "\n";
continue;
}
if (!preg_match('/\{[^{}]*\}/s', $chat['content'], $jm)) {
echo " Kein JSON gefunden\n";
continue;
}
$obj = json_decode($jm[0], true);
if (!is_array($obj)) {
echo " JSON unlesbar\n";
continue;
}
{
foreach ([['en', 'name_en', 'desc_en'], ['de', 'name_de', 'desc_de']] as [$lang, $nameKey, $descKey]) {
$name = trim((string)($obj[$nameKey] ?? ''));
$desc = trim((string)($obj[$descKey] ?? ''));
if ($name === '' || !isset($languages[$lang])) {
continue;
}
$usedNames[$lang][] = $name;
$stmt = $db->prepare('UPDATE pano_phrases SET name = :name, description = :desc
WHERE panos_id = :pid AND languages_id = :lid');
$stmt->bindValue(':name', $name, SQLITE3_TEXT);
$stmt->bindValue(':desc', $desc, SQLITE3_TEXT);
$stmt->bindValue(':pid', $panoId, SQLITE3_INTEGER);
$stmt->bindValue(':lid', $languages[$lang], SQLITE3_INTEGER);
$stmt->execute();
if ($db->changes() === 0) {
$stmt = $db->prepare('INSERT INTO pano_phrases (panos_id, languages_id, name, description)
VALUES (:pid, :lid, :name, :desc)');
$stmt->bindValue(':pid', $panoId, SQLITE3_INTEGER);
$stmt->bindValue(':lid', $languages[$lang], SQLITE3_INTEGER);
$stmt->bindValue(':name', $name, SQLITE3_TEXT);
$stmt->bindValue(':desc', $desc, SQLITE3_TEXT);
$stmt->execute();
}
echo " p$panoId/$lang: $name\n";
}
}
}
// ---------- Phase 3: Konversationsstarter ----------
$db->exec('CREATE TABLE IF NOT EXISTS ollama_conversation_starters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pano_id INTEGER,
category_id INTEGER,
language TEXT NOT NULL,
starter_text TEXT NOT NULL,
position INTEGER DEFAULT 0,
active INTEGER DEFAULT 1
)');
foreach ($panos as $panoId) {
foreach (array_keys($languages) as $lang) {
$cnt = $db->querySingle('SELECT COUNT(*) FROM ollama_conversation_starters WHERE pano_id = ' . $panoId . ' AND language = \'' . $lang . '\' AND active = 1');
if (!$force && (int)$cnt >= 2) {
continue;
}
$ctx = read_pano_context($db, $panoId, $lang);
if ($ctx === '') {
continue;
}
$langName = ollama_context_language_name($lang);
$prompt =
"Language: $langName.\n" .
"Create exactly 3 short conversation starters a VISITOR of a virtual condo tour would tap. " .
"First-person visitor perspective, questions or short requests, max 9 words each. " .
"One per line, no numbering, no bullets, nothing else.\n\nScene context:\n" . $ctx;
$chat = gpuq_chat([['role' => 'user', 'content' => $prompt]], ['priority' => 'normal', 'timeout' => 240, 'max_wait' => 280]);
if (empty($chat['success'])) {
echo "starter p$panoId/$lang: FEHLER " . ($chat['error'] ?? '') . "\n";
continue;
}
$db->exec('DELETE FROM ollama_conversation_starters WHERE pano_id = ' . $panoId . ' AND language = \'' . $lang . '\'');
$pos = 0;
foreach (explode("\n", $chat['content']) as $line) {
$starter = trim(preg_replace('/^[\d\.\-\*\•"]+\s*/u', '', trim($line)));
$starter = trim($starter, '"');
if ($starter === '' || mb_strlen($starter) < 6 || $pos >= 3) {
continue;
}
$stmt = $db->prepare('INSERT INTO ollama_conversation_starters (pano_id, category_id, language, starter_text, position, active)
VALUES (:pid, NULL, :lang, :text, :pos, 1)');
$stmt->bindValue(':pid', $panoId, SQLITE3_INTEGER);
$stmt->bindValue(':lang', $lang, SQLITE3_TEXT);
$stmt->bindValue(':text', $starter, SQLITE3_TEXT);
$stmt->bindValue(':pos', $pos, SQLITE3_INTEGER);
$stmt->execute();
$pos++;
}
echo "starter p$panoId/$lang: $pos gespeichert\n";
}
}
$db->close();
echo "Fertig. Tour-XML neu erzeugen mit: php " . __DIR__ . "/regenerate-xml.php\n";