From 001bd7de0aea2fd71f86a43722d3d75a07246875 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 00:00:28 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Add=20Jus=20IA=20Start=20Kit=20?= =?UTF-8?q?=E2=80=94=20MPA=20wizard=20for=20legal=20prompt=20assembly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fastify v5 + TypeScript + Nunjucks + Tailwind CSS v4 application that guides Brazilian lawyers through a step-by-step flow to collect case details and assemble optimized prompts for Jus IA. Includes: - Flow engine with registry pattern for area/subtipo flows - Complete trabalhista/horas-extras flow (2 steps, legal references) - MPA state management via hidden form fields - Chip-selector UI with 44px touch targets - Optional LLM integration for contextual refinement questions - Prompt builder with template interpolation and URL delivery - Progressive enhancement JS (copy-to-clipboard) - Deep link support (/:area/:subtipo) - Brand design tokens from Jusbrasil guidelines https://claude.ai/code/session_01CvrcMDqfCKWV2hC3xpRbx3 --- jus-ia-start-kit/.env.example | 15 ++ jus-ia-start-kit/.gitignore | 5 + jus-ia-start-kit/package.json | 35 ++++ jus-ia-start-kit/src/config/constants.ts | 14 ++ jus-ia-start-kit/src/config/flows.ts | 60 ++++++ jus-ia-start-kit/src/config/index.ts | 12 ++ jus-ia-start-kit/src/flows/civel/index.ts | 11 ++ .../src/flows/trabalhista/horas-extras.ts | 168 ++++++++++++++++ .../src/flows/trabalhista/index.ts | 11 ++ jus-ia-start-kit/src/flows/types.ts | 63 ++++++ jus-ia-start-kit/src/public/js/app.js | 64 ++++++ jus-ia-start-kit/src/routes/flow.ts | 186 ++++++++++++++++++ jus-ia-start-kit/src/routes/home.ts | 51 +++++ jus-ia-start-kit/src/server.ts | 61 ++++++ jus-ia-start-kit/src/services/flow-engine.ts | 49 +++++ jus-ia-start-kit/src/services/llm-client.ts | 117 +++++++++++ .../src/services/prompt-builder.ts | 32 +++ jus-ia-start-kit/src/services/url-builder.ts | 12 ++ jus-ia-start-kit/src/styles/input.css | 63 ++++++ .../src/templates/layouts/base.njk | 38 ++++ .../src/templates/pages/error.njk | 24 +++ .../src/templates/pages/flow-step.njk | 74 +++++++ jus-ia-start-kit/src/templates/pages/home.njk | 62 ++++++ .../src/templates/pages/not-available.njk | 25 +++ .../src/templates/pages/preview.njk | 30 +++ .../src/templates/pages/select-subtipo.njk | 51 +++++ .../src/templates/partials/_back-button.njk | 10 + .../src/templates/partials/_chip-selector.njk | 35 ++++ .../src/templates/partials/_copy-fallback.njk | 38 ++++ .../src/templates/partials/_hidden-state.njk | 8 + .../src/templates/partials/_legal-badge.njk | 8 + .../src/templates/partials/_loading-state.njk | 12 ++ .../src/templates/partials/_preview-card.njk | 31 +++ .../templates/partials/_stepper-progress.njk | 15 ++ .../src/utils/parse-flow-state.ts | 49 +++++ jus-ia-start-kit/src/utils/sanitize.ts | 14 ++ jus-ia-start-kit/tsconfig.json | 19 ++ 37 files changed, 1572 insertions(+) create mode 100644 jus-ia-start-kit/.env.example create mode 100644 jus-ia-start-kit/.gitignore create mode 100644 jus-ia-start-kit/package.json create mode 100644 jus-ia-start-kit/src/config/constants.ts create mode 100644 jus-ia-start-kit/src/config/flows.ts create mode 100644 jus-ia-start-kit/src/config/index.ts create mode 100644 jus-ia-start-kit/src/flows/civel/index.ts create mode 100644 jus-ia-start-kit/src/flows/trabalhista/horas-extras.ts create mode 100644 jus-ia-start-kit/src/flows/trabalhista/index.ts create mode 100644 jus-ia-start-kit/src/flows/types.ts create mode 100644 jus-ia-start-kit/src/public/js/app.js create mode 100644 jus-ia-start-kit/src/routes/flow.ts create mode 100644 jus-ia-start-kit/src/routes/home.ts create mode 100644 jus-ia-start-kit/src/server.ts create mode 100644 jus-ia-start-kit/src/services/flow-engine.ts create mode 100644 jus-ia-start-kit/src/services/llm-client.ts create mode 100644 jus-ia-start-kit/src/services/prompt-builder.ts create mode 100644 jus-ia-start-kit/src/services/url-builder.ts create mode 100644 jus-ia-start-kit/src/styles/input.css create mode 100644 jus-ia-start-kit/src/templates/layouts/base.njk create mode 100644 jus-ia-start-kit/src/templates/pages/error.njk create mode 100644 jus-ia-start-kit/src/templates/pages/flow-step.njk create mode 100644 jus-ia-start-kit/src/templates/pages/home.njk create mode 100644 jus-ia-start-kit/src/templates/pages/not-available.njk create mode 100644 jus-ia-start-kit/src/templates/pages/preview.njk create mode 100644 jus-ia-start-kit/src/templates/pages/select-subtipo.njk create mode 100644 jus-ia-start-kit/src/templates/partials/_back-button.njk create mode 100644 jus-ia-start-kit/src/templates/partials/_chip-selector.njk create mode 100644 jus-ia-start-kit/src/templates/partials/_copy-fallback.njk create mode 100644 jus-ia-start-kit/src/templates/partials/_hidden-state.njk create mode 100644 jus-ia-start-kit/src/templates/partials/_legal-badge.njk create mode 100644 jus-ia-start-kit/src/templates/partials/_loading-state.njk create mode 100644 jus-ia-start-kit/src/templates/partials/_preview-card.njk create mode 100644 jus-ia-start-kit/src/templates/partials/_stepper-progress.njk create mode 100644 jus-ia-start-kit/src/utils/parse-flow-state.ts create mode 100644 jus-ia-start-kit/src/utils/sanitize.ts create mode 100644 jus-ia-start-kit/tsconfig.json diff --git a/jus-ia-start-kit/.env.example b/jus-ia-start-kit/.env.example new file mode 100644 index 000000000..d16c37dd2 --- /dev/null +++ b/jus-ia-start-kit/.env.example @@ -0,0 +1,15 @@ +# LLM Provider (openai or anthropic) +LLM_PROVIDER=openai +LLM_API_KEY=sk-your-api-key +LLM_MODEL=gpt-4o-mini + +# Server +PORT=3000 +HOST=0.0.0.0 +NODE_ENV=development + +# Analytics (optional) +ANALYTICS_ID= + +# Error Tracking (optional) +SENTRY_DSN= diff --git a/jus-ia-start-kit/.gitignore b/jus-ia-start-kit/.gitignore new file mode 100644 index 000000000..a71028314 --- /dev/null +++ b/jus-ia-start-kit/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +src/public/css/app.css +*.tsbuildinfo diff --git a/jus-ia-start-kit/package.json b/jus-ia-start-kit/package.json new file mode 100644 index 000000000..88210afed --- /dev/null +++ b/jus-ia-start-kit/package.json @@ -0,0 +1,35 @@ +{ + "name": "jus-ia-start-kit", + "version": "0.1.0", + "description": "Assistente web que guia advogados na construção de pedidos otimizados para o Jus IA", + "type": "module", + "scripts": { + "dev": "concurrently \"npm run dev:server\" \"npm run dev:css\"", + "dev:server": "tsx watch src/server.ts", + "dev:css": "npx @tailwindcss/cli -i src/styles/input.css -o src/public/css/app.css --watch", + "build": "npm run build:css && npm run build:server", + "build:server": "tsc", + "build:css": "npx @tailwindcss/cli -i src/styles/input.css -o src/public/css/app.css --minify", + "start": "node dist/server.js", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@fastify/formbody": "^8.0.2", + "@fastify/static": "^8.1.0", + "@fastify/view": "^10.0.1", + "fastify": "^5.2.1", + "nunjucks": "^3.2.4" + }, + "devDependencies": { + "@tailwindcss/cli": "^4.0.14", + "@types/node": "^22.12.0", + "@types/nunjucks": "^3.2.6", + "concurrently": "^9.1.2", + "pino-pretty": "^13.1.3", + "tailwindcss": "^4.0.14", + "tsx": "^4.19.2", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + } +} diff --git a/jus-ia-start-kit/src/config/constants.ts b/jus-ia-start-kit/src/config/constants.ts new file mode 100644 index 000000000..d096ac302 --- /dev/null +++ b/jus-ia-start-kit/src/config/constants.ts @@ -0,0 +1,14 @@ +/** Max URL length for Jus IA redirect (conservative to account for encoding) */ +export const MAX_URL_LENGTH = 1800; + +/** Jus IA base URL for redirect */ +export const JUS_IA_BASE_URL = "https://ia.jusbrasil.com.br/conversa"; + +/** LLM timeout in milliseconds */ +export const LLM_TIMEOUT_MS = 10_000; + +/** Max input text length for user responses */ +export const MAX_INPUT_LENGTH = 500; + +/** Max refinement questions from LLM */ +export const MAX_REFINEMENT_QUESTIONS = 3; diff --git a/jus-ia-start-kit/src/config/flows.ts b/jus-ia-start-kit/src/config/flows.ts new file mode 100644 index 000000000..688c91d18 --- /dev/null +++ b/jus-ia-start-kit/src/config/flows.ts @@ -0,0 +1,60 @@ +import type { FlowConfig } from "../flows/types.js"; +import { horasExtrasFlow } from "../flows/trabalhista/horas-extras.js"; + +/** Registry of all available flows */ +const flowRegistry: Map = new Map(); + +function registerFlow(flow: FlowConfig): void { + const key = `${flow.area}/${flow.subtipo}`; + flowRegistry.set(key, flow); +} + +// Register all flows +registerFlow(horasExtrasFlow); +// TODO: Register remaining 9 flows as they are built + +/** Get a flow by area/subtipo */ +export function getFlow(area: string, subtipo: string): FlowConfig | undefined { + return flowRegistry.get(`${area}/${subtipo}`); +} + +/** Get all flows for an area */ +export function getFlowsByArea(area: string): FlowConfig[] { + const flows: FlowConfig[] = []; + for (const [key, flow] of flowRegistry) { + if (key.startsWith(`${area}/`)) { + flows.push(flow); + } + } + return flows; +} + +/** Get all available areas */ +export function getAreas(): Array<{ value: string; label: string }> { + const areas = new Map(); + for (const flow of flowRegistry.values()) { + areas.set(flow.area, flow.areaLabel); + } + return Array.from(areas, ([value, label]) => ({ value, label })); +} + +/** Get all available task types */ +export function getTiposTarefa(): Array<{ value: string; label: string }> { + return [ + { value: "peticao-inicial", label: "Petição Inicial" }, + { value: "contestacao", label: "Contestação" }, + { value: "pesquisa-jurisprudencia", label: "Pesquisa de Jurisprudência" }, + { value: "parecer", label: "Parecer Jurídico" }, + { value: "contrato", label: "Contrato" }, + ]; +} + +/** Check if a flow exists */ +export function flowExists(area: string, subtipo: string): boolean { + return flowRegistry.has(`${area}/${subtipo}`); +} + +/** Get all registered flows */ +export function getAllFlows(): FlowConfig[] { + return Array.from(flowRegistry.values()); +} diff --git a/jus-ia-start-kit/src/config/index.ts b/jus-ia-start-kit/src/config/index.ts new file mode 100644 index 000000000..f0b65fa2d --- /dev/null +++ b/jus-ia-start-kit/src/config/index.ts @@ -0,0 +1,12 @@ +export const config = { + port: parseInt(process.env.PORT || "3000", 10), + host: process.env.HOST || "0.0.0.0", + nodeEnv: process.env.NODE_ENV || "development", + llm: { + provider: process.env.LLM_PROVIDER || "openai", + apiKey: process.env.LLM_API_KEY || "", + model: process.env.LLM_MODEL || "gpt-4o-mini", + }, + analyticsId: process.env.ANALYTICS_ID || "", + sentryDsn: process.env.SENTRY_DSN || "", +} as const; diff --git a/jus-ia-start-kit/src/flows/civel/index.ts b/jus-ia-start-kit/src/flows/civel/index.ts new file mode 100644 index 000000000..79c6eaecb --- /dev/null +++ b/jus-ia-start-kit/src/flows/civel/index.ts @@ -0,0 +1,11 @@ +export const civelArea = { + value: "civel", + label: "Cível", + subtipos: [ + { value: "cobranca", label: "Cobrança", available: false }, + { value: "indenizacao", label: "Indenização", available: false }, + { value: "obrigacao-fazer", label: "Obrigação de Fazer", available: false }, + { value: "contestacao", label: "Contestação Cível", available: false }, + { value: "contrato", label: "Contrato (Revisão)", available: false }, + ], +}; diff --git a/jus-ia-start-kit/src/flows/trabalhista/horas-extras.ts b/jus-ia-start-kit/src/flows/trabalhista/horas-extras.ts new file mode 100644 index 000000000..7017afc07 --- /dev/null +++ b/jus-ia-start-kit/src/flows/trabalhista/horas-extras.ts @@ -0,0 +1,168 @@ +import type { FlowConfig } from "../types.js"; + +export const horasExtrasFlow: FlowConfig = { + area: "trabalhista", + areaLabel: "Trabalhista", + subtipo: "horas-extras", + subtipoLabel: "Horas Extras", + tipoTarefa: "peticao-inicial", + steps: [ + { + stepNumber: 1, + title: "Dados do Caso", + requiresLlm: false, + groups: [ + { + title: "Relação de Trabalho", + questions: [ + { + id: "empregador_tipo", + text: "O empregador é pessoa jurídica ou física?", + type: "select", + options: ["Pessoa Jurídica (empresa)", "Pessoa Física"], + required: true, + }, + { + id: "regime", + text: "Qual o regime de trabalho?", + type: "select", + options: ["CLT", "PJ", "Autônomo", "Temporário"], + required: true, + }, + { + id: "jornada_contratual", + text: "Qual a jornada contratual?", + type: "select", + options: ["44h semanais", "36h semanais", "30h semanais", "Outra"], + required: true, + }, + ], + }, + { + title: "Período", + questions: [ + { + id: "data_inicio", + text: "Data de início do contrato", + type: "date", + required: true, + }, + { + id: "data_fim", + text: "Data de término (ou atual se ainda empregado)", + type: "date", + required: true, + }, + { + id: "ainda_empregado", + text: "Ainda está empregado?", + type: "select", + options: ["Sim", "Não"], + required: true, + }, + ], + }, + { + title: "Horas Extras", + questions: [ + { + id: "horas_extras_semana", + text: "Quantas horas extras estimadas por semana?", + type: "select", + options: [ + "Até 5 horas", + "5 a 10 horas", + "10 a 20 horas", + "Mais de 20 horas", + ], + required: true, + }, + { + id: "banco_horas", + text: "Havia banco de horas?", + type: "select", + options: ["Sim, formal", "Sim, informal", "Não"], + required: true, + }, + ], + }, + ], + }, + { + stepNumber: 2, + title: "Detalhes do Caso", + requiresLlm: true, + groups: [ + { + title: "Provas e Evidências", + questions: [ + { + id: "registro_ponto", + text: "Havia registro de ponto?", + type: "select", + options: [ + "Sim, eletrônico", + "Sim, manual", + "Não havia controle", + ], + required: true, + }, + { + id: "testemunhas", + text: "Existem testemunhas?", + type: "select", + options: ["Sim", "Não", "Não sei"], + required: true, + }, + { + id: "pagamento_parcial", + text: "Houve pagamento parcial de horas extras?", + type: "select", + options: [ + "Sim, parcial (algumas horas pagas)", + "Não, nenhum pagamento", + "Sim, todas pagas mas sem adicional correto", + ], + required: true, + }, + ], + }, + ], + }, + ], + promptTemplate: `Elabore uma petição inicial trabalhista de horas extras com os seguintes dados: + +**Partes:** +- Reclamante: [a ser preenchido pelo advogado] +- Reclamada: {{empregador_tipo}} + +**Vínculo empregatício:** +- Regime: {{regime}} +- Jornada contratual: {{jornada_contratual}} +- Período: {{data_inicio}} a {{data_fim}} +- Situação atual: {{ainda_empregado}} + +**Horas extras:** +- Volume estimado: {{horas_extras_semana}} por semana +- Banco de horas: {{banco_horas}} +- Registro de ponto: {{registro_ponto}} +- Pagamento anterior: {{pagamento_parcial}} +- Testemunhas: {{testemunhas}} + +{{#refinement_context}} + +**Fundamente com base em:** +- Art. 59 da CLT (limite de horas extras) +- Art. 71 da CLT (intervalo intrajornada) +- Súmula 85 do TST (compensação de jornada) +- Súmula 338 do TST (ônus da prova do registro de ponto) + +Inclua pedidos de: horas extras com adicional de 50% (dias úteis) e 100% (domingos/feriados), reflexos em DSR, férias + 1/3, 13º salário, FGTS + 40%, e honorários advocatícios.`, + + legalReferences: [ + "art. 59 CLT", + "art. 71 CLT", + "Súmula 85 TST", + "Súmula 338 TST", + ], +}; diff --git a/jus-ia-start-kit/src/flows/trabalhista/index.ts b/jus-ia-start-kit/src/flows/trabalhista/index.ts new file mode 100644 index 000000000..45a79ce2a --- /dev/null +++ b/jus-ia-start-kit/src/flows/trabalhista/index.ts @@ -0,0 +1,11 @@ +export const trabalhistaArea = { + value: "trabalhista", + label: "Trabalhista", + subtipos: [ + { value: "horas-extras", label: "Horas Extras" }, + { value: "rescisao-indireta", label: "Rescisão Indireta", available: false }, + { value: "dano-moral", label: "Dano Moral", available: false }, + { value: "acumulo-funcao", label: "Acúmulo de Função", available: false }, + { value: "contestacao", label: "Contestação Trabalhista", available: false }, + ], +}; diff --git a/jus-ia-start-kit/src/flows/types.ts b/jus-ia-start-kit/src/flows/types.ts new file mode 100644 index 000000000..e59e3a9bb --- /dev/null +++ b/jus-ia-start-kit/src/flows/types.ts @@ -0,0 +1,63 @@ +/** A single question in a flow step */ +export interface FlowQuestion { + id: string; + text: string; + type: "select" | "multiselect" | "text" | "date"; + options?: string[]; + placeholder?: string; + required: boolean; +} + +/** A group of questions displayed together (mental moment) */ +export interface QuestionGroup { + title: string; + questions: FlowQuestion[]; +} + +/** A single step in a flow */ +export interface FlowStep { + stepNumber: number; + title: string; + groups: QuestionGroup[]; + requiresLlm: boolean; +} + +/** Complete flow configuration for a legal subtype */ +export interface FlowConfig { + area: string; + areaLabel: string; + subtipo: string; + subtipoLabel: string; + tipoTarefa: string; + steps: FlowStep[]; + promptTemplate: string; + legalReferences: string[]; +} + +/** Accumulated state across MPA pages */ +export interface FlowState { + area: string; + subtipo: string; + tipoTarefa: string; + currentStep: number; + totalSteps: number; + responses: Record; +} + +/** Parsed LLM refinement response */ +export interface RefinementQuestion { + id: string; + text: string; + type: "select" | "multiselect" | "text"; + options?: string[]; + placeholder?: string; +} + +/** Assembled prompt ready for delivery */ +export interface AssembledPrompt { + text: string; + legalReferences: string[]; + charCount: number; + fitsInUrl: boolean; + encodedUrl?: string; +} diff --git a/jus-ia-start-kit/src/public/js/app.js b/jus-ia-start-kit/src/public/js/app.js new file mode 100644 index 000000000..d0a18becb --- /dev/null +++ b/jus-ia-start-kit/src/public/js/app.js @@ -0,0 +1,64 @@ +/** + * Progressive Enhancement — Jus IA Start Kit + * Everything works without this file (MPA form submissions). + * This adds: copy-to-clipboard, chip visual feedback, smooth interactions. + */ + +// Copy to clipboard +function setupCopyButtons() { + document.querySelectorAll('[id^="copy-btn"]').forEach((btn) => { + btn.addEventListener("click", async () => { + const textarea = document.getElementById("prompt-text"); + const text = textarea + ? textarea.value + : btn.dataset.promptText || ""; + + try { + await navigator.clipboard.writeText(text); + const originalHTML = btn.innerHTML; + btn.innerHTML = ` + + + + Copiado! + `; + btn.classList.add("bg-[var(--color-success)]"); + setTimeout(() => { + btn.innerHTML = originalHTML; + btn.classList.remove("bg-[var(--color-success)]"); + }, 2000); + } catch { + // Fallback: select text in textarea + if (textarea) { + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + } + } + }); + }); +} + +// Chip visual feedback (check icon on selection) +function setupChipFeedback() { + document.querySelectorAll('input[type="radio"], input[type="checkbox"]').forEach((input) => { + if (input.closest(".peer")) { + input.addEventListener("change", () => { + // For radio buttons, remove check from siblings + if (input.type === "radio" && input.name) { + document.querySelectorAll(`input[name="${input.name}"]`).forEach((sibling) => { + const span = sibling.nextElementSibling; + if (span) { + span.querySelectorAll(".check-icon").forEach((icon) => icon.remove()); + } + }); + } + }); + } + }); +} + +// Initialize on DOM ready +document.addEventListener("DOMContentLoaded", () => { + setupCopyButtons(); + setupChipFeedback(); +}); diff --git a/jus-ia-start-kit/src/routes/flow.ts b/jus-ia-start-kit/src/routes/flow.ts new file mode 100644 index 000000000..1d1eec372 --- /dev/null +++ b/jus-ia-start-kit/src/routes/flow.ts @@ -0,0 +1,186 @@ +import type { FastifyInstance } from "fastify"; +import { getFlow, flowExists, getAllFlows } from "../config/flows.js"; +import { getCurrentStep, calculateTotalSteps, getVisualStep } from "../services/flow-engine.js"; +import { buildPrompt } from "../services/prompt-builder.js"; +import { getJusIaDirectUrl } from "../services/url-builder.js"; +import { getRefinementQuestions } from "../services/llm-client.js"; +import { parseFlowState, serializeFlowState } from "../utils/parse-flow-state.js"; +import type { FlowState, QuestionGroup } from "../flows/types.js"; + +export async function flowRoutes(app: FastifyInstance): Promise { + // POST /:area/iniciar — Start a flow after subtipo selection + app.post<{ Params: { area: string }; Body: Record }>( + "/:area/iniciar", + async (request, reply) => { + const { area } = request.params; + const subtipo = request.body.subtipo; + const tipoTarefa = request.body._tipo_tarefa || "peticao-inicial"; + + if (!flowExists(area, subtipo)) { + return reply.view("pages/not-available.njk", { + availableFlows: getAllFlows(), + }); + } + + const flow = getFlow(area, subtipo)!; + const totalSteps = calculateTotalSteps(flow, false); + + const flowState: FlowState = { + area, + subtipo, + tipoTarefa, + currentStep: 1, + totalSteps, + responses: {}, + }; + + const step = flow.steps[0]; + if (!step) { + return reply.view("pages/error.njk", { + errorMessage: "Fluxo sem perguntas configuradas.", + retryable: true, + retryUrl: "/", + }); + } + + return reply.view("pages/flow-step.njk", { + stepTitle: step.title, + flowLabel: `${flow.areaLabel} — ${flow.subtipoLabel}`, + groups: step.groups, + flowState, + serializedResponses: serializeFlowState(flowState), + formAction: `/${area}/${subtipo}/step/1`, + backHref: "/", + isLastStep: flow.steps.length === 1, + currentVisualStep: getVisualStep(flow, 1, false), + totalSteps, + }); + }, + ); + + // GET /:area/:subtipo — Deep link entry (skip selection) + app.get<{ Params: { area: string; subtipo: string } }>( + "/:area/:subtipo", + async (request, reply) => { + const { area, subtipo } = request.params; + + if (!flowExists(area, subtipo)) { + return reply.view("pages/not-available.njk", { + availableFlows: getAllFlows(), + }); + } + + const flow = getFlow(area, subtipo)!; + const totalSteps = calculateTotalSteps(flow, true); + + const flowState: FlowState = { + area, + subtipo, + tipoTarefa: flow.tipoTarefa, + currentStep: 1, + totalSteps, + responses: {}, + }; + + const step = flow.steps[0]!; + + return reply.view("pages/flow-step.njk", { + stepTitle: step.title, + flowLabel: `${flow.areaLabel} — ${flow.subtipoLabel}`, + groups: step.groups, + flowState, + serializedResponses: serializeFlowState(flowState), + formAction: `/${area}/${subtipo}/step/1`, + backHref: "/", + isLastStep: flow.steps.length === 1, + currentVisualStep: getVisualStep(flow, 1, true), + totalSteps, + }); + }, + ); + + // POST /:area/:subtipo/step/:stepNumber — Handle step submission + app.post<{ Params: { area: string; subtipo: string; stepNumber: string }; Body: Record }>( + "/:area/:subtipo/step/:stepNumber", + async (request, reply) => { + const { area, subtipo, stepNumber } = request.params; + const currentStepNum = parseInt(stepNumber, 10); + + const flow = getFlow(area, subtipo); + if (!flow) { + return reply.view("pages/not-available.njk", { + availableFlows: getAllFlows(), + }); + } + + // Parse accumulated state + new responses + const flowState = parseFlowState(request.body as Record); + flowState.area = area; + flowState.subtipo = subtipo; + + const hasDeepLink = flowState.totalSteps < 5; // Heuristic: deep link has fewer total steps + const nextStepNum = currentStepNum + 1; + const nextStep = flow.steps.find((s) => s.stepNumber === nextStepNum); + + // If there's a next step, render it + if (nextStep) { + flowState.currentStep = nextStepNum; + + // If next step requires LLM, get refinement questions + let groups: QuestionGroup[] = nextStep.groups; + if (nextStep.requiresLlm) { + const refinementQuestions = await getRefinementQuestions( + flowState, + flow.areaLabel, + flow.subtipoLabel, + ); + if (refinementQuestions.length > 0) { + // Add LLM questions to the existing groups + groups = [ + ...nextStep.groups, + { + title: "Perguntas específicas do seu caso", + questions: refinementQuestions.map((q) => ({ + ...q, + required: true, + })), + }, + ]; + } + } + + return reply.view("pages/flow-step.njk", { + stepTitle: nextStep.title, + flowLabel: `${flow.areaLabel} — ${flow.subtipoLabel}`, + groups, + flowState, + serializedResponses: serializeFlowState(flowState), + formAction: `/${area}/${subtipo}/step/${nextStepNum}`, + backHref: `/${area}/${subtipo}/step/${currentStepNum - 1}`, + isLastStep: !flow.steps.find((s) => s.stepNumber === nextStepNum + 1), + currentVisualStep: getVisualStep(flow, nextStepNum, hasDeepLink), + totalSteps: flowState.totalSteps, + }); + } + + // No more steps — build prompt and show preview + const prompt = buildPrompt(flow, flowState); + + return reply.view("pages/preview.njk", { + flowLabel: `uma ${flow.subtipoLabel.toLowerCase()} ${flow.areaLabel.toLowerCase()}`, + promptText: prompt.text, + legalReferences: prompt.legalReferences, + fitsInUrl: prompt.fitsInUrl, + encodedUrl: prompt.encodedUrl, + jusIaUrl: getJusIaDirectUrl(), + currentVisualStep: flowState.totalSteps, + totalSteps: flowState.totalSteps, + }); + }, + ); + + // GET /health — Health check + app.get("/health", async () => { + return { status: "ok" }; + }); +} diff --git a/jus-ia-start-kit/src/routes/home.ts b/jus-ia-start-kit/src/routes/home.ts new file mode 100644 index 000000000..111662f3b --- /dev/null +++ b/jus-ia-start-kit/src/routes/home.ts @@ -0,0 +1,51 @@ +import type { FastifyInstance } from "fastify"; +import { getAreas, getTiposTarefa } from "../config/flows.js"; +import { trabalhistaArea } from "../flows/trabalhista/index.js"; +import { civelArea } from "../flows/civel/index.js"; + +export async function homeRoutes(app: FastifyInstance): Promise { + // GET / — Landing page + app.get("/", async (_request, reply) => { + return reply.view("pages/home.njk", { + tiposTarefa: getTiposTarefa(), + areas: getAreas(), + }); + }); + + // POST /selecionar — Handle tipo + area selection + app.post<{ Body: { tipo_tarefa: string; area: string } }>( + "/selecionar", + async (request, reply) => { + const { tipo_tarefa, area } = request.body; + + // Get subtipos for the selected area + let areaConfig; + let areaLabel = ""; + if (area === "trabalhista") { + areaConfig = trabalhistaArea; + areaLabel = "Trabalhista"; + } else if (area === "civel") { + areaConfig = civelArea; + areaLabel = "Cível"; + } + + if (!areaConfig) { + return reply.view("pages/not-available.njk", { + availableFlows: [], + }); + } + + const tiposTarefa = getTiposTarefa(); + const tipoTarefaLabel = + tiposTarefa.find((t) => t.value === tipo_tarefa)?.label || tipo_tarefa; + + return reply.view("pages/select-subtipo.njk", { + area, + areaLabel, + tipoTarefa: tipo_tarefa, + tipoTarefaLabel, + subtipos: areaConfig.subtipos, + }); + }, + ); +} diff --git a/jus-ia-start-kit/src/server.ts b/jus-ia-start-kit/src/server.ts new file mode 100644 index 000000000..0b6f3b5cd --- /dev/null +++ b/jus-ia-start-kit/src/server.ts @@ -0,0 +1,61 @@ +import Fastify from "fastify"; +import fastifyView from "@fastify/view"; +import fastifyStatic from "@fastify/static"; +import fastifyFormbody from "@fastify/formbody"; +import nunjucks from "nunjucks"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { config } from "./config/index.js"; +import { homeRoutes } from "./routes/home.js"; +import { flowRoutes } from "./routes/flow.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const app = Fastify({ + logger: { + level: config.nodeEnv === "production" ? "info" : "debug", + transport: + config.nodeEnv === "development" + ? { target: "pino-pretty", options: { translateTime: "HH:MM:ss" } } + : undefined, + }, +}); + +// Parse form body (application/x-www-form-urlencoded) +await app.register(fastifyFormbody); + +// Serve static assets +await app.register(fastifyStatic, { + root: join(__dirname, "public"), + prefix: "/", +}); + +// Nunjucks template engine +const nunjucksEnv = nunjucks.configure(join(__dirname, "templates"), { + autoescape: true, + noCache: config.nodeEnv === "development", +}); + +await app.register(fastifyView, { + engine: { nunjucks }, + templates: join(__dirname, "templates"), + options: { + onConfigure: (env: nunjucks.Environment) => { + // Add any custom filters here + }, + }, +}); + +// Register routes +await app.register(homeRoutes); +await app.register(flowRoutes); + +// Start server +try { + const address = await app.listen({ port: config.port, host: config.host }); + app.log.info(`Jus IA Start Kit running at ${address}`); +} catch (err) { + app.log.error(err); + process.exit(1); +} diff --git a/jus-ia-start-kit/src/services/flow-engine.ts b/jus-ia-start-kit/src/services/flow-engine.ts new file mode 100644 index 000000000..13437acd2 --- /dev/null +++ b/jus-ia-start-kit/src/services/flow-engine.ts @@ -0,0 +1,49 @@ +import type { FlowConfig, FlowState, FlowStep } from "../flows/types.js"; +import { getFlow } from "../config/flows.js"; + +/** Get the current step configuration for a flow state */ +export function getCurrentStep(state: FlowState): FlowStep | null { + const flow = getFlow(state.area, state.subtipo); + if (!flow) return null; + + const step = flow.steps.find((s) => s.stepNumber === state.currentStep); + return step ?? null; +} + +/** Get the full flow config */ +export function getFlowConfig(area: string, subtipo: string): FlowConfig | null { + return getFlow(area, subtipo) ?? null; +} + +/** Calculate total steps: selection screens + flow steps + preview */ +export function calculateTotalSteps(flow: FlowConfig, hasDeepLink: boolean): number { + const selectionSteps = hasDeepLink ? 0 : 2; // tipo+area, subtipo + const flowSteps = flow.steps.length; + const previewStep = 1; + return selectionSteps + flowSteps + previewStep; +} + +/** Determine the visual step number for the progress indicator */ +export function getVisualStep( + flow: FlowConfig, + currentFlowStep: number, + hasDeepLink: boolean, +): number { + const offset = hasDeepLink ? 0 : 2; + return offset + currentFlowStep; +} + +/** Check if all required questions in a step are answered */ +export function isStepComplete(step: FlowStep, responses: Record): boolean { + for (const group of step.groups) { + for (const question of group.questions) { + if (question.required) { + const answer = responses[question.id]; + if (!answer || (typeof answer === "string" && !answer.trim())) { + return false; + } + } + } + } + return true; +} diff --git a/jus-ia-start-kit/src/services/llm-client.ts b/jus-ia-start-kit/src/services/llm-client.ts new file mode 100644 index 000000000..41973d76b --- /dev/null +++ b/jus-ia-start-kit/src/services/llm-client.ts @@ -0,0 +1,117 @@ +import type { RefinementQuestion, FlowState } from "../flows/types.js"; +import { config } from "../config/index.js"; +import { LLM_TIMEOUT_MS, MAX_REFINEMENT_QUESTIONS } from "../config/constants.js"; + +const SYSTEM_PROMPT = `Você é um assistente jurídico especializado em direito brasileiro. +Dado o contexto de um caso jurídico, gere perguntas de refinamento para capturar nuances importantes. + +Regras: +- Gere no máximo ${MAX_REFINEMENT_QUESTIONS} perguntas +- Cada pergunta deve ser relevante ao caso e área do direito +- Prefira perguntas de seleção (com opções) a perguntas de texto livre +- Use linguagem jurídica profissional mas acessível +- As perguntas devem ajudar a construir um pedido mais preciso + +Responda APENAS em JSON no formato: +{ + "questions": [ + { + "id": "identificador_unico", + "text": "Texto da pergunta", + "type": "select", + "options": ["Opção 1", "Opção 2", "Opção 3"] + } + ] +}`; + +/** Call LLM for contextual refinement questions */ +export async function getRefinementQuestions( + state: FlowState, + areaLabel: string, + subtipoLabel: string, +): Promise { + if (!config.llm.apiKey) { + // No LLM configured — return empty (skip refinement) + return []; + } + + const userMessage = `Caso: ${areaLabel} - ${subtipoLabel} + +Dados coletados até agora: +${Object.entries(state.responses) + .map(([key, value]) => `- ${key}: ${Array.isArray(value) ? value.join(", ") : value}`) + .join("\n")} + +Gere perguntas de refinamento para capturar nuances deste caso.`; + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), LLM_TIMEOUT_MS); + + let response: Response; + + if (config.llm.provider === "anthropic") { + response = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": config.llm.apiKey, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: config.llm.model, + max_tokens: 1024, + system: SYSTEM_PROMPT, + messages: [{ role: "user", content: userMessage }], + }), + signal: controller.signal, + }); + } else { + // Default: OpenAI-compatible + response = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${config.llm.apiKey}`, + }, + body: JSON.stringify({ + model: config.llm.model, + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: userMessage }, + ], + temperature: 0.3, + response_format: { type: "json_object" }, + }), + signal: controller.signal, + }); + } + + clearTimeout(timeout); + + if (!response.ok) { + throw new Error(`LLM API error: ${response.status}`); + } + + const data = await response.json(); + let content: string; + + if (config.llm.provider === "anthropic") { + content = data.content?.[0]?.text || "{}"; + } else { + content = data.choices?.[0]?.message?.content || "{}"; + } + + const parsed = JSON.parse(content); + const questions: RefinementQuestion[] = (parsed.questions || []).slice( + 0, + MAX_REFINEMENT_QUESTIONS, + ); + + return questions; + } catch (error) { + // LLM failure is non-blocking — skip refinement + console.error("LLM refinement failed:", (error as Error).message); + return []; + } +} diff --git a/jus-ia-start-kit/src/services/prompt-builder.ts b/jus-ia-start-kit/src/services/prompt-builder.ts new file mode 100644 index 000000000..43e06dbc2 --- /dev/null +++ b/jus-ia-start-kit/src/services/prompt-builder.ts @@ -0,0 +1,32 @@ +import type { AssembledPrompt, FlowConfig, FlowState } from "../flows/types.js"; +import { MAX_URL_LENGTH, JUS_IA_BASE_URL } from "../config/constants.js"; + +/** Build the final prompt from template + responses */ +export function buildPrompt(flow: FlowConfig, state: FlowState): AssembledPrompt { + let text = flow.promptTemplate; + + // Replace template variables with responses + for (const [key, value] of Object.entries(state.responses)) { + const displayValue = Array.isArray(value) ? value.join(", ") : value; + text = text.replace(new RegExp(`\\{\\{${key}\\}\\}`, "g"), displayValue); + } + + // Remove any unreplaced template variables + text = text.replace(/\{\{#?\w+\}\}/g, ""); + + // Clean up extra whitespace + text = text.replace(/\n{3,}/g, "\n\n").trim(); + + const charCount = text.length; + const encodedQuery = encodeURIComponent(text); + const fullUrl = `${JUS_IA_BASE_URL}?q=${encodedQuery}&send`; + const fitsInUrl = fullUrl.length <= MAX_URL_LENGTH; + + return { + text, + legalReferences: flow.legalReferences, + charCount, + fitsInUrl, + encodedUrl: fitsInUrl ? fullUrl : undefined, + }; +} diff --git a/jus-ia-start-kit/src/services/url-builder.ts b/jus-ia-start-kit/src/services/url-builder.ts new file mode 100644 index 000000000..692136611 --- /dev/null +++ b/jus-ia-start-kit/src/services/url-builder.ts @@ -0,0 +1,12 @@ +import { JUS_IA_BASE_URL } from "../config/constants.js"; + +/** Build the Jus IA redirect URL from assembled prompt text */ +export function buildRedirectUrl(promptText: string): string { + const encoded = encodeURIComponent(promptText); + return `${JUS_IA_BASE_URL}?q=${encoded}&send`; +} + +/** Get the direct Jus IA URL (without query) for copy-paste fallback */ +export function getJusIaDirectUrl(): string { + return JUS_IA_BASE_URL; +} diff --git a/jus-ia-start-kit/src/styles/input.css b/jus-ia-start-kit/src/styles/input.css new file mode 100644 index 000000000..ae8951cac --- /dev/null +++ b/jus-ia-start-kit/src/styles/input.css @@ -0,0 +1,63 @@ +@import "tailwindcss"; + +@theme { + /* Brand Colors — Jus IA aligned */ + --color-primary: #007A5F; + --color-primary-light: #009B78; + --color-primary-dark: #005C47; + --color-accent: #D4A843; + --color-surface: #FFFFFF; + --color-background: #F8F9FA; + --color-text-primary: #5C6F8A; + --color-text-dark: #0F172A; + --color-text-secondary: #6B7280; + --color-border: #B3C0D0; + + /* Semantic Colors */ + --color-success: #7AB441; + --color-warning: #D97706; + --color-error: #DC2626; + --color-info: #378CC8; + + /* Typography */ + --font-sans: "Inter", system-ui, -apple-system, "Segoe UI", "Oxygen", "Ubuntu", "Cantarell", "Open Sans", "Helvetica Neue", sans-serif; + + /* Spacing base: 8px */ + --spacing-1: 4px; + --spacing-2: 8px; + --spacing-3: 12px; + --spacing-4: 16px; + --spacing-6: 24px; + --spacing-8: 32px; + + /* Border Radius */ + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-full: 9999px; +} + +/* Base styles */ +html { + font-family: var(--font-sans); + color: var(--color-text-primary); + background-color: var(--color-background); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + margin: 0; + min-height: 100dvh; +} + +/* Focus visible for keyboard navigation */ +:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +/* Touch target minimum */ +button, a, input, select, [role="radio"], [role="checkbox"] { + min-height: 44px; +} diff --git a/jus-ia-start-kit/src/templates/layouts/base.njk b/jus-ia-start-kit/src/templates/layouts/base.njk new file mode 100644 index 000000000..8893b978f --- /dev/null +++ b/jus-ia-start-kit/src/templates/layouts/base.njk @@ -0,0 +1,38 @@ + + + + + + {% block title %}Jus IA Start Kit{% endblock %} + + {% block ogTags %} + + + + + {% endblock %} + + + + + +
+ {% block header %} +
+ + Jus IA Start Kit + + {% if totalSteps %} + {% include "partials/_stepper-progress.njk" %} + {% endif %} +
+ {% endblock %} + + {% block content %}{% endblock %} +
+ + {% block scripts %} + + {% endblock %} + + diff --git a/jus-ia-start-kit/src/templates/pages/error.njk b/jus-ia-start-kit/src/templates/pages/error.njk new file mode 100644 index 000000000..95b3bc38e --- /dev/null +++ b/jus-ia-start-kit/src/templates/pages/error.njk @@ -0,0 +1,24 @@ +{% extends "layouts/base.njk" %} + +{% block title %}Erro — Jus IA Start Kit{% endblock %} + +{% block content %} +
+

