From 83b0ae61a878b0ae7a4c40501dfcc7520a14a3b3 Mon Sep 17 00:00:00 2001 From: Harun CAN Date: Mon, 23 Mar 2026 02:26:08 +0300 Subject: [PATCH] main --- package-lock.json | 56 +-- package.json | 2 +- src/modules/gemini/gemini.service.ts | 473 ++++++++++++++++-- .../skriptai/prompts/analysis.prompt.ts | 191 +++++++ .../prompts/character-generation.prompt.ts | 38 ++ .../skriptai/prompts/deep-research.prompt.ts | 51 ++ .../prompts/discovery-questions.prompt.ts | 53 ++ src/modules/skriptai/prompts/index.ts | 54 ++ .../skriptai/prompts/logline.prompt.ts | 30 ++ .../prompts/script-generation.prompt.ts | 191 +++++++ .../skriptai/services/analysis.service.ts | 168 ++----- .../skriptai/services/research.service.ts | 103 ++-- .../skriptai/services/scripts.service.ts | 150 +++--- 13 files changed, 1193 insertions(+), 367 deletions(-) create mode 100644 src/modules/skriptai/prompts/analysis.prompt.ts create mode 100644 src/modules/skriptai/prompts/character-generation.prompt.ts create mode 100644 src/modules/skriptai/prompts/deep-research.prompt.ts create mode 100644 src/modules/skriptai/prompts/discovery-questions.prompt.ts create mode 100644 src/modules/skriptai/prompts/index.ts create mode 100644 src/modules/skriptai/prompts/logline.prompt.ts create mode 100644 src/modules/skriptai/prompts/script-generation.prompt.ts diff --git a/package-lock.json b/package-lock.json index 7868933..a0e5b1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,7 @@ "prisma": "^5.22.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "zod": "^4.3.5" + "zod": "^4.3.6" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -1137,7 +1137,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -3075,7 +3074,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3242,7 +3240,6 @@ "version": "11.1.11", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.11.tgz", "integrity": "sha512-R/+A8XFqLgN8zNs2twhrOaE7dJbRQhdPX3g46am4RT/x8xGLqDphrXkUIno4cGUZHxbczChBAaAPTdPv73wDZA==", - "peer": true, "dependencies": { "file-type": "21.2.0", "iterare": "1.2.1", @@ -3288,7 +3285,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.11.tgz", "integrity": "sha512-H9i+zT3RvHi7tDc+lCmWHJ3ustXveABCr+Vcpl96dNOxgmrx4elQSTC4W93Mlav2opfLV+p0UTHY6L+bpUA4zA==", "hasInstallScript": true, - "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -3368,7 +3364,6 @@ "version": "11.1.11", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.11.tgz", "integrity": "sha512-kyABSskdMRIAMWL0SlbwtDy4yn59RL4HDdwHDz/fxWuv7/53YP8Y2DtV3/sHqY5Er0msMVTZrM38MjqXhYL7gw==", - "peer": true, "dependencies": { "cors": "2.8.5", "express": "5.2.1", @@ -3389,7 +3384,6 @@ "version": "11.1.11", "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.11.tgz", "integrity": "sha512-0z6pLg9CuTXtz7q2lRZoPOU94DN28OTa39f4cQrlZysKA6QrKM7w7z6xqb4g32qjF+LQHFNRmMJtE/pLrxBaig==", - "peer": true, "dependencies": { "socket.io": "4.8.3", "tslib": "2.8.1" @@ -3724,7 +3718,6 @@ "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", "hasInstallScript": true, - "peer": true, "engines": { "node": ">=16.13" }, @@ -3789,7 +3782,6 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -4695,7 +4687,6 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -4810,7 +4801,6 @@ "version": "22.19.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4975,7 +4965,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.52.0.tgz", "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -5613,7 +5602,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5667,7 +5655,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6157,7 +6144,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6231,7 +6217,6 @@ "version": "5.66.4", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.66.4.tgz", "integrity": "sha512-y2VRk2z7d1YNI2JQDD7iThoD0X/0iZZ3VEp8lqT5s5U0XDl9CIjXp1LQgmE9EKy6ReHtzmYXS1f328PnUbZGtQ==", - "peer": true, "dependencies": { "cron-parser": "4.9.0", "ioredis": "5.8.2", @@ -6305,7 +6290,6 @@ "version": "7.2.7", "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.7.tgz", "integrity": "sha512-TKeeb9nSybk1e9E5yAiPVJ6YKdX9FYhwqqy8fBfVKAFVTJYZUNmeIvwjURW6+UikNsO6l2ta27thYgo/oumDsw==", - "peer": true, "dependencies": { "@cacheable/utils": "^2.3.2", "keyv": "^5.5.4" @@ -6457,7 +6441,6 @@ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -6501,14 +6484,12 @@ "node_modules/class-transformer": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", - "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "peer": true + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==" }, "node_modules/class-validator": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -7194,7 +7175,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "dev": true + "dev": true, + "peer": true }, "node_modules/es-object-atoms": { "version": "1.1.1", @@ -7253,7 +7235,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7313,7 +7294,6 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -8674,7 +8654,6 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -9518,7 +9497,6 @@ "version": "5.5.5", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.5.tgz", "integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -10263,6 +10241,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "peer": true, "engines": { "node": ">= 6" } @@ -10446,7 +10425,6 @@ "version": "0.7.0", "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", - "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -10573,7 +10551,6 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/pino/-/pino-10.1.0.tgz", "integrity": "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==", - "peer": true, "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", @@ -10603,7 +10580,6 @@ "version": "11.0.0", "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-11.0.0.tgz", "integrity": "sha512-wqg5XIAGRRIWtTk8qPGxkbrfiwEWz1lgedVLvhLALudKXvg1/L2lTFgTGPJ4Z2e3qcRmxoFxDuSdMdMGNM6I1g==", - "peer": true, "dependencies": { "get-caller-file": "^2.0.5", "pino": "^10.0.0", @@ -10757,7 +10733,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -10811,7 +10786,6 @@ "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", "hasInstallScript": true, - "peer": true, "dependencies": { "@prisma/engines": "5.22.0" }, @@ -11876,7 +11850,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -12191,7 +12164,6 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -12329,7 +12301,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12668,6 +12639,7 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -12685,6 +12657,7 @@ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -12697,6 +12670,7 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -12710,6 +12684,7 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, + "peer": true, "engines": { "node": ">=4.0" } @@ -12718,13 +12693,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true + "dev": true, + "peer": true }, "node_modules/webpack/node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, + "peer": true, "engines": { "node": ">= 0.6" } @@ -12734,6 +12711,7 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, + "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -12746,6 +12724,7 @@ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -12953,9 +12932,10 @@ } }, "node_modules/zod": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", - "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 6259c6f..14375ec 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "prisma": "^5.22.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "zod": "^4.3.5" + "zod": "^4.3.6" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", diff --git a/src/modules/gemini/gemini.service.ts b/src/modules/gemini/gemini.service.ts index 3f46f60..4e08b33 100644 --- a/src/modules/gemini/gemini.service.ts +++ b/src/modules/gemini/gemini.service.ts @@ -1,6 +1,11 @@ import { Injectable, OnModuleInit, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { GoogleGenAI } from '@google/genai'; +import { ZodSchema, ZodError } from 'zod'; + +// ============================================ +// Types & Interfaces +// ============================================ export interface GeminiGenerateOptions { model?: string; @@ -15,30 +20,72 @@ export interface GeminiChatMessage { content: string; } +export interface GeminiJSONOptions extends GeminiGenerateOptions { + /** Zod schema for runtime validation of the AI response */ + zodSchema?: ZodSchema; + /** Max retry attempts for JSON generation (default: 3) */ + maxRetries?: number; +} + /** - * Gemini AI Service + * Error types for Gemini API failures + */ +export enum GeminiErrorType { + RATE_LIMIT = 'RATE_LIMIT', + QUOTA_EXCEEDED = 'QUOTA_EXCEEDED', + SAFETY_BLOCKED = 'SAFETY_BLOCKED', + INVALID_RESPONSE = 'INVALID_RESPONSE', + JSON_PARSE_FAILED = 'JSON_PARSE_FAILED', + TIMEOUT = 'TIMEOUT', + UNAVAILABLE = 'UNAVAILABLE', + UNKNOWN = 'UNKNOWN', +} + +/** + * Custom exception for Gemini AI errors with rich context + */ +export class GeminiException extends Error { + constructor( + message: string, + public readonly type: GeminiErrorType, + public readonly originalError?: any, + public readonly retryable: boolean = false, + ) { + super(message); + this.name = 'GeminiException'; + } +} + +// ============================================ +// Service +// ============================================ + +/** + * Gemini AI Service — Enhanced with Retry, JSON Recovery & Validation * - * Provides AI-powered text generation using Google Gemini API. + * Provides AI-powered text/JSON/image generation using Google Gemini API. * This service is globally available when ENABLE_GEMINI=true. * + * Key improvements over v1: + * - responseMimeType: "application/json" for native JSON output + * - Exponential backoff retry (up to 3 attempts) + * - Multi-strategy JSON extraction & recovery + * - Optional Zod schema validation + * - Typed GeminiException with error classification + * - AI usage metrics logging + * * @example * ```typescript * // Simple text generation * const response = await geminiService.generateText('Write a poem about coding'); * - * // With options - * const response = await geminiService.generateText('Translate to Turkish', { - * temperature: 0.7, - * systemPrompt: 'You are a professional translator', - * }); - * - * // Chat conversation - * const messages = [ - * { role: 'user', content: 'Hello!' }, - * { role: 'model', content: 'Hi there!' }, - * { role: 'user', content: 'What is 2+2?' }, - * ]; - * const response = await geminiService.chat(messages); + * // JSON generation with Zod validation + * import { z } from 'zod'; + * const schema = z.object({ title: z.string(), score: z.number() }); + * const result = await geminiService.generateJSON( + * 'Analyze this script', '{ title, score }', + * { zodSchema: schema } + * ); * ``` */ @Injectable() @@ -87,6 +134,10 @@ export class GeminiService implements OnModuleInit { return this.isEnabled && this.client !== null; } + // ============================================ + // Text Generation + // ============================================ + /** * Generate text content from a prompt * @@ -98,11 +149,10 @@ export class GeminiService implements OnModuleInit { prompt: string, options: GeminiGenerateOptions = {}, ): Promise<{ text: string; usage?: any }> { - if (!this.isAvailable()) { - throw new Error('Gemini AI is not available. Check your configuration.'); - } + this.ensureAvailable(); const model = options.model || this.defaultModel; + const startTime = Date.now(); try { const contents: any[] = []; @@ -134,16 +184,27 @@ export class GeminiService implements OnModuleInit { }, }); + const durationMs = Date.now() - startTime; + this.logUsage('generateText', model, response.usageMetadata, durationMs); + return { text: (response.text || '').trim(), usage: response.usageMetadata, }; } catch (error) { - this.logger.error('Gemini generation failed', error); - throw error; + const durationMs = Date.now() - startTime; + this.logger.error( + `Gemini generation failed after ${durationMs}ms`, + error, + ); + throw this.classifyError(error); } } + // ============================================ + // Chat + // ============================================ + /** * Have a multi-turn chat conversation * @@ -155,11 +216,10 @@ export class GeminiService implements OnModuleInit { messages: GeminiChatMessage[], options: GeminiGenerateOptions = {}, ): Promise<{ text: string; usage?: any }> { - if (!this.isAvailable()) { - throw new Error('Gemini AI is not available. Check your configuration.'); - } + this.ensureAvailable(); const model = options.model || this.defaultModel; + const startTime = Date.now(); try { const contents = messages.map((msg) => ({ @@ -190,55 +250,165 @@ export class GeminiService implements OnModuleInit { }, }); + const durationMs = Date.now() - startTime; + this.logUsage('chat', model, response.usageMetadata, durationMs); + return { text: (response.text || '').trim(), usage: response.usageMetadata, }; } catch (error) { this.logger.error('Gemini chat failed', error); - throw error; + throw this.classifyError(error); } } + // ============================================ + // JSON Generation (Enhanced) + // ============================================ + /** - * Generate structured JSON output + * Generate structured JSON output with retry, recovery, and optional Zod validation. + * + * Strategy: + * 1. First attempt uses `responseMimeType: "application/json"` for native JSON + * 2. If that fails, falls back to prompt-based JSON with multi-strategy extraction + * 3. Up to `maxRetries` attempts with exponential backoff + * 4. Optional Zod schema validation on the parsed result * * @param prompt - The prompt describing what JSON to generate - * @param schema - JSON schema description for the expected output - * @param options - Optional configuration for the generation - * @returns Parsed JSON object + * @param schema - JSON schema description for the expected output (human readable) + * @param options - Optional configuration including zodSchema and maxRetries + * @returns Parsed and optionally validated JSON object */ async generateJSON( prompt: string, schema: string, - options: GeminiGenerateOptions = {}, + options: GeminiJSONOptions = {}, ): Promise<{ data: T; usage?: any }> { - const fullPrompt = `${prompt} + this.ensureAvailable(); + + const maxRetries = options.maxRetries ?? 3; + const model = options.model || this.defaultModel; + let lastError: Error | null = null; + let lastUsage: any = undefined; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + const startTime = Date.now(); + + try { + // Build the full prompt + const fullPrompt = `${prompt} Output the result as valid JSON that matches this schema: ${schema} IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`; - const response = await this.generateText(fullPrompt, options); + const contents: any[] = []; - try { - // Try to extract JSON from the response - let jsonStr = response.text; + if (options.systemPrompt) { + contents.push({ + role: 'user', + parts: [{ text: options.systemPrompt }], + }); + contents.push({ + role: 'model', + parts: [ + { text: 'Understood. I will follow these instructions.' }, + ], + }); + } - // Remove potential markdown code blocks - const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/); - if (jsonMatch) { - jsonStr = jsonMatch[1].trim(); + contents.push({ + role: 'user', + parts: [{ text: fullPrompt }], + }); + + // Configure responseMimeType for native JSON (only when no tools — tools don't support it) + const config: any = { + temperature: options.temperature, + maxOutputTokens: options.maxTokens, + }; + + if (!options.tools || options.tools.length === 0) { + config.responseMimeType = 'application/json'; + } + + if (options.tools) { + config.tools = options.tools; + } + + const response = await this.client!.models.generateContent({ + model, + contents, + config, + }); + + const durationMs = Date.now() - startTime; + lastUsage = response.usageMetadata; + this.logUsage( + `generateJSON (attempt ${attempt}/${maxRetries})`, + model, + response.usageMetadata, + durationMs, + ); + + const rawText = (response.text || '').trim(); + + // Try to extract and parse JSON + const jsonStr = this.extractJSON(rawText); + const data = JSON.parse(jsonStr) as T; + + // Validate with Zod schema if provided + if (options.zodSchema) { + const validated = options.zodSchema.parse(data); + return { data: validated as T, usage: lastUsage }; + } + + return { data, usage: lastUsage }; + } catch (error) { + lastError = error as Error; + + const isParseError = + error instanceof SyntaxError || + (error instanceof ZodError) || + (error instanceof Error && + error.message.includes('Failed to extract JSON')); + + const isRetryable = isParseError || this.isRetryableError(error); + + if (isRetryable && attempt < maxRetries) { + const backoffMs = Math.min(1000 * Math.pow(2, attempt - 1), 8000); + this.logger.warn( + `JSON generation attempt ${attempt}/${maxRetries} failed (${error instanceof Error ? error.message : 'unknown'}). Retrying in ${backoffMs}ms...`, + ); + await this.sleep(backoffMs); + continue; + } + + // Log failure details + if (error instanceof ZodError) { + this.logger.error( + `Zod validation failed after ${attempt} attempts: ${error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')}`, + ); + } } - - const data = JSON.parse(jsonStr) as T; - return { data, usage: response.usage }; - } catch (error) { - this.logger.error('Failed to parse JSON response', error); - throw new Error('Failed to parse AI response as JSON'); } + + // All retries exhausted + throw new GeminiException( + `Failed to generate valid JSON after ${maxRetries} attempts: ${lastError?.message}`, + GeminiErrorType.JSON_PARSE_FAILED, + lastError, + false, + ); } + + // ============================================ + // Image Generation + // ============================================ + /** * Generate an image using Google Imagen (Nano Banana) * @@ -246,9 +416,9 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`; * @returns Base64 encoded image data URI */ async generateImage(prompt: string): Promise { - if (!this.isAvailable()) { - throw new Error('Gemini AI is not available. Check your configuration.'); - } + this.ensureAvailable(); + + const startTime = Date.now(); try { // Use Imagen 3.0 (Nano Banana Pro) @@ -263,6 +433,11 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`; }, })) as any; + const durationMs = Date.now() - startTime; + this.logger.log( + `Image generated in ${durationMs}ms (model: ${model})`, + ); + if ( response.images && response.images.length > 0 && @@ -272,11 +447,209 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`; return `data:image/png;base64,${response.images[0].image}`; } - throw new Error('No image returned from Gemini'); + throw new GeminiException( + 'No image returned from Gemini', + GeminiErrorType.INVALID_RESPONSE, + ); } catch (error) { + if (error instanceof GeminiException) throw error; this.logger.error('Gemini image generation failed', error); - // Fallback or rethrow - throw error; + throw this.classifyError(error); } } + + // ============================================ + // Private Helpers + // ============================================ + + /** + * Ensure Gemini client is available, throw typed exception if not + */ + private ensureAvailable(): void { + if (!this.isAvailable()) { + throw new GeminiException( + 'Gemini AI is not available. Check your configuration.', + GeminiErrorType.UNAVAILABLE, + ); + } + } + + /** + * Extract JSON from a raw AI response using multiple strategies: + * 1. Direct parse (cleanest case) + * 2. Strip markdown code blocks + * 3. Find first { or [ and match to closing bracket + * 4. Remove trailing commas and retry + */ + private extractJSON(raw: string): string { + // Strategy 1: Direct parse attempt + try { + JSON.parse(raw); + return raw; + } catch { + // Continue to next strategy + } + + // Strategy 2: Strip markdown code blocks (```json ... ``` or ``` ... ```) + const codeBlockMatch = raw.match(/```(?:json)?\s*([\s\S]*?)```/); + if (codeBlockMatch) { + const extracted = codeBlockMatch[1].trim(); + try { + JSON.parse(extracted); + return extracted; + } catch { + // Continue with the extracted content for further cleaning + raw = extracted; + } + } + + // Strategy 3: Find the first { or [ and match to the last } or ] + const objectStart = raw.indexOf('{'); + const arrayStart = raw.indexOf('['); + + let start = -1; + let endChar = ''; + + if (objectStart >= 0 && (arrayStart < 0 || objectStart < arrayStart)) { + start = objectStart; + endChar = '}'; + } else if (arrayStart >= 0) { + start = arrayStart; + endChar = ']'; + } + + if (start >= 0) { + const end = raw.lastIndexOf(endChar); + if (end > start) { + const candidate = raw.substring(start, end + 1); + try { + JSON.parse(candidate); + return candidate; + } catch { + // Strategy 4: Remove trailing commas and retry + const cleaned = candidate + .replace(/,\s*([\]}])/g, '$1') // Remove trailing commas + .replace(/'/g, '"') // Replace single quotes with double quotes + .replace(/(\w+)\s*:/g, '"$1":') // Quote unquoted keys + .replace(/""(\w+)""/g, '"$1"'); // Fix double-quoted keys + + try { + JSON.parse(cleaned); + return cleaned; + } catch { + // Last resort: return the candidate anyway, caller will handle error + } + } + } + } + + throw new Error( + `Failed to extract JSON from AI response (length: ${raw.length})`, + ); + } + + /** + * Classify an error into a typed GeminiException + */ + private classifyError(error: any): GeminiException { + if (error instanceof GeminiException) return error; + + const message = error?.message || String(error); + const status = error?.status || error?.statusCode; + + // Rate limiting + if (status === 429 || message.includes('429') || message.includes('RATE_LIMIT') || message.includes('rate limit')) { + return new GeminiException( + 'Gemini API rate limit exceeded. Please wait before retrying.', + GeminiErrorType.RATE_LIMIT, + error, + true, + ); + } + + // Quota + if (message.includes('QUOTA') || message.includes('quota') || status === 403) { + return new GeminiException( + 'Gemini API quota exceeded.', + GeminiErrorType.QUOTA_EXCEEDED, + error, + false, + ); + } + + // Safety + if (message.includes('SAFETY') || message.includes('safety') || message.includes('blocked')) { + return new GeminiException( + 'Content was blocked by safety filters. Try rephrasing the prompt.', + GeminiErrorType.SAFETY_BLOCKED, + error, + false, + ); + } + + // Timeout + if (message.includes('TIMEOUT') || message.includes('timeout') || message.includes('DEADLINE_EXCEEDED')) { + return new GeminiException( + 'Gemini API request timed out.', + GeminiErrorType.TIMEOUT, + error, + true, + ); + } + + // Generic + return new GeminiException( + `Gemini API error: ${message}`, + GeminiErrorType.UNKNOWN, + error, + true, + ); + } + + /** + * Check if an error is retryable + */ + private isRetryableError(error: any): boolean { + if (error instanceof GeminiException) return error.retryable; + const message = error?.message || ''; + return ( + message.includes('429') || + message.includes('RATE_LIMIT') || + message.includes('TIMEOUT') || + message.includes('DEADLINE_EXCEEDED') || + message.includes('UNAVAILABLE') || + message.includes('INTERNAL') + ); + } + + /** + * Log AI usage metrics for monitoring + */ + private logUsage( + operation: string, + model: string, + usage: any, + durationMs: number, + ): void { + if (usage) { + this.logger.log( + `AI Usage [${operation}] model=${model} ` + + `prompt=${usage.promptTokenCount || '?'} ` + + `completion=${usage.candidatesTokenCount || '?'} ` + + `total=${usage.totalTokenCount || '?'} ` + + `duration=${durationMs}ms`, + ); + } else { + this.logger.log( + `AI Usage [${operation}] model=${model} duration=${durationMs}ms`, + ); + } + } + + /** + * Sleep helper for retry backoff + */ + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } } diff --git a/src/modules/skriptai/prompts/analysis.prompt.ts b/src/modules/skriptai/prompts/analysis.prompt.ts new file mode 100644 index 0000000..c539f91 --- /dev/null +++ b/src/modules/skriptai/prompts/analysis.prompt.ts @@ -0,0 +1,191 @@ +/** + * Analysis Prompt Builders + * + * Prompts for AI-powered content analysis: + * - Neuro Marketing Analysis (Cialdini's 6 Principles) + * - YouTube Algorithm Audit + * - Commercial Brief (Sponsorship Analysis) + * - Visual Asset Keywords + * + * Used in: AnalysisService + */ + +// ============================================ +// Neuro Marketing Analysis +// ============================================ + +export interface NeuroAnalysisInput { + fullScript: string; +} + +export function buildNeuroAnalysisPrompt(input: NeuroAnalysisInput): { + prompt: string; + temperature: number; + schema: string; +} { + return { + prompt: `Analyze this script using Consumer Neuroscience and Cialdini's 6 Principles of Persuasion. + +Script: +${input.fullScript.substring(0, 10000)} + +Provide: +1. Engagement Score (0-100): How well does it capture attention? +2. Dopamine Score (0-100): Does it create anticipation & reward loops? +3. Clarity Score (0-100): Is the message clear and memorable? + +4. Cialdini's Persuasion Metrics (0-100 each): + - Reciprocity: Does it give value first? + - Scarcity: Does it create urgency? + - Authority: Does it establish credibility? + - Consistency: Does it align with viewer beliefs? + - Liking: Is the tone likeable/relatable? + - Social Proof: Does it reference others' actions? + +5. Neuro Metrics: + - Attention Hooks: Moments that grab attention + - Emotional Triggers: Points that evoke emotion + - Memory Anchors: Unique/memorable elements + - Action Drivers: CTAs or challenges + +6. Suggestions: 3-5 specific improvements`, + + temperature: 0.6, + + schema: `{ + "engagementScore": 0, + "dopamineScore": 0, + "clarityScore": 0, + "persuasionMetrics": { + "reciprocity": 0, "scarcity": 0, "authority": 0, + "consistency": 0, "liking": 0, "socialProof": 0 + }, + "neuroMetrics": { + "attentionHooks": ["..."], "emotionalTriggers": ["..."], + "memoryAnchors": ["..."], "actionDrivers": ["..."] + }, + "suggestions": ["..."] +}`, + }; +} + +// ============================================ +// YouTube Audit +// ============================================ + +export interface YoutubeAuditInput { + topic: string; + fullScript: string; +} + +export function buildYoutubeAuditPrompt(input: YoutubeAuditInput): { + prompt: string; + temperature: number; + schema: string; +} { + return { + prompt: `Perform a YouTube Algorithm Audit on this script for topic "${input.topic}". + +Script: +${input.fullScript.substring(0, 10000)} + +Analyze and provide: +1. Hook Score (0-100): First 10 seconds effectiveness +2. Pacing Score (0-100): Does it maintain momentum? +3. Viral Potential (0-100): Shareability factor + +4. Retention Analysis: 3-5 potential drop-off points with time, issue, suggestion, severity (High/Medium/Low) + +5. Thumbnail Concepts: 3 high-CTR thumbnail ideas with: + - Concept name, Visual description, Text overlay + - Color psychology, Emotion target, AI generation prompt + +6. Title Options: 5 clickable titles (curiosity gap, numbers, power words) +7. Community Post: Engaging post to tease the video +8. Pinned Comment: Engagement-driving first comment +9. SEO Description: Optimized video description with keywords +10. Keywords: 10 relevant search keywords`, + + temperature: 0.7, + + schema: `{ + "hookScore": 0, "pacingScore": 0, "viralPotential": 0, + "retentionAnalysis": [{ "time": "0:30", "issue": "...", "suggestion": "...", "severity": "High" }], + "thumbnails": [{ "conceptName": "...", "visualDescription": "...", "textOverlay": "...", "colorPsychology": "...", "emotionTarget": "...", "aiPrompt": "..." }], + "titles": ["..."], + "communityPost": "...", "pinnedComment": "...", + "description": "...", "keywords": ["..."] +}`, + }; +} + +// ============================================ +// Commercial Brief +// ============================================ + +export interface CommercialBriefInput { + topic: string; + targetAudience: string[]; + contentType: string; + fullScript: string; +} + +export function buildCommercialBriefPrompt(input: CommercialBriefInput): { + prompt: string; + temperature: number; + schema: string; +} { + return { + prompt: `Analyze this content for commercial viability and sponsorship opportunities. + +Topic: "${input.topic}" +Audience: ${input.targetAudience.join(', ')} +Content Type: ${input.contentType} + +Script excerpt: +${input.fullScript.substring(0, 5000)} + +Provide: +1. Viability Score (1-10 scale as string): "8/10" +2. Viability Reason: Why this content is commercially viable + +3. Sponsor Suggestions (3-5 potential sponsors): + - Company name, Industry + - Match reason (why this sponsor fits) + - Email draft (outreach template)`, + + temperature: 0.6, + + schema: `{ + "viabilityScore": "8/10", + "viabilityReason": "...", + "sponsors": [{ "name": "...", "industry": "...", "matchReason": "...", "emailDraft": "..." }] +}`, + }; +} + +// ============================================ +// Visual Asset Keywords +// ============================================ + +export interface VisualAssetKeywordsInput { + topic: string; + count: number; +} + +export function buildVisualAssetKeywordsPrompt( + input: VisualAssetKeywordsInput, +): { + prompt: string; + temperature: number; + schema: string; +} { + return { + prompt: `Generate ${input.count} specific, simple visual keywords for an image generator about "${input.topic}". +Format: "subject action context style". Keep it English, concise, no special chars.`, + + temperature: 0.8, + + schema: '["keyword1", "keyword2", ...]', + }; +} diff --git a/src/modules/skriptai/prompts/character-generation.prompt.ts b/src/modules/skriptai/prompts/character-generation.prompt.ts new file mode 100644 index 0000000..a660290 --- /dev/null +++ b/src/modules/skriptai/prompts/character-generation.prompt.ts @@ -0,0 +1,38 @@ +/** + * Character Generation Prompt Builder + * + * Uses Alan C. Hueth's "Triunity of Character" model to create + * rich character profiles for video content. + * + * Used in: ResearchService.generateCharacters() + */ + +export interface CharacterGenerationInput { + contentType: string; + topic: string; + language: string; +} + +export function buildCharacterGenerationPrompt( + input: CharacterGenerationInput, +): { + prompt: string; + temperature: number; + schema: string; +} { + return { + prompt: `Create Character Profiles for a ${input.contentType} about "${input.topic}". +Use Alan C. Hueth's "Triunity of Character" model: +1. Values (Inner belief) +2. Traits (Personality) +3. Mannerisms (External behavior) + +If format is non-fiction (Youtube Doc), create a 'Host/Narrator' persona and potentially an 'Antagonist' (e.g., The Problem, Time, A Rival). +Language: ${input.language}.`, + + temperature: 0.8, + + schema: + '[{ "name": "Name", "role": "Protagonist", "values": "...", "traits": "...", "mannerisms": "..." }]', + }; +} diff --git a/src/modules/skriptai/prompts/deep-research.prompt.ts b/src/modules/skriptai/prompts/deep-research.prompt.ts new file mode 100644 index 0000000..834ae77 --- /dev/null +++ b/src/modules/skriptai/prompts/deep-research.prompt.ts @@ -0,0 +1,51 @@ +/** + * Deep Research Prompt Builders + * + * Two-stage prompts: + * 1. Generate search queries for a topic + * 2. Find high-quality web sources for each query + * + * Used in: ResearchService.performDeepResearch() + */ + +export interface SearchQueryInput { + topic: string; + briefContext: string; + language: string; +} + +export function buildSearchQueryPrompt(input: SearchQueryInput): { + prompt: string; + temperature: number; + schema: string; +} { + return { + prompt: `Generate 5 specific Google Search queries for "${input.topic}". +Context: ${input.briefContext}. Language: ${input.language}. +Return strictly a JSON array of strings.`, + + temperature: 0.7, + + schema: '["query1", "query2", ...]', + }; +} + +export interface SourceSearchInput { + query: string; + language: string; +} + +export function buildSourceSearchPrompt(input: SourceSearchInput): { + prompt: string; + temperature: number; + schema: string; +} { + return { + prompt: `Find 3 high-quality web sources for: ${input.query}. Language: ${input.language}. +Return JSON array: [{ "title": string, "url": string, "snippet": string, "type": "article" }]`, + + temperature: 0.5, + + schema: '[{ "title": "...", "url": "...", "snippet": "...", "type": "article" }]', + }; +} diff --git a/src/modules/skriptai/prompts/discovery-questions.prompt.ts b/src/modules/skriptai/prompts/discovery-questions.prompt.ts new file mode 100644 index 0000000..9a9cd94 --- /dev/null +++ b/src/modules/skriptai/prompts/discovery-questions.prompt.ts @@ -0,0 +1,53 @@ +/** + * Discovery Questions Prompt Builder + * + * Generates provocative "Screenwriter's Room" style questions + * to help shape the narrative arc for a given topic. + * + * Used in: ResearchService.generateDiscoveryQuestions() + */ + +export interface DiscoveryQuestionsInput { + topic: string; + language: string; + existingQuestions?: string[]; +} + +export function buildDiscoveryQuestionsPrompt( + input: DiscoveryQuestionsInput, +): { + prompt: string; + temperature: number; + schema: string; +} { + const existingContext = + input.existingQuestions && input.existingQuestions.length > 0 + ? `Avoid these questions: ${input.existingQuestions.join(', ')}` + : ''; + + return { + prompt: `You are an expert Screenwriter and Creative Director. Topic: "${input.topic}". + +PHASE 1: DEEP DIVE +Think like a filmmaker. We are not just making a video; we are telling a story. +Analyze the topic "${input.topic}" to find the drama, the conflict, and the human element. + +PHASE 2: INTERROGATION +Ask 3-4 provocative, "Screenwriter's Room" style questions to help shape the narrative arc. + +DO NOT ASK: "What is the goal?" or "Who is the audience?". + +INSTEAD ASK (Examples): +- "What is the 'Inciting Incident' that makes this topic urgent right now?" +- "If this topic was a character, what would be its fatal flaw?" +- "What is the 'Villain' (opposing force or misconception) we are fighting against?" +- "What is the emotional climax you want the viewer to feel at the end?" + +${existingContext} +Output Language: ${input.language}.`, + + temperature: 0.9, + + schema: '{ "questions": ["Question 1", "Question 2", "Question 3", "Question 4"] }', + }; +} diff --git a/src/modules/skriptai/prompts/index.ts b/src/modules/skriptai/prompts/index.ts new file mode 100644 index 0000000..4edbd49 --- /dev/null +++ b/src/modules/skriptai/prompts/index.ts @@ -0,0 +1,54 @@ +/** + * SkriptAI Prompt Index + * + * Centralized exports for all AI prompt builders. + * Each prompt is a pure function that takes typed input and returns + * { prompt, temperature, schema } — ready to pass to GeminiService methods. + */ + +// Discovery & Research +export { + buildDiscoveryQuestionsPrompt, + type DiscoveryQuestionsInput, +} from './discovery-questions.prompt'; + +export { + buildSearchQueryPrompt, + buildSourceSearchPrompt, + type SearchQueryInput, + type SourceSearchInput, +} from './deep-research.prompt'; + +// Characters & Logline +export { + buildCharacterGenerationPrompt, + type CharacterGenerationInput, +} from './character-generation.prompt'; + +export { buildLoglinePrompt, type LoglineInput } from './logline.prompt'; + +// Script Generation +export { + buildScriptOutlinePrompt, + buildChapterSegmentPrompt, + buildSegmentRewritePrompt, + buildSegmentImagePrompt, + calculateTargetWordCount, + calculateEstimatedChapters, + type ScriptOutlineInput, + type ChapterSegmentInput, + type SegmentRewriteInput, + type SegmentImagePromptInput, +} from './script-generation.prompt'; + +// Analysis +export { + buildNeuroAnalysisPrompt, + buildYoutubeAuditPrompt, + buildCommercialBriefPrompt, + buildVisualAssetKeywordsPrompt, + type NeuroAnalysisInput, + type YoutubeAuditInput, + type CommercialBriefInput, + type VisualAssetKeywordsInput, +} from './analysis.prompt'; diff --git a/src/modules/skriptai/prompts/logline.prompt.ts b/src/modules/skriptai/prompts/logline.prompt.ts new file mode 100644 index 0000000..b171a28 --- /dev/null +++ b/src/modules/skriptai/prompts/logline.prompt.ts @@ -0,0 +1,30 @@ +/** + * Logline & High Concept Prompt Builder + * + * Uses Hollywood Producer persona with Dallas Jones formula + * to create compelling loglines and high concept premises. + * + * Used in: ResearchService.generateLogline() + */ + +export interface LoglineInput { + topic: string; + sourceContext: string; + language: string; +} + +export function buildLoglinePrompt(input: LoglineInput): { + prompt: string; + temperature: number; + schema: string; +} { + return { + prompt: `Act as a Hollywood Producer. Topic: ${input.topic}. Material: ${input.sourceContext}. +Create a "High Concept" premise and a "Logline" (Max 25 words, Dallas Jones formula). +Language: ${input.language}.`, + + temperature: 0.9, + + schema: '{ "logline": "...", "highConcept": "..." }', + }; +} diff --git a/src/modules/skriptai/prompts/script-generation.prompt.ts b/src/modules/skriptai/prompts/script-generation.prompt.ts new file mode 100644 index 0000000..7d480a5 --- /dev/null +++ b/src/modules/skriptai/prompts/script-generation.prompt.ts @@ -0,0 +1,191 @@ +/** + * Script Generation Prompt Builders + * + * Two-phase script generation: + * - Phase 1: Content outline (chapters, SEO, thumbnails) + * - Phase 2: Per-chapter segment generation + * - Segment rewrite with style change + * - Segment image prompt generation + * + * Used in: ScriptsService.generateScript(), rewriteSegment(), generateSegmentImage() + */ + +// ============================================ +// Phase 1: Outline +// ============================================ + +export interface ScriptOutlineInput { + topic: string; + logline: string; + characterContext: string; + speechStyles: string[]; + targetAudience: string[]; + contentType: string; + targetDuration: string; + targetWordCount: number; + estimatedChapters: number; + sourceContext: string; + briefContext: string; +} + +export function buildScriptOutlinePrompt(input: ScriptOutlineInput): { + prompt: string; + temperature: number; + schema: string; +} { + return { + prompt: `Create a CONTENT OUTLINE. +Topic: "${input.topic}" +Logline: "${input.logline}" +Characters: ${input.characterContext} +Styles: ${input.speechStyles.join(', ')}. Audience: ${input.targetAudience.join(', ')}. +Format: ${input.contentType}. Target Duration: ${input.targetDuration}. Target Total Word Count: ${input.targetWordCount}. +Generate exactly ${input.estimatedChapters} chapters. +Material: ${input.sourceContext.substring(0, 15000)} +Brief: ${input.briefContext}`, + + temperature: 0.7, + + schema: `{ + "title": "Title", + "seoDescription": "Desc", + "tags": ["tag1"], + "thumbnailIdeas": ["Idea 1"], + "chapters": [{ "title": "Chap 1", "focus": "Summary", "type": "Intro" }] +}`, + }; +} + +// ============================================ +// Phase 2: Chapter → Segments +// ============================================ + +export interface ChapterSegmentInput { + chapterIndex: number; + totalChapters: number; + chapterTitle: string; + chapterFocus: string; + chapterType: string; + speechStyles: string[]; + targetAudience: string[]; + characterContext: string; + language: string; +} + +export function buildChapterSegmentPrompt(input: ChapterSegmentInput): { + prompt: string; + temperature: number; + schema: string; +} { + return { + prompt: `Write Script Segment ${input.chapterIndex + 1}/${input.totalChapters}. +Chapter: "${input.chapterTitle}". Focus: ${input.chapterFocus}. +Style: ${input.speechStyles.join(', ')}. +Audience: ${input.targetAudience.join(', ')}. +Characters: ${input.characterContext}. +Target Length: ~200 words. +Language: ${input.language}.`, + + temperature: 0.8, + + schema: `[{ + "segmentType": "${input.chapterType || 'Body'}", + "narratorScript": "Full text...", + "visualDescription": "Detailed visual explanation...", + "videoPrompt": "Cinematic shot of [subject], 4k...", + "imagePrompt": "Hyper-realistic photo of [subject]...", + "onScreenText": "Overlay text...", + "stockQuery": "Pexels keyword", + "audioCues": "SFX..." +}]`, + }; +} + +// ============================================ +// Segment Rewrite +// ============================================ + +export interface SegmentRewriteInput { + currentScript: string; + newStyle: string; + topic: string; + language: string; +} + +export function buildSegmentRewritePrompt(input: SegmentRewriteInput): { + prompt: string; + temperature: number; + schema: string; +} { + return { + prompt: `Rewrite this script segment. +Current Text: "${input.currentScript}" +Goal: Change style to "${input.newStyle}". +Context: Topic is "${input.topic}". Language: ${input.language}. +Principles: Show Don't Tell, Subtext.`, + + temperature: 0.85, + + schema: `{ + "narratorScript": "New text...", + "visualDescription": "Updated visual...", + "onScreenText": "Updated overlay...", + "audioCues": "Updated audio..." +}`, + }; +} + +// ============================================ +// Segment Image Prompt +// ============================================ + +export interface SegmentImagePromptInput { + topic: string; + narratorScript: string; + visualDescription: string; +} + +export function buildSegmentImagePrompt(input: SegmentImagePromptInput): { + prompt: string; + temperature: number; + schema: string; +} { + return { + prompt: `Create a detailed AI Image Generation Prompt and a Video Generation Prompt for this script segment. +Topic: "${input.topic}" +Segment Content: "${input.narratorScript}" +Visual Context: "${input.visualDescription}" + +Goal: Create a highly detailed, cinematic, and artistic prompt optimized for tools like Midjourney, Flux, or Runway. +Style: Cinematic, highly detailed, 8k, professional lighting.`, + + temperature: 0.7, + + schema: `{ + "imagePrompt": "Full detailed image prompt...", + "videoPrompt": "Full detailed video prompt..." +}`, + }; +} + +// ============================================ +// Helpers +// ============================================ + +/** + * Calculate target word count based on duration string + */ +export function calculateTargetWordCount(targetDuration: string): number { + if (targetDuration.includes('Short')) return 140; + if (targetDuration.includes('Standard')) return 840; + if (targetDuration.includes('Long')) return 1680; + if (targetDuration.includes('Deep Dive')) return 2800; + return 840; +} + +/** + * Calculate estimated chapters based on word count + */ +export function calculateEstimatedChapters(targetWordCount: number): number { + return Math.ceil(targetWordCount / 200); +} diff --git a/src/modules/skriptai/services/analysis.service.ts b/src/modules/skriptai/services/analysis.service.ts index b686fd3..68b6d9f 100644 --- a/src/modules/skriptai/services/analysis.service.ts +++ b/src/modules/skriptai/services/analysis.service.ts @@ -2,6 +2,12 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../../database/prisma.service'; import { GeminiService } from '../../gemini/gemini.service'; import { NeuroAnalysisResult, YoutubeAudit } from '../types/skriptai.types'; +import { + buildNeuroAnalysisPrompt, + buildYoutubeAuditPrompt, + buildCommercialBriefPrompt, + buildVisualAssetKeywordsPrompt, +} from '../prompts'; /** * AnalysisService @@ -43,56 +49,12 @@ export class AnalysisService { .map((s) => s.narratorScript) .join('\n\n'); - const prompt = `Analyze this script using Consumer Neuroscience and Cialdini's 6 Principles of Persuasion. - -Script: -${fullScript.substring(0, 10000)} - -Provide: -1. Engagement Score (0-100): How well does it capture attention? -2. Dopamine Score (0-100): Does it create anticipation & reward loops? -3. Clarity Score (0-100): Is the message clear and memorable? - -4. Cialdini's Persuasion Metrics (0-100 each): - - Reciprocity: Does it give value first? - - Scarcity: Does it create urgency? - - Authority: Does it establish credibility? - - Consistency: Does it align with viewer beliefs? - - Liking: Is the tone likeable/relatable? - - Social Proof: Does it reference others' actions? - -5. Neuro Metrics: - - Attention Hooks: Moments that grab attention - - Emotional Triggers: Points that evoke emotion - - Memory Anchors: Unique/memorable elements - - Action Drivers: CTAs or challenges - -6. Suggestions: 3-5 specific improvements - -Return JSON: { - "engagementScore": number, - "dopamineScore": number, - "clarityScore": number, - "persuasionMetrics": { - "reciprocity": number, - "scarcity": number, - "authority": number, - "consistency": number, - "liking": number, - "socialProof": number - }, - "neuroMetrics": { - "attentionHooks": ["..."], - "emotionalTriggers": ["..."], - "memoryAnchors": ["..."], - "actionDrivers": ["..."] - }, - "suggestions": ["..."] -}`; + const promptData = buildNeuroAnalysisPrompt({ fullScript }); const resp = await this.gemini.generateJSON( - prompt, - '{ engagementScore, dopamineScore, clarityScore, persuasionMetrics, neuroMetrics, suggestions }', + promptData.prompt, + promptData.schema, + { temperature: promptData.temperature }, ); // Save to project @@ -124,52 +86,15 @@ Return JSON: { .map((s) => s.narratorScript) .join('\n\n'); - const prompt = `Perform a YouTube Algorithm Audit on this script for topic "${project.topic}". - -Script: -${fullScript.substring(0, 10000)} - -Analyze and provide: -1. Hook Score (0-100): First 10 seconds effectiveness -2. Pacing Score (0-100): Does it maintain momentum? -3. Viral Potential (0-100): Shareability factor - -4. Retention Analysis: 3-5 potential drop-off points with time, issue, suggestion, severity (High/Medium/Low) - -5. Thumbnail Concepts: 3 high-CTR thumbnail ideas with: - - Concept name - - Visual description - - Text overlay - - Color psychology - - Emotion target - - AI generation prompt - -6. Title Options: 5 clickable titles (curiosity gap, numbers, power words) - -7. Community Post: Engaging post to tease the video - -8. Pinned Comment: Engagement-driving first comment - -9. SEO Description: Optimized video description with keywords - -10. Keywords: 10 relevant search keywords - -Return JSON: { - "hookScore": number, - "pacingScore": number, - "viralPotential": number, - "retentionAnalysis": [{ "time": "0:30", "issue": "...", "suggestion": "...", "severity": "High" }], - "thumbnails": [{ "conceptName": "...", "visualDescription": "...", "textOverlay": "...", "colorPsychology": "...", "emotionTarget": "...", "aiPrompt": "..." }], - "titles": ["..."], - "communityPost": "...", - "pinnedComment": "...", - "description": "...", - "keywords": ["..."] -}`; + const promptData = buildYoutubeAuditPrompt({ + topic: project.topic, + fullScript, + }); const resp = await this.gemini.generateJSON( - prompt, - '{ hookScore, pacingScore, viralPotential, retentionAnalysis, thumbnails, titles, communityPost, pinnedComment, description, keywords }', + promptData.prompt, + promptData.schema, + { temperature: promptData.temperature }, ); // Save to project @@ -201,37 +126,12 @@ Return JSON: { .map((s) => s.narratorScript) .join('\n\n'); - const prompt = `Analyze this content for commercial viability and sponsorship opportunities. - -Topic: "${project.topic}" -Audience: ${project.targetAudience.join(', ')} -Content Type: ${project.contentType} - -Script excerpt: -${fullScript.substring(0, 5000)} - -Provide: -1. Viability Score (1-10 scale as string): "8/10" -2. Viability Reason: Why this content is commercially viable - -3. Sponsor Suggestions (3-5 potential sponsors): - - Company name - - Industry - - Match reason (why this sponsor fits) - - Email draft (outreach template) - -Return JSON: { - "viabilityScore": "8/10", - "viabilityReason": "...", - "sponsors": [ - { - "name": "Company Name", - "industry": "Tech/Finance/etc", - "matchReason": "...", - "emailDraft": "..." - } - ] -}`; + const promptData = buildCommercialBriefPrompt({ + topic: project.topic, + targetAudience: project.targetAudience, + contentType: project.contentType, + fullScript, + }); const resp = await this.gemini.generateJSON<{ viabilityScore: string; @@ -242,7 +142,9 @@ Return JSON: { matchReason: string; emailDraft: string; }[]; - }>(prompt, '{ viabilityScore, viabilityReason, sponsors }'); + }>(promptData.prompt, promptData.schema, { + temperature: promptData.temperature, + }); // Save to project await this.prisma.scriptProject.update({ @@ -253,12 +155,6 @@ Return JSON: { return resp.data; } - /** - * Generate thumbnails using external image service - * - * @param prompt - Image generation prompt - * @returns Generated image URL - */ /** * Generate thumbnails using external image service * Applies "Nano Banana" prompt enrichment for high-quality results. @@ -302,13 +198,15 @@ Return JSON: { throw new NotFoundException(`Project with ID ${projectId} not found`); } - const prompt = `Generate ${count} specific, simple visual keywords for an image generator about "${project.topic}". - Format: "subject action context style". Keep it English, concise, no special chars. - Return JSON array of strings.`; + const promptData = buildVisualAssetKeywordsPrompt({ + topic: project.topic, + count, + }); const resp = await this.gemini.generateJSON( - prompt, - '["keyword1", "keyword2", ...]', + promptData.prompt, + promptData.schema, + { temperature: promptData.temperature }, ); // Generate image URLs and save to database diff --git a/src/modules/skriptai/services/research.service.ts b/src/modules/skriptai/services/research.service.ts index 65de92f..e48c4d6 100644 --- a/src/modules/skriptai/services/research.service.ts +++ b/src/modules/skriptai/services/research.service.ts @@ -7,6 +7,13 @@ import { CreateCharacterDto, } from '../dto'; import { CharacterRole } from '../types/skriptai.types'; +import { + buildDiscoveryQuestionsPrompt, + buildSearchQueryPrompt, + buildSourceSearchPrompt, + buildCharacterGenerationPrompt, + buildLoglinePrompt, +} from '../prompts'; /** * ResearchService @@ -85,12 +92,8 @@ export class ResearchService { : project.topic; // Generate search queries - const queryPrompt = `Generate 5 specific Google Search queries for "${topic}". - Context: ${briefContext}. Language: ${project.language}. - Return strictly a JSON array of strings.`; - let searchQueries: string[] = []; - // Check if Gemini is available for queries + if (!this.gemini.isAvailable()) { this.logger.warn('Gemini is disabled. Using mock search queries.'); searchQueries = [ @@ -100,10 +103,19 @@ export class ResearchService { ]; } else { try { + const queryPromptData = buildSearchQueryPrompt({ + topic, + briefContext, + language: project.language, + }); + const queryResp = await this.gemini.generateJSON( - queryPrompt, - '["query1", "query2", ...]', - { tools: [{ googleSearch: {} }] }, + queryPromptData.prompt, + queryPromptData.schema, + { + temperature: queryPromptData.temperature, + tools: [{ googleSearch: {} }], + }, ); searchQueries = queryResp.data; } catch { @@ -130,12 +142,15 @@ export class ResearchService { continue; } - const sourcePrompt = `Find 3 high-quality web sources for: ${query}. Language: ${project.language}. - Return JSON array: [{ "title": string, "url": string, "snippet": string, "type": "article" }]`; + const sourcePromptData = buildSourceSearchPrompt({ + query, + language: project.language, + }); const sourceResp = await this.gemini.generateJSON< { title: string; url: string; snippet: string; type: string }[] - >(sourcePrompt, '[{ title, url, snippet, type }]', { + >(sourcePromptData.prompt, sourcePromptData.schema, { + temperature: sourcePromptData.temperature, tools: [{ googleSearch: {} }], }); @@ -221,11 +236,6 @@ export class ResearchService { language: string, existingQuestions: string[] = [], ) { - const existingContext = - existingQuestions.length > 0 - ? `Avoid these questions: ${existingQuestions.join(', ')}` - : ''; - // Check if Gemini is available if (!this.gemini.isAvailable()) { this.logger.warn( @@ -239,30 +249,16 @@ export class ResearchService { ]; } - const prompt = `You are an expert Screenwriter and Creative Director. Topic: "${topic}". - - PHASE 1: DEEP DIVE - Think like a filmmaker. We are not just making a video; we are telling a story. - Analyze the topic "${topic}" to find the drama, the conflict, and the human element. - - PHASE 2: INTERROGATION - Ask 3-4 provocative, "Screenwriter's Room" style questions to help shape the narrative arc. - - DO NOT ASK: "What is the goal?" or "Who is the audience?". - - INSTEAD ASK (Examples): - - "What is the 'Inciting Incident' that makes this topic urgent right now?" - - "If this topic was a character, what would be its fatal flaw?" - - "What is the 'Villain' (opposing force or misconception) we are fighting against?" - - "What is the emotional climax you want the viewer to feel at the end?" - - ${existingContext} - Output Language: ${language}. - Return JSON object: { "questions": ["Question 1", "Question 2", "Question 3", "Question 4"] }`; + const promptData = buildDiscoveryQuestionsPrompt({ + topic, + language, + existingQuestions, + }); const resp = await this.gemini.generateJSON<{ questions: string[] }>( - prompt, - '{ questions: string[] }', + promptData.prompt, + promptData.schema, + { temperature: promptData.temperature }, ); return resp.data.questions; @@ -312,15 +308,11 @@ export class ResearchService { throw new NotFoundException(`Project with ID ${projectId} not found`); } - const prompt = `Create Character Profiles for a ${project.contentType} about "${project.topic}". - Use Alan C. Hueth's "Triunity of Character" model: - 1. Values (Inner belief) - 2. Traits (Personality) - 3. Mannerisms (External behavior) - - If format is non-fiction (Youtube Doc), create a 'Host/Narrator' persona and potentially an 'Antagonist' (e.g., The Problem, Time, A Rival). - Language: ${project.language}. - Return JSON Array: [{ "name": "Name", "role": "Protagonist", "values": "...", "traits": "...", "mannerisms": "..." }]`; + const promptData = buildCharacterGenerationPrompt({ + contentType: project.contentType, + topic: project.topic, + language: project.language, + }); const resp = await this.gemini.generateJSON< { @@ -330,7 +322,9 @@ export class ResearchService { traits: string; mannerisms: string; }[] - >(prompt, '[{ name, role, values, traits, mannerisms }]'); + >(promptData.prompt, promptData.schema, { + temperature: promptData.temperature, + }); // Save characters to database const characters = await Promise.all( @@ -371,15 +365,18 @@ export class ResearchService { const sourceContext = project.sources.map((s) => s.snippet).join('\n'); - const prompt = `Act as a Hollywood Producer. Topic: ${project.topic}. Material: ${sourceContext}. - Create a "High Concept" premise and a "Logline" (Max 25 words, Dallas Jones formula). - Language: ${project.language}. - Return JSON: { "logline": "...", "highConcept": "..." }`; + const promptData = buildLoglinePrompt({ + topic: project.topic, + sourceContext, + language: project.language, + }); const resp = await this.gemini.generateJSON<{ logline: string; highConcept: string; - }>(prompt, '{ logline, highConcept }'); + }>(promptData.prompt, promptData.schema, { + temperature: promptData.temperature, + }); // Update project await this.prisma.scriptProject.update({ diff --git a/src/modules/skriptai/services/scripts.service.ts b/src/modules/skriptai/services/scripts.service.ts index 4850a9a..9d2b1f0 100644 --- a/src/modules/skriptai/services/scripts.service.ts +++ b/src/modules/skriptai/services/scripts.service.ts @@ -2,10 +2,15 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../../database/prisma.service'; import { GeminiService } from '../../gemini/gemini.service'; import { CreateSegmentDto, UpdateSegmentDto } from '../dto'; - import { AnalysisService } from './analysis.service'; - -// AI_CONFIG is only used for model selection reference +import { + buildScriptOutlinePrompt, + buildChapterSegmentPrompt, + buildSegmentRewritePrompt, + buildSegmentImagePrompt, + calculateTargetWordCount, + calculateEstimatedChapters, +} from '../prompts'; /** * ScriptsService @@ -138,34 +143,24 @@ export class ScriptsService { ) .join('\n'); - // Calculate target word count based on duration - let targetWordCount = 840; - if (project.targetDuration.includes('Short')) targetWordCount = 140; - else if (project.targetDuration.includes('Standard')) targetWordCount = 840; - else if (project.targetDuration.includes('Long')) targetWordCount = 1680; - else if (project.targetDuration.includes('Deep Dive')) - targetWordCount = 2800; + // Calculate target metrics + const targetWordCount = calculateTargetWordCount(project.targetDuration); + const estimatedChapters = calculateEstimatedChapters(targetWordCount); - const estimatedChapters = Math.ceil(targetWordCount / 200); - - // PHASE 1: Generate Outline - const outlinePrompt = ` - Create a CONTENT OUTLINE. - Topic: "${project.topic}" - Logline: "${project.logline || ''}" - Characters: ${characterContext} - Styles: ${project.speechStyle.join(', ')}. Audience: ${project.targetAudience.join(', ')}. - Format: ${project.contentType}. Target Duration: ${project.targetDuration}. Target Total Word Count: ${targetWordCount}. - Generate exactly ${estimatedChapters} chapters. - Material: ${sourceContext.substring(0, 15000)} - Brief: ${briefContext} - - Return JSON: { - "title": "Title", "seoDescription": "Desc", "tags": ["tag1"], - "thumbnailIdeas": ["Idea 1"], - "chapters": [{ "title": "Chap 1", "focus": "Summary", "type": "Intro" }] - } - `; + // PHASE 1: Generate Outline using prompt builder + const outlinePromptData = buildScriptOutlinePrompt({ + topic: project.topic, + logline: project.logline || '', + characterContext, + speechStyles: project.speechStyle, + targetAudience: project.targetAudience, + contentType: project.contentType, + targetDuration: project.targetDuration, + targetWordCount, + estimatedChapters, + sourceContext, + briefContext, + }); const outlineResp = await this.gemini.generateJSON<{ title: string; @@ -173,10 +168,9 @@ export class ScriptsService { tags: string[]; thumbnailIdeas: string[]; chapters: { title: string; focus: string; type: string }[]; - }>( - outlinePrompt, - '{ title, seoDescription, tags, thumbnailIdeas, chapters }', - ); + }>(outlinePromptData.prompt, outlinePromptData.schema, { + temperature: outlinePromptData.temperature, + }); const outlineData = outlineResp.data; @@ -191,38 +185,30 @@ export class ScriptsService { }, }); - // PHASE 2: Generate each chapter + // PHASE 2: Generate each chapter using prompt builder const generatedSegments: any[] = []; let timeOffset = 0; for (let i = 0; i < outlineData.chapters.length; i++) { const chapter = outlineData.chapters[i]; - const chapterPrompt = ` - Write Script Segment ${i + 1}/${outlineData.chapters.length}. - Chapter: "${chapter.title}". Focus: ${chapter.focus}. - Style: ${project.speechStyle.join(', ')}. - Audience: ${project.targetAudience.join(', ')}. - Characters: ${characterContext}. - Target Length: ~200 words. - Language: ${project.language}. - - Return JSON Array: [{ - "segmentType": "${chapter.type || 'Body'}", - "narratorScript": "Full text...", - "visualDescription": "Detailed visual explanation...", - "videoPrompt": "Cinematic shot of [subject], 4k...", - "imagePrompt": "Hyper-realistic photo of [subject]...", - "onScreenText": "Overlay text...", - "stockQuery": "Pexels keyword", - "audioCues": "SFX..." - }] - `; + const chapterPromptData = buildChapterSegmentPrompt({ + chapterIndex: i, + totalChapters: outlineData.chapters.length, + chapterTitle: chapter.title, + chapterFocus: chapter.focus, + chapterType: chapter.type, + speechStyles: project.speechStyle, + targetAudience: project.targetAudience, + characterContext, + language: project.language, + }); try { const segmentResp = await this.gemini.generateJSON( - chapterPrompt, - '[{ segmentType, narratorScript, visualDescription, videoPrompt, imagePrompt, onScreenText, stockQuery, audioCues }]', + chapterPromptData.prompt, + chapterPromptData.schema, + { temperature: chapterPromptData.temperature }, ); for (const seg of segmentResp.data) { @@ -293,30 +279,21 @@ export class ScriptsService { throw new NotFoundException(`Segment with ID ${segmentId} not found`); } - const prompt = ` - Rewrite this script segment. - Current Text: "${segment.narratorScript}" - Goal: Change style to "${newStyle}". - Context: Topic is "${segment.project.topic}". Language: ${segment.project.language}. - Principles: Show Don't Tell, Subtext. - - Return JSON: { - "narratorScript": "New text...", - "visualDescription": "Updated visual...", - "onScreenText": "Updated overlay...", - "audioCues": "Updated audio..." - } - `; + const promptData = buildSegmentRewritePrompt({ + currentScript: segment.narratorScript || '', + newStyle, + topic: segment.project.topic, + language: segment.project.language, + }); const rewriteResp = await this.gemini.generateJSON<{ narratorScript: string; visualDescription: string; onScreenText: string; audioCues: string; - }>( - prompt, - '{ narratorScript, visualDescription, onScreenText, audioCues }', - ); + }>(promptData.prompt, promptData.schema, { + temperature: promptData.temperature, + }); const data = rewriteResp.data; const words = data.narratorScript @@ -359,25 +336,18 @@ export class ScriptsService { } // 1. Generate/Refine Image Prompt using LLM - const promptGenPrompt = ` - Create a detailed AI Image Generation Prompt and a Video Generation Prompt for this script segment. - Topic: "${segment.project.topic}" - Segment Content: "${segment.narratorScript}" - Visual Context: "${segment.visualDescription}" - - Goal: Create a highly detailed, cinematic, and artistic prompt optimized for tools like Midjourney, Flux, or Runway. - Style: Cinematic, highly detailed, 8k, professional lighting. - - Return JSON: { - "imagePrompt": "Full detailed image prompt...", - "videoPrompt": "Full detailed video prompt..." - } - `; + const promptData = buildSegmentImagePrompt({ + topic: segment.project.topic, + narratorScript: segment.narratorScript || '', + visualDescription: segment.visualDescription || '', + }); const prompts = await this.gemini.generateJSON<{ imagePrompt: string; videoPrompt: string; - }>(promptGenPrompt, '{ imagePrompt, videoPrompt }'); + }>(promptData.prompt, promptData.schema, { + temperature: promptData.temperature, + }); // 2. Use the new image prompt for generation const imageUrl = await this.analysisService.generateThumbnailImage(