diff --git a/apps/api/app/Domain/Ai/CodexPage.php b/apps/api/app/Domain/Ai/CodexPage.php index 3d3c7be..95c2faf 100644 --- a/apps/api/app/Domain/Ai/CodexPage.php +++ b/apps/api/app/Domain/Ai/CodexPage.php @@ -89,6 +89,32 @@ public static function adBrief(string $prompt, ?string $product, ?array $site, s $user."\n\n".$languageRule); } + /** + * What the account list calls a prototype drawn as a picture, which has no of its own: + * the website's name, else its address, else the customer's first sentence. "Prototyp" nine + * times over told the customer nothing (2026-09-26). + */ + public static function title(string $prompt, ?array $site): string + { + $name = trim((string) ($site['name'] ?? '')); + if ($name !== '') { + // "Küstenpatent Kroatien | Boat Skipper B ..." keeps its first part. + $name = trim((string) preg_split('~\s+[|\x{2013}\x{2014}-]\s+~u', $name, 2)[0]); + } + if ($name === '' && isset($site['url'])) { + $name = preg_replace('~^www\.~', '', (string) parse_url((string) $site['url'], PHP_URL_HOST)) ?? ''; + } + if ($name === '') { + $name = trim((string) preg_split('~(?<=[.!?])\s|\n~u', trim($prompt), 2)[0]); + $name = rtrim($name, '.!? '); + } + if ($name === '') { + return 'Prototyp'; + } + + return mb_strlen($name) > 70 ? rtrim(mb_substr($name, 0, 67)).'…' : $name; + } + /** One answer from Claude on the tool-less chat agent. */ private static function ask(string $system, string $user): string { diff --git a/apps/api/app/Domain/Ai/ProductPage.php b/apps/api/app/Domain/Ai/ProductPage.php index c24bad8..4d416c9 100644 --- a/apps/api/app/Domain/Ai/ProductPage.php +++ b/apps/api/app/Domain/Ai/ProductPage.php @@ -89,7 +89,7 @@ public function read(string $domain): ?array return $page ?: null; } - /** @return array{url:string,text:string,lang?:?string}|false false so a failed read is cached too, briefly enough */ + /** @return array{url:string,text:string,lang?:?string,name?:?string}|false false so a failed read is cached too, briefly enough */ private function fetch(string $domain): array|false { $url = "https://{$domain}/"; @@ -119,7 +119,10 @@ private function fetch(string $domain): array|false // The language the business speaks to its customers in, from <html lang>. $lang = preg_match('~<html[^>]*\slang="([a-zA-Z]{2,3})~i', $html, $l) === 1 ? strtolower($l[1]) : null; - return ['url' => $url, 'text' => $text, 'images' => $this->bigEnough($images, $domain), 'logo' => $logo, 'lang' => $lang]; + // The site's own name, for the title of a prototype drawn as a picture (CodexPage). + $name = preg_match('~<title[^>]*>(.*?)~is', $html, $t) === 1 ? trim(html_entity_decode(strip_tags($t[1]), ENT_QUOTES | ENT_HTML5)) : null; + + return ['url' => $url, 'text' => $text, 'images' => $this->bigEnough($images, $domain), 'logo' => $logo, 'lang' => $lang, 'name' => $name ?: null]; } } catch (\Throwable $e) { Log::info('product page: not read', ['domain' => $domain, 'error' => mb_substr($e->getMessage(), 0, 160)]); diff --git a/apps/api/app/Domain/Ai/PrototypeWriter.php b/apps/api/app/Domain/Ai/PrototypeWriter.php index 8671443..b9cf562 100644 --- a/apps/api/app/Domain/Ai/PrototypeWriter.php +++ b/apps/api/app/Domain/Ai/PrototypeWriter.php @@ -183,7 +183,7 @@ public function build(string $prompt, string $kind = 'site', ?DesignRefs $refs = $lap('brief+render'); $timing['total'] = round(array_sum($timing), 1); - return ['title' => 'Prototyp', 'html' => $out['html'], 'qa' => [ + return ['title' => CodexPage::title($prompt, $site), 'html' => $out['html'], 'qa' => [ 'ok' => null, 'findings' => [], 'writer' => 'codex', 'mockup' => true, 'brief' => $out['brief'], 'refs' => count($looks), 'timing' => $timing, 'product' => isset($site['url']) ? ['url' => $site['url'], 'brief' => $product] : null, diff --git a/apps/api/database/migrations/2026_09_26_120000_name_mockup_prototypes.php b/apps/api/database/migrations/2026_09_26_120000_name_mockup_prototypes.php new file mode 100644 index 0000000..f50ffa8 --- /dev/null +++ b/apps/api/database/migrations/2026_09_26_120000_name_mockup_prototypes.php @@ -0,0 +1,25 @@ +each(function (Prototype $p): void { + $url = $p->qa['product']['url'] ?? null; + $title = CodexPage::title((string) $p->prompt, is_string($url) ? ['url' => $url] : null); + if ($title !== 'Prototyp') { + $p->forceFill(['title' => $title])->saveQuietly(); + } + }); + } + + public function down(): void {} +}; diff --git a/apps/api/tests/Feature/MockupTitleTest.php b/apps/api/tests/Feature/MockupTitleTest.php new file mode 100644 index 0000000..cdb84c8 --- /dev/null +++ b/apps/api/tests/Feature/MockupTitleTest.php @@ -0,0 +1,29 @@ +assertSame('Küstenpatent Kroatien', CodexPage::title('x', ['url' => 'https://www.kuestenpatent-kroatien.at/', 'name' => 'Küstenpatent Kroatien | Boat Skipper B in einem Tag'])); + } + + public function test_then_the_address_then_the_first_sentence(): void + { + $this->assertSame('kuestenpatent-kroatien.at', CodexPage::title('x', ['url' => 'https://www.kuestenpatent-kroatien.at/'])); + $this->assertSame('Eine Website für ein Friseurstudio in Wien', CodexPage::title("Eine Website für ein Friseurstudio in Wien. Mit Preisliste.", null)); + $this->assertSame('Prototyp', CodexPage::title(' ', null)); + } + + public function test_a_long_sentence_is_cut(): void + { + $title = CodexPage::title(str_repeat('Bäckerei Lang ', 12), null); + $this->assertLessThanOrEqual(70, mb_strlen($title)); + $this->assertStringEndsWith('…', $title); + } +}