+ {{ errorTitle or "Algo deu errado" }} +

+

+ {{ errorMessage or "Não conseguimos processar seu pedido. Tente novamente." }} +

+ + {% if retryable %} + + Tentar novamente + + {% endif %} + + + Voltar ao início + +
+{% endblock %} diff --git a/jus-ia-start-kit/src/templates/pages/flow-step.njk b/jus-ia-start-kit/src/templates/pages/flow-step.njk new file mode 100644 index 000000000..6b6a7994d --- /dev/null +++ b/jus-ia-start-kit/src/templates/pages/flow-step.njk @@ -0,0 +1,74 @@ +{% extends "layouts/base.njk" %} + +{% block title %}{{ stepTitle }} — Jus IA Start Kit{% endblock %} + +{% block content %} +{% set backUrl = backHref %} +{% include "partials/_back-button.njk" %} + +
+

+ {{ stepTitle }} +

+

+ {{ flowLabel }} +

+
+ +
+ {% include "partials/_hidden-state.njk" %} + + {% for group in groups %} +
+

+ {{ group.title }} +

+ + {% for question in group.questions %} + {% if question.type == "select" or question.type == "multiselect" %} + {% set selectedValue = flowState.responses[question.id] %} + {% include "partials/_chip-selector.njk" %} + {% elif question.type == "date" %} +
+ + +
+ {% elif question.type == "text" %} +
+ + +
+ {% endif %} + {% endfor %} +
+ {% endfor %} + + +
+{% endblock %} diff --git a/jus-ia-start-kit/src/templates/pages/home.njk b/jus-ia-start-kit/src/templates/pages/home.njk new file mode 100644 index 000000000..e4f861fce --- /dev/null +++ b/jus-ia-start-kit/src/templates/pages/home.njk @@ -0,0 +1,62 @@ +{% extends "layouts/base.njk" %} + +{% block title %}Jus IA Start Kit — Monte seu pedido jurídico{% endblock %} + +{% block content %} +
+

+ O que você precisa? +

+

+ Responda algumas perguntas sobre seu caso e receba um pedido otimizado para o Jus IA. +

+
+ +
+ {# Task type selection #} +
+ + Tipo de tarefa + +
+ {% for tipo in tiposTarefa %} + + {% endfor %} +
+
+ + {# Area selection #} +
+ + Área do direito + +
+ {% for area in areas %} + + {% endfor %} +
+
+ + +
+{% endblock %} diff --git a/jus-ia-start-kit/src/templates/pages/not-available.njk b/jus-ia-start-kit/src/templates/pages/not-available.njk new file mode 100644 index 000000000..89c248af9 --- /dev/null +++ b/jus-ia-start-kit/src/templates/pages/not-available.njk @@ -0,0 +1,25 @@ +{% extends "layouts/base.njk" %} + +{% block title %}Fluxo não disponível — Jus IA Start Kit{% endblock %} + +{% block content %} +{% set backUrl = "/" %} +{% include "partials/_back-button.njk" %} + +
+

+ Este fluxo ainda não está disponível +

+

+ Estamos trabalhando para adicionar mais fluxos. Veja os que já estão disponíveis: +

+ +
+ {% for flow in availableFlows %} + + {{ flow.areaLabel }} — {{ flow.subtipoLabel }} + + {% endfor %} +
+
+{% endblock %} diff --git a/jus-ia-start-kit/src/templates/pages/preview.njk b/jus-ia-start-kit/src/templates/pages/preview.njk new file mode 100644 index 000000000..f19f13fc9 --- /dev/null +++ b/jus-ia-start-kit/src/templates/pages/preview.njk @@ -0,0 +1,30 @@ +{% extends "layouts/base.njk" %} + +{% block title %}Seu pedido está pronto — Jus IA Start Kit{% endblock %} + +{% block content %} +
+

+ Pronto! +

+

+ Montamos seu pedido otimizado para o Jus IA. Ele vai gerar {{ flowLabel }} com fundamentação jurídica na primeira tentativa. +

+
+ +{% include "partials/_preview-card.njk" %} + +{# Always show copy option as secondary #} +{% if fitsInUrl %} +
+ +
+{% endif %} +{% endblock %} diff --git a/jus-ia-start-kit/src/templates/pages/select-subtipo.njk b/jus-ia-start-kit/src/templates/pages/select-subtipo.njk new file mode 100644 index 000000000..f6ae61014 --- /dev/null +++ b/jus-ia-start-kit/src/templates/pages/select-subtipo.njk @@ -0,0 +1,51 @@ +{% extends "layouts/base.njk" %} + +{% block title %}{{ areaLabel }} — Jus IA Start Kit{% endblock %} + +{% block content %} +{% set backUrl = "/" %} +{% include "partials/_back-button.njk" %} + +
+

+ Que tipo de {{ tipoTarefaLabel | lower }}? +

+

+ {{ areaLabel }} +

+
+ +
+ + + +
+ Selecione o subtipo +
+ {% for sub in subtipos %} + {% if sub.available !== false %} + + {% else %} + + {{ sub.label }} + (em breve) + + {% endif %} + {% endfor %} +
+
+ + +
+{% endblock %} diff --git a/jus-ia-start-kit/src/templates/partials/_back-button.njk b/jus-ia-start-kit/src/templates/partials/_back-button.njk new file mode 100644 index 000000000..ffd481e5b --- /dev/null +++ b/jus-ia-start-kit/src/templates/partials/_back-button.njk @@ -0,0 +1,10 @@ +{# Back Button — secondary action, top of page #} +{# Expects: backUrl (string, optional) #} +{% if backUrl %} + + + + + Voltar + +{% endif %} diff --git a/jus-ia-start-kit/src/templates/partials/_chip-selector.njk b/jus-ia-start-kit/src/templates/partials/_chip-selector.njk new file mode 100644 index 000000000..42b4f395c --- /dev/null +++ b/jus-ia-start-kit/src/templates/partials/_chip-selector.njk @@ -0,0 +1,35 @@ +{# Chip Selector — single or multi select #} +{# Expects: question (FlowQuestion object), selectedValue (string or array) #} +
+ + {{ question.text }} + +
+ {% for option in question.options %} + {% set isSelected = (selectedValue == option) or (selectedValue and option in selectedValue) %} + + {% endfor %} +
+
diff --git a/jus-ia-start-kit/src/templates/partials/_copy-fallback.njk b/jus-ia-start-kit/src/templates/partials/_copy-fallback.njk new file mode 100644 index 000000000..d62f4276f --- /dev/null +++ b/jus-ia-start-kit/src/templates/partials/_copy-fallback.njk @@ -0,0 +1,38 @@ +{# Copy Fallback — when URL exceeds limit #} +{# Expects: promptText (string), jusIaUrl (string) #} +
+

+ Seu pedido está pronto! Copie e cole no Jus IA: +

+ +
+ +
+ + + + + Abrir Jus IA + +
diff --git a/jus-ia-start-kit/src/templates/partials/_hidden-state.njk b/jus-ia-start-kit/src/templates/partials/_hidden-state.njk new file mode 100644 index 000000000..0b042f749 --- /dev/null +++ b/jus-ia-start-kit/src/templates/partials/_hidden-state.njk @@ -0,0 +1,8 @@ +{# Hidden State — preserves flow state between MPA pages #} +{# Expects: flowState (FlowState object) #} + + + + + + diff --git a/jus-ia-start-kit/src/templates/partials/_legal-badge.njk b/jus-ia-start-kit/src/templates/partials/_legal-badge.njk new file mode 100644 index 000000000..fb7510142 --- /dev/null +++ b/jus-ia-start-kit/src/templates/partials/_legal-badge.njk @@ -0,0 +1,8 @@ +{# Legal Badge — pill with legal reference #} +{# Expects: reference (string like "art. 59 CLT") #} + + + + + {{ reference }} + diff --git a/jus-ia-start-kit/src/templates/partials/_loading-state.njk b/jus-ia-start-kit/src/templates/partials/_loading-state.njk new file mode 100644 index 000000000..31a1f4d2b --- /dev/null +++ b/jus-ia-start-kit/src/templates/partials/_loading-state.njk @@ -0,0 +1,12 @@ +{# Loading State — skeleton shimmer during LLM call #} +
+

+ Analisando seu caso... +

+
+
+
+
+
+
+
diff --git a/jus-ia-start-kit/src/templates/partials/_preview-card.njk b/jus-ia-start-kit/src/templates/partials/_preview-card.njk new file mode 100644 index 000000000..2b4effe35 --- /dev/null +++ b/jus-ia-start-kit/src/templates/partials/_preview-card.njk @@ -0,0 +1,31 @@ +{# Preview Card — the "aha moment" card #} +{# Expects: prompt (AssembledPrompt), flow (FlowConfig) #} +
+

+ Seu pedido otimizado para o Jus IA +

+ +
+ {{ promptText | truncate(300) }} +
+ + {# Legal reference badges #} +
+ {% for ref in legalReferences %} + {% include "partials/_legal-badge.njk" %} + {% endfor %} +
+ + {# Delivery method #} + {% if fitsInUrl %} + + Gerar no Jus IA → + + {% else %} + {% include "partials/_copy-fallback.njk" %} + {% endif %} + +

+ Seus dados não são armazenados após o redirecionamento +

+
diff --git a/jus-ia-start-kit/src/templates/partials/_stepper-progress.njk b/jus-ia-start-kit/src/templates/partials/_stepper-progress.njk new file mode 100644 index 000000000..382235eef --- /dev/null +++ b/jus-ia-start-kit/src/templates/partials/_stepper-progress.njk @@ -0,0 +1,15 @@ +{# Stepper Progress — compact visual indicator #} +{# Expects: currentVisualStep, totalSteps #} +
+ {% for i in range(1, totalSteps + 1) %} +
+ {% endfor %} +
diff --git a/jus-ia-start-kit/src/utils/parse-flow-state.ts b/jus-ia-start-kit/src/utils/parse-flow-state.ts new file mode 100644 index 000000000..55c6e2c26 --- /dev/null +++ b/jus-ia-start-kit/src/utils/parse-flow-state.ts @@ -0,0 +1,49 @@ +import type { FlowState } from "../flows/types.js"; +import { sanitizeText } from "./sanitize.js"; + +/** Parse FlowState from form body hidden fields */ +export function parseFlowState(body: Record): FlowState { + const responses: Record = {}; + + // Parse _responses JSON from hidden field + if (typeof body._responses === "string" && body._responses) { + try { + const parsed = JSON.parse(body._responses); + if (typeof parsed === "object" && parsed !== null) { + for (const [key, value] of Object.entries(parsed)) { + if (typeof value === "string") { + responses[key] = sanitizeText(value); + } else if (Array.isArray(value)) { + responses[key] = value.map((v) => sanitizeText(String(v))); + } + } + } + } catch { + // Invalid JSON, skip + } + } + + // Merge current step's new responses + for (const [key, value] of Object.entries(body)) { + if (key.startsWith("_")) continue; // Skip internal fields + if (typeof value === "string") { + responses[key] = sanitizeText(value); + } else if (Array.isArray(value)) { + responses[key] = value.map((v) => sanitizeText(String(v))); + } + } + + return { + area: sanitizeText(String(body._area || "")), + subtipo: sanitizeText(String(body._subtipo || "")), + tipoTarefa: sanitizeText(String(body._tipo_tarefa || "")), + currentStep: parseInt(String(body._step || "1"), 10), + totalSteps: parseInt(String(body._total_steps || "5"), 10), + responses, + }; +} + +/** Serialize FlowState to hidden form fields */ +export function serializeFlowState(state: FlowState): string { + return JSON.stringify(state.responses); +} diff --git a/jus-ia-start-kit/src/utils/sanitize.ts b/jus-ia-start-kit/src/utils/sanitize.ts new file mode 100644 index 000000000..25f3a2bb2 --- /dev/null +++ b/jus-ia-start-kit/src/utils/sanitize.ts @@ -0,0 +1,14 @@ +import { MAX_INPUT_LENGTH } from "../config/constants.js"; + +/** Strip HTML tags and trim whitespace from user input */ +export function sanitizeText(input: string): string { + return input + .replace(/<[^>]*>/g, "") + .trim() + .slice(0, MAX_INPUT_LENGTH); +} + +/** Validate that a select value is in the allowed options */ +export function validateSelect(value: string, allowedOptions: string[]): boolean { + return allowedOptions.includes(value); +} diff --git a/jus-ia-start-kit/tsconfig.json b/jus-ia-start-kit/tsconfig.json new file mode 100644 index 000000000..49eb3596c --- /dev/null +++ b/jus-ia-start-kit/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +}