diff --git a/ai-service/harness.py b/ai-service/harness.py index b459725..5f40fe4 100644 --- a/ai-service/harness.py +++ b/ai-service/harness.py @@ -71,6 +71,14 @@ def measurable_time_question(fields: dict[str, str], editing_context: Any, rules return f"Qual é o tempo máximo verificável para ‘{excerpt}’?" return None +def contains_term(value: str, term: str) -> bool: + return re.search(r"(? bool: + # Mesma regra do backend: só os campos da história e das regras, nunca o contexto de edição. + story = "\n".join(fields.get(key, "") for key in ("titulo", "historia_como_um", "historia_eu_quero", "historia_para_que", "regras_observacoes")) + return any(contains_term(story, term) for term in rules.get("permission_terms", [])) + @router.post("/harness/run") async def run_harness(req: HarnessRequest): if req.operation not in OPERATIONS[req.mode] or set(req.config.operation_instructions) != OPERATIONS[req.mode]: @@ -116,6 +124,12 @@ async def run_harness(req: HarnessRequest): if proposal_schema and all(isinstance(fields.get(key), str) and fields[key].strip() for key in proposal_schema.get("required", []) if key != "regras_observacoes"): output_schema["properties"]["proposal"] = proposal_schema output_schema["properties"]["questions"]["maxItems"] = 0 + if req.operation == "cenarios": + # O backend só chama esta operação com a história completa: há base para ao menos um cenário. + scenarios_schema = output_schema.get("properties", {}).get("scenarios") + if isinstance(scenarios_schema, dict): + scenarios_schema["minItems"] = 1 + scenarios_schema["maxItems"] = 8 if req.operation == "questionar": questions_schema = output_schema.get("properties", {}).get("questions", {}) if isinstance(questions_schema, dict): @@ -125,6 +139,10 @@ async def run_harness(req: HarnessRequest): system += "\nLeia primeiro TODOS os campos e criterios_salvos. Um cenário DADO/QUANDO/ENTÃO já informa o caminho principal. Não pergunte novamente o caminho principal quando ele estiver descrito. Uma regra que limita consulta ao proprietário já informa a permissão. Não peça ator, objetivo ou benefício se já constam da história. Se requer_interface for false, não faça perguntas de interface. Procure lacunas específicas como o tempo máximo quando há 'rápido' ou 'rapidamente'. Não pergunte como implementar nem como corrigir automaticamente falhas. Retorne somente perguntas ainda necessárias; a lista pode estar vazia." if req.operation == "padronizar": system += "\nSua tarefa é organizar o texto EXISTENTE nos campos do nível, não exigir uma especificação completa de cenários. Se título e os campos obrigatórios da história/nível já estiverem preenchidos, retorne proposal com esses campos reorganizados, preservando ator, intenção e benefício. Não peça novamente informações já presentes. Não adicione regras. regras_observacoes pode ficar vazio. Use proposal null apenas quando faltar informação para preencher os campos obrigatórios do nível; não por faltarem critérios de exceção, detalhes técnicos ou tempo de resposta. Não pergunte como implementar." + if req.operation == "cenarios": + system += "\nProponha de dois a seis cenários de aceitação para o PBI, cada um com nome curto, tipo, DADO, QUANDO e ENTÃO verificáveis. Use tipo caminho_principal para o fluxo esperado, acesso_nao_autorizado para quem não tem permissão e alternativo para validações, erros e estados alternativos. Inclua ao menos um caminho_principal. Não repita cenários de criterios_salvos. Descreva comportamento observável, nunca implementação. Use perguntas apenas para lacunas que impeçam um cenário verificável." + if requires_permission_scenario(fields, req.config.rules): + system += "\nA história envolve uma ação sujeita a permissão: inclua obrigatoriamente ao menos um cenário de tipo acesso_nao_autorizado, em que um usuário sem permissão tenta a ação e o sistema a recusa sem alterar dados. O ENTÃO desse cenário deve dizer explicitamente que a ação é recusada ou negada." system += "\nSeja conciso: no máximo seis perguntas sobre lacunas relevantes, sem repetir o que já está escrito." prompt = json.dumps({"fields": fields, "editing_context": editing_context, "context": [chunk.model_dump() for chunk in req.context]}, ensure_ascii=False) try: @@ -133,7 +151,7 @@ async def run_harness(req: HarnessRequest): elif req.mode == "recomendacao" and not req.context: result = {"items": []} else: - raw = await asyncio.wait_for(ollama_client.generate_response(prompt, system_prompt=system, temperature=0.1, output_schema=output_schema, max_tokens=1024), req.config.timeout_ms / 1000) + raw = await asyncio.wait_for(ollama_client.generate_response(prompt, system_prompt=system, temperature=0.1, output_schema=output_schema, max_tokens=2048 if req.operation == "cenarios" else 1024), req.config.timeout_ms / 1000) if len(raw) > 200000: raise ValueError("Resposta excedeu o limite") result = json.loads(raw) diff --git a/ai-service/tests/fixtures/harness.json b/ai-service/tests/fixtures/harness.json index 87750a9..eb0dcd3 100644 --- a/ai-service/tests/fixtures/harness.json +++ b/ai-service/tests/fixtures/harness.json @@ -328,6 +328,19 @@ "items": { "type": "object", "properties": { + "nome": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "tipo": { + "type": "string", + "enum": [ + "caminho_principal", + "acesso_nao_autorizado", + "alternativo" + ] + }, "dado": { "type": "string", "minLength": 1, @@ -345,6 +358,8 @@ } }, "required": [ + "nome", + "tipo", "dado", "quando", "entao" @@ -377,6 +392,19 @@ "items": { "type": "object", "properties": { + "nome": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "tipo": { + "type": "string", + "enum": [ + "caminho_principal", + "acesso_nao_autorizado", + "alternativo" + ] + }, "dado": { "type": "string", "minLength": 1, @@ -394,6 +422,8 @@ } }, "required": [ + "nome", + "tipo", "dado", "quando", "entao" @@ -426,6 +456,19 @@ "items": { "type": "object", "properties": { + "nome": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "tipo": { + "type": "string", + "enum": [ + "caminho_principal", + "acesso_nao_autorizado", + "alternativo" + ] + }, "dado": { "type": "string", "minLength": 1, @@ -443,6 +486,8 @@ } }, "required": [ + "nome", + "tipo", "dado", "quando", "entao" @@ -643,7 +688,7 @@ "operation_instructions": { "questionar": "Avalie exatamente as seis dimensões de cobertura. Questione ambiguidade e condições sem critério verificável, sem inventar lacunas.", "padronizar": "Reorganize somente o conteúdo fornecido na estrutura do nível. Se faltar informação para uma proposta completa, retorne proposal null e perguntas objetivas.", - "cenarios": "Proponha cenários DADO / QUANDO / ENTÃO apenas com fundamento no item. Se faltar informação, faça perguntas." + "cenarios": "Proponha cenários DADO / QUANDO / ENTÃO apenas com fundamento na história e nas regras do PBI, cada um com nome curto e tipo. Inclua ao menos um cenário de caminho principal. Se a história envolver permissão, inclua também ao menos um cenário de acesso não autorizado. Não repita cenários já salvos. Se faltar informação, faça perguntas." }, "timeout_ms": 45000, "rules": { @@ -665,6 +710,26 @@ "react", "docker", "tabela no banco" + ], + "permission_terms": [ + "permissão", + "permissões", + "sem permissão", + "não autorizado", + "não autorizada", + "usuário autorizado", + "usuários autorizados", + "perfil de acesso", + "perfil de administrador", + "controle de acesso", + "nível de acesso", + "acesso restrito", + "somente o administrador", + "apenas o administrador", + "somente administradores", + "apenas administradores", + "somente o proprietário", + "apenas o proprietário" ] } } diff --git a/ai-service/tests/test_harness.py b/ai-service/tests/test_harness.py index aee3a0b..3d0fa34 100644 --- a/ai-service/tests/test_harness.py +++ b/ai-service/tests/test_harness.py @@ -154,3 +154,33 @@ async def test_named_main_scenario_is_not_requested_again(self): self.assertEqual(response.status_code,200) dimensions=model.call_args.kwargs["output_schema"]["properties"]["questions"]["items"]["properties"]["dimension"]["enum"] self.assertNotIn("caminho_principal",dimensions) + + def scenarios_request(self, **fields): + story = {"titulo":"Consultar pedido", "historia_como_um":"cliente", "historia_eu_quero":"consultar meus pedidos", "historia_para_que":"acompanhar a entrega", **fields} + return self.request(operation="cenarios", output_schema=FIXTURE["schemas"]["cenarios"]["pbi"], fields=story) + + async def test_scenarios_are_named_typed_and_at_least_one(self): + answer={"scenarios":[{"nome":"Consultar pedido existente","tipo":"caminho_principal","dado":"que há um pedido","quando":"o cliente consulta","entao":"o pedido é exibido"}],"questions":[]} + with patch("harness.ollama_client.generate_response", new=AsyncMock(return_value=json.dumps(answer))) as model: + response=await self.post(self.scenarios_request()) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["result"], answer) + schema=model.call_args.kwargs["output_schema"]["properties"]["scenarios"] + self.assertEqual((schema["minItems"], schema["maxItems"]), (1, 8)) + self.assertIn("caminho_principal", model.call_args.kwargs["system_prompt"]) + self.assertNotIn("obrigatoriamente", model.call_args.kwargs["system_prompt"]) + for invalid in [{"scenarios":[],"questions":["Qual?"]}, {"scenarios":[{**answer["scenarios"][0],"tipo":"outro"}],"questions":[]}]: + with self.subTest(invalid=invalid), patch("harness.ollama_client.generate_response", new=AsyncMock(return_value=json.dumps(invalid))): + self.assertEqual((await self.post(self.scenarios_request())).status_code, 503) + + async def test_permission_story_requires_unauthorized_access_scenario_in_prompt(self): + answer='{"scenarios":[{"nome":"x","tipo":"caminho_principal","dado":"a","quando":"b","entao":"c"}],"questions":[]}' + for fields, expected in [({"historia_eu_quero":"aprovar pedidos com perfil de administrador"}, True), ({"regras_observacoes":"Somente o Proprietário altera o pedido."}, True), ({}, False)]: + with self.subTest(fields=fields), patch("harness.ollama_client.generate_response", new=AsyncMock(return_value=answer)) as model: + await self.post(self.scenarios_request(**fields)) + self.assertEqual("acesso_nao_autorizado, em que um usuário sem permissão" in model.call_args.kwargs["system_prompt"], expected) + + async def test_scenarios_have_larger_output_budget(self): + with patch("harness.ollama_client.generate_response", new=AsyncMock(return_value='{"scenarios":[{"nome":"x","tipo":"caminho_principal","dado":"a","quando":"b","entao":"c"}],"questions":[]}')) as model: + await self.post(self.scenarios_request()) + self.assertEqual(model.call_args.kwargs["max_tokens"], 2048) diff --git a/backend/scripts/validate-harness.mts b/backend/scripts/validate-harness.mts index d8054f6..cc90fd7 100644 --- a/backend/scripts/validate-harness.mts +++ b/backend/scripts/validate-harness.mts @@ -40,8 +40,35 @@ const model = createServer(async (req, res) => { } if (delayMs) await new Promise((resolve) => setTimeout(resolve, delayMs)); const proposalSchema = payload.format.properties.proposal; + const scenario = (nome: string, tipo: string, entao: string) => ({ + nome, + tipo, + dado: "que existe um pedido registrado", + quando: "o usuário solicita a aprovação", + entao, + }); const result = invalid ? { questions: [], summary: "SEGREDO", extra: "proibido" } + : payload.format.properties.scenarios + ? { + scenarios: [ + scenario( + "Aprovar pedido com permissão", + "caminho_principal", + "o pedido passa a constar como aprovado", + ), + ...(payload.system.includes("obrigatoriamente") + ? [ + scenario( + "Recusar aprovação sem permissão", + "acesso_nao_autorizado", + "o sistema recusa a ação e o pedido permanece inalterado", + ), + ] + : []), + ], + questions: [], + } : proposalSchema ? { proposal: Object.fromEntries( @@ -282,6 +309,30 @@ try { assert.equal(bad.status, 503); assert.ok(!JSON.stringify(bad.body).includes("SEGREDO")); invalid = false; + // S2-11: história incompleta não chega ao modelo; com permissão, vem o cenário de acesso não autorizado. + const story = { + titulo: "Aprovar pedido", + historia_como_um: "gestor", + historia_eu_quero: "aprovar pedidos com o perfil de administrador", + historia_para_que: "liberar a entrega", + }; + const scenarioRequest = { ...request, operation: "cenarios", fields: story }; + const modelCalls = captures.length; + const incomplete = await call(run, 1, "POST", { + ...scenarioRequest, + fields: { ...story, historia_para_que: "" }, + }); + assert.equal(incomplete.status, 400); + assert.equal(incomplete.body.code, "HISTORIA_INCOMPLETA"); + assert.deepEqual(incomplete.body.details.campos, ["historia_para_que"]); + assert.equal(captures.length, modelCalls); + const scenarios = await call(run, 1, "POST", scenarioRequest); + assert.equal(scenarios.status, 200, JSON.stringify(scenarios.body)); + assert.deepEqual( + scenarios.body.result.scenarios.map((item: any) => item.tipo), + ["caminho_principal", "acesso_nao_autorizado"], + ); + assert.ok(captures.at(-1).system.includes("acesso_nao_autorizado")); const answer = await call(run, 1, "POST", { mode: "consulta", operation: "responder", diff --git a/backend/src/modules/harness/harness.contract.ts b/backend/src/modules/harness/harness.contract.ts index 8224a43..1dcf3cd 100644 --- a/backend/src/modules/harness/harness.contract.ts +++ b/backend/src/modules/harness/harness.contract.ts @@ -35,6 +35,38 @@ export const FIELDS = { "regras_observacoes", ], } as const; +export const SCENARIO_TYPES = [ + "caminho_principal", + "acesso_nao_autorizado", + "alternativo", +] as const; +// Expressões que indicam ação sujeita a permissão (PBI-03.1.3, cenário 2). +// Palavras soltas como "perfil" ou "administrador" também aparecem como dado do +// domínio ("perfil público", "cadastrar administrador") e não entram aqui. +export const PERMISSION_TERMS = [ + "permissão", + "permissões", + "sem permissão", + "não autorizado", + "não autorizada", + "usuário autorizado", + "usuários autorizados", + "perfil de acesso", + "perfil de administrador", + "controle de acesso", + "nível de acesso", + "acesso restrito", + "somente o administrador", + "apenas o administrador", + "somente administradores", + "apenas administradores", + "somente o proprietário", + "apenas o proprietário", +] as const; +// ENTÃO de um cenário de acesso não autorizado precisa descrever a recusa. +// A verificação é lexical: garante que a recusa está escrita, não que a regra está correta. +export const DENIAL_PATTERN = + /(? [...PERMISSION_TERMS]), }) .strict(); export const configSchema = z @@ -57,7 +95,7 @@ export const configSchema = z rules: rulesSchema, }) .strict(); -export type HarnessConfig = z.infer; +export type HarnessConfig = z.output; export interface HarnessRevision { mode: HarnessMode; version: number; @@ -101,7 +139,7 @@ export function defaultConfig(mode: HarnessMode): HarnessConfig { padronizar: "Reorganize somente o conteúdo fornecido na estrutura do nível. Se faltar informação para uma proposta completa, retorne proposal null e perguntas objetivas.", cenarios: - "Proponha cenários DADO / QUANDO / ENTÃO apenas com fundamento no item. Se faltar informação, faça perguntas.", + "Proponha cenários DADO / QUANDO / ENTÃO apenas com fundamento na história e nas regras do PBI, cada um com nome curto e tipo. Inclua ao menos um cenário de caminho principal. Se a história envolver permissão, inclua também ao menos um cenário de acesso não autorizado. Não repita cenários já salvos. Se faltar informação, faça perguntas.", responder: "Responda à pergunta e liste os IDs dos trechos efetivamente utilizados.", similares: @@ -123,6 +161,7 @@ export function defaultConfig(mode: HarnessMode): HarnessConfig { "docker", "tabela no banco", ], + permission_terms: [...PERMISSION_TERMS], }, }; } @@ -167,7 +206,15 @@ export function outputSchema( } case "cenarios": return obj({ - scenarios: array(obj({ dado: str, quando: str, entao: str })), + scenarios: array( + obj({ + nome: { type: "string", minLength: 1, maxLength: 255 }, + tipo: { type: "string", enum: SCENARIO_TYPES }, + dado: str, + quando: str, + entao: str, + }), + ), questions, }); case "responder": @@ -219,7 +266,17 @@ export function parseOutput(input: HarnessInput, value: unknown) { cenarios: z .object({ scenarios: z - .array(z.object({ dado: text, quando: text, entao: text }).strict()) + .array( + z + .object({ + nome: z.string().trim().min(1).max(255), + tipo: z.enum(SCENARIO_TYPES), + dado: text, + quando: text, + entao: text, + }) + .strict(), + ) .max(30), questions, }) diff --git a/backend/src/modules/harness/harness.service.test.ts b/backend/src/modules/harness/harness.service.test.ts index d90d1e1..58d8256 100644 --- a/backend/src/modules/harness/harness.service.test.ts +++ b/backend/src/modules/harness/harness.service.test.ts @@ -11,6 +11,7 @@ import { import { HarnessRepository } from "./harness.repository.js"; import { HarnessService, + requiresPermissionScenario, validateConfig, validateGuide, } from "./harness.service.js"; @@ -272,3 +273,247 @@ test("padronização respeita limites dos campos que receberão sugestões", () }), ); }); + +const story = { + titulo: "Consultar pedido", + historia_como_um: "cliente", + historia_eu_quero: "consultar meus pedidos", + historia_para_que: "acompanhar a entrega", +}; +const scenariosInput = requestSchema.parse({ + mode: "levantamento", + operation: "cenarios", + level: "pbi", + fields: story, +}); +const scenario = (tipo: string, nome = "Consultar pedido existente") => + tipo === "acesso_nao_autorizado" + ? { + nome, + tipo, + dado: "que o usuário não tem permissão para aprovar pedidos", + quando: "ele tenta aprovar um pedido", + entao: "o sistema recusa a ação e o pedido permanece inalterado", + } + : { + nome, + tipo, + dado: "que o cliente possui um pedido registrado", + quando: "ele consulta seus pedidos", + entao: "o sistema exibe o pedido com o estado atual", + }; + +test("PBI-03.1.3: sugere cenários DADO/QUANDO/ENTÃO nomeados e tipados a partir da história completa", async () => { + const sent: Record[] = []; + const service = new HarnessService(new MemoryRepository(), { + async generate(payload) { + sent.push(payload); + return { scenarios: [scenario("caminho_principal")], questions: [] }; + }, + }); + const answer = await service.run("p1", scenariosInput); + assert.equal(answer.operation, "cenarios"); + assert.deepEqual(answer.result, { + scenarios: [scenario("caminho_principal")], + questions: [], + }); + assert.deepEqual(sent[0].output_schema, outputSchema("cenarios", "pbi")); + assert.throws(() => + parseOutput(scenariosInput, { + scenarios: [{ ...scenario("caminho_principal"), tipo: "outro" }], + questions: [], + }), + ); + assert.throws(() => + parseOutput(scenariosInput, { + scenarios: [{ ...scenario("caminho_principal"), nome: "x".repeat(256) }], + questions: [], + }), + ); + const { nome: _nome, ...semNome } = scenario("caminho_principal"); + assert.throws(() => + parseOutput(scenariosInput, { scenarios: [semNome], questions: [] }), + ); +}); + +test("PBI-03.1.3: história com permissão exige caminho principal e acesso não autorizado", () => { + const config = defaultConfig("levantamento"); + const restricted = { + ...scenariosInput, + fields: { + ...story, + historia_eu_quero: "aprovar pedidos com o perfil de administrador", + }, + }; + assert.equal(requiresPermissionScenario(restricted.fields, config), true); + assert.equal(requiresPermissionScenario(story, config), false); + // "perfil" e "administrador" como dado do domínio não indicam permissão. + for (const historia_eu_quero of [ + "consultar o perfil público do desenvolvedor", + "editar meu perfil", + "cadastrar um administrador do condomínio", + ]) + assert.equal( + requiresPermissionScenario({ ...story, historia_eu_quero }, config), + false, + historia_eu_quero, + ); + for (const regras_observacoes of [ + "Somente o proprietário pode excluir o pedido.", + "Usuário sem permissão não visualiza o valor.", + "Acesso restrito ao time financeiro.", + ]) + assert.equal( + requiresPermissionScenario({ ...story, regras_observacoes }, config), + true, + regras_observacoes, + ); + assert.throws( + () => + validateGuide( + restricted, + { scenarios: [scenario("caminho_principal")], questions: [] }, + config, + ), + /acesso não autorizado/, + ); + assert.throws( + () => + validateGuide( + restricted, + { scenarios: [scenario("acesso_nao_autorizado")], questions: [] }, + config, + ), + /caminho principal/, + ); + assert.doesNotThrow(() => + validateGuide( + restricted, + { + scenarios: [ + scenario("caminho_principal"), + scenario("acesso_nao_autorizado", "Recusar usuário sem permissão"), + ], + questions: [], + }, + config, + ), + ); + // Termo de permissão no contexto de edição não conta: só história e regras. + assert.equal( + requiresPermissionScenario( + { ...story, texto_livre: "perfil administrador" }, + config, + ), + false, + ); +}); + +test("PBI-03.1.3: cenário de acesso não autorizado precisa descrever a recusa no ENTÃO", () => { + const config = defaultConfig("levantamento"); + const contraditorio = { + ...scenario("acesso_nao_autorizado", "Recusar usuário sem permissão"), + entao: "o sistema exibe o pedido com o estado atual", + }; + assert.throws( + () => + validateGuide( + scenariosInput, + { scenarios: [scenario("caminho_principal"), contraditorio], questions: [] }, + config, + ), + /sem recusa/, + ); + for (const entao of [ + "o sistema recusa a aprovação", + "o acesso é negado e uma mensagem de permissão é exibida", + "a exclusão não é permitida", + "o pedido permanece inalterado", + ]) + assert.doesNotThrow( + () => + validateGuide( + scenariosInput, + { + scenarios: [ + scenario("caminho_principal"), + { ...contraditorio, entao }, + ], + questions: [], + }, + config, + ), + entao, + ); +}); + +test("PBI-03.1.3: cenários descrevem comportamento, e lista vazia só com perguntas", () => { + const config = defaultConfig("levantamento"); + assert.throws(() => + validateGuide( + scenariosInput, + { + scenarios: [ + { ...scenario("caminho_principal"), entao: "o endpoint grava no SQL" }, + ], + questions: [], + }, + config, + ), + ); + assert.throws(() => + validateGuide(scenariosInput, { scenarios: [], questions: [] }, config), + ); + assert.doesNotThrow(() => + validateGuide( + scenariosInput, + { scenarios: [], questions: ["Qual estado o pedido deve exibir?"] }, + config, + ), + ); +}); + +test("PBI-03.1.3: história incompleta pede preenchimento sem chamar o modelo", async () => { + let calls = 0; + const service = new HarnessService(new MemoryRepository(), { + async generate() { + calls++; + return { scenarios: [scenario("caminho_principal")], questions: [] }; + }, + }); + for (const [field, value] of [ + ["historia_como_um", ""], + ["historia_eu_quero", " "], + ["historia_para_que", ""], + ] as const) + await assert.rejects( + service.run("p1", { + ...scenariosInput, + fields: { ...story, [field]: value }, + }), + (error: any) => + error.statusCode === 400 && + error.code === "HISTORIA_INCOMPLETA" && + error.details.campos.includes(field), + ); + assert.equal(calls, 0); +}); + +test("revisão salva antes da S2-11 recebe os termos de permissão padrão", () => { + const { permission_terms: _terms, ...legacyRules } = + defaultConfig("levantamento").rules; + const config = validateConfig("levantamento", { + ...defaultConfig("levantamento"), + rules: legacyRules, + }); + assert.deepEqual( + config.rules.permission_terms, + defaultConfig("levantamento").rules.permission_terms, + ); + assert.throws(() => + validateConfig("levantamento", { + ...defaultConfig("levantamento"), + rules: { ...legacyRules, permission_terms: [] }, + }), + ); +}); diff --git a/backend/src/modules/harness/harness.service.ts b/backend/src/modules/harness/harness.service.ts index df4da81..33cde0c 100644 --- a/backend/src/modules/harness/harness.service.ts +++ b/backend/src/modules/harness/harness.service.ts @@ -2,12 +2,16 @@ import axios from "axios"; import { env } from "../../config/env.js"; import { serviceHeaders } from "../../shared/service-auth.js"; import { AppError, ValidationError } from "../../shared/errors.js"; -import { validarTituloInfinitivo } from "../quality/quality.rules.js"; +import { + validarHistoria, + validarTituloInfinitivo, +} from "../quality/quality.rules.js"; import { ChatRepository } from "../chat/chat.repository.js"; import { searchPatterns } from "./search-patterns.js"; import { HarnessRepository } from "./harness.repository.js"; import { configSchema, + DENIAL_PATTERN, FIELDS, OPERATIONS, outputSchema, @@ -79,6 +83,7 @@ export function validateGuide( result.items.some((item: { source_id: string }) => !ids.has(item.source_id)) ) throw Error("Origem inválida"); + if (input.operation === "cenarios") return validateScenarios(input, result, config); if (input.operation !== "padronizar") return; if (!result.proposal) { if (!result.questions.length) @@ -101,6 +106,73 @@ export function validateGuide( ) throw Error("Descrição de implementação"); } +export function requiresPermissionScenario( + fields: Record, + config: HarnessConfig, +) { + const story = FIELDS.pbi.map((field) => fields[field] ?? "").join("\n"); + return config.rules.permission_terms.some((term) => containsTerm(story, term)); +} +function validateScenarios( + input: HarnessInput, + result: { scenarios: Array>; questions: string[] }, + config: HarnessConfig, +) { + if (!result.scenarios.length) { + if (!result.questions.length) + throw Error("Cenários ausentes sem esclarecimento"); + return; + } + const types = new Set(result.scenarios.map((scenario) => scenario.tipo)); + if (!types.has("caminho_principal")) throw Error("Sem caminho principal"); + if ( + requiresPermissionScenario(input.fields, config) && + !types.has("acesso_nao_autorizado") + ) + throw Error("Sem cenário de acesso não autorizado"); + // Um cenário marcado como acesso não autorizado que termina em sucesso contradiz o próprio tipo. + if ( + result.scenarios.some( + (scenario) => + scenario.tipo === "acesso_nao_autorizado" && + !DENIAL_PATTERN.test(scenario.entao), + ) + ) + throw Error("Acesso não autorizado sem recusa"); + const content = result.scenarios + .map((scenario) => [scenario.dado, scenario.quando, scenario.entao].join("\n")) + .join("\n"); + if ( + config.rules.implementation_terms.some((term) => + containsTerm(content, term), + ) + ) + throw Error("Descrição de implementação"); +} +// PBI-03.1.3, cenário 3: sem história completa não há o que sugerir. +function assertStoryComplete(fields: Record) { + const story = { + comoUm: fields.historia_como_um ?? "", + euQuero: fields.historia_eu_quero ?? "", + paraQue: fields.historia_para_que ?? "", + }; + if (validarHistoria(story).aprovado) return; + const missing = ( + [ + ["historia_como_um", story.comoUm], + ["historia_eu_quero", story.euQuero], + ["historia_para_que", story.paraQue], + ] as const + ) + .filter(([, value]) => !value.trim()) + .map(([field]) => field); + throw new AppError( + "Complete a história do usuário (COMO UM, EU QUERO e PARA QUE) antes de pedir sugestões de cenários.", + 400, + "HISTORIA_INCOMPLETA", + { campos: missing }, + ); +} export class HarnessService { constructor( private readonly repository = new HarnessRepository(), @@ -143,6 +215,7 @@ export class HarnessService { throw new ValidationError( "Informe os campos do nível ou a pergunta do modo selecionado.", ); + if (input.operation === "cenarios") assertStoryComplete(input.fields); const revision = await this.repository.current(input.mode); const config = validateConfig(input.mode, revision.config); // Cada requisição lê a revisão vigente; nada é mantido em cache de processo. diff --git a/docs/S2-09-HARNESS.md b/docs/S2-09-HARNESS.md index d420325..e28c8ea 100644 --- a/docs/S2-09-HARNESS.md +++ b/docs/S2-09-HARNESS.md @@ -8,7 +8,7 @@ O backend mantém configurações independentes para levantamento, consulta e re | --- | --- | --- | | levantamento | questionar | perguntas por dimensão e resumo; perguntas vazias quando não houver lacunas relevantes | | levantamento | padronizar | proposta nos campos do nível e perguntas; proposta nula quando faltam informações | -| levantamento | cenarios | cenários DADO/QUANDO/ENTÃO e perguntas; somente PBI | +| levantamento | cenarios | cenários com nome, tipo e DADO/QUANDO/ENTÃO, e perguntas; somente PBI com história completa; caminho principal sempre e acesso não autorizado quando a história cita `permission_terms` (ver [S2-11](S2-11-CENARIOS.md)) | | consulta | responder | resposta e IDs das fontes; recusa explícita quando não há evidência | | recomendacao | similares | IDs e justificativas dos candidatos pertinentes; lista vazia quando não há candidatos | diff --git a/docs/S2-11-CENARIOS.md b/docs/S2-11-CENARIOS.md new file mode 100644 index 0000000..dab5901 --- /dev/null +++ b/docs/S2-11-CENARIOS.md @@ -0,0 +1,56 @@ +# S2-11: Sugestão de cenários de aceitação + +Implementa o [PBI-03.1.3](backlog/EP-03-apoio-inteligencia-artificial.md) (Sugerir cenários de aceitação) sobre o harness da S2-09 e o painel da S2-10. + +## Entrega + +Na edição de um PBI, o painel **Apoio à especificação** ganhou o modo **Sugerir cenários**. Ele usa a operação `cenarios` do modo `levantamento`, que já existia no contrato da S2-09 sem uso na interface. + +| Critério do PBI | Como é atendido | +| --- | --- | +| 1. Sugerir a partir da história | Cada sugestão traz nome, tipo, DADO, QUANDO e ENTÃO. A tela mostra os cenários em lista e cada um tem o botão **Usar no editor**. | +| 2. Caminho principal e acesso não autorizado | Toda resposta com cenários precisa ter ao menos um `caminho_principal`. Se a história ou as regras do PBI citarem uma expressão de `rules.permission_terms` (por exemplo "sem permissão", "perfil de acesso", "somente o proprietário"), a resposta também precisa ter um `acesso_nao_autorizado`, e o ENTÃO desse cenário precisa descrever a recusa. O backend recusa a resposta que não cumpre isso. | +| 3. Exigir história completa | Sem COMO UM, EU QUERO ou PARA QUE, a tela informa quais blocos faltam e não consulta a IA. O backend aplica a mesma regra: responde 400 `HISTORIA_INCOMPLETA` com `details.campos` e não chama o modelo. | + +O modo só aparece na edição de um PBI existente e para quem pode editar os cenários. Na criação do PBI ainda não existe onde salvar um cenário, então o modo fica oculto. + +## Do cenário sugerido ao cenário salvo + +**Usar no editor** copia nome, DADO, QUANDO e ENTÃO para o formulário de cenário da seção **Cenários do PBI** e leva o foco até ele. Nada é gravado nesse momento: o PO revisa, ajusta e salva pelo fluxo normal, que já cuida da justificativa em item concluído e do registro de auditoria. + +- Se o formulário já tiver um cenário sendo digitado, ele não é sobrescrito. O painel avisa para salvar ou cancelar esse cenário antes. +- Salvar um cenário sugerido não apaga as outras sugestões, para que o PO possa aproveitar várias de uma vez. Editar os campos do PBI continua descartando a resposta, porque ela deixa de corresponder ao texto atual. +- As sugestões não usam a tabela de sugestões da S2-13: ela trata de campos do item, e cenários são registros próprios. + +## Contrato + +A operação continua sendo `POST /api/v1/projects/{projectId}/ai/requests` com `mode: levantamento` e `operation: cenarios`, somente para `level: pbi`. Mudanças no contrato da S2-09: + +- Cada item de `scenarios` passou a ter `nome` (até 255 caracteres, o limite do critério de PBI) e `tipo` (`caminho_principal`, `acesso_nao_autorizado` ou `alternativo`). +- `rules` ganhou `permission_terms`. A lista é versionada com o restante da configuração do modo. Revisões salvas antes desta entrega não têm a lista e passam a usar o padrão do código, sem migration. +- A instrução padrão de `cenarios` foi detalhada. Revisões já salvas mantêm a instrução que tinham. + +O serviço de IA limita a resposta a entre 1 e 8 cenários, reforça no prompt o cenário de acesso não autorizado quando a história envolve permissão e usa um orçamento de 2.048 tokens para essa operação. A detecção de permissão considera apenas título, história e regras do PBI, nunca o contexto de edição. O [OpenAPI](api/openapi.yaml) foi atualizado. + +### Limites das verificações + +As duas verificações são lexicais, não semânticas: + +- **Detecção de permissão:** a lista padrão usa expressões de autorização, não palavras soltas. "Perfil" ou "administrador" sozinhos também aparecem como dado do domínio ("consultar o perfil público", "cadastrar um administrador"), por isso só "perfil de acesso" e "perfil de administrador" contam. Uma história que exige permissão com outras palavras não é detectada; nesse caso, o administrador acrescenta a expressão em `permission_terms`. +- **Cenário de acesso não autorizado:** o backend confere que o ENTÃO descreve uma recusa (recusa, negado, bloqueado, não é permitido, permanece inalterado, mensagem de erro ou de permissão...). Isso impede um cenário marcado como recusa que termina em sucesso, mas não garante que a regra de permissão proposta esteja correta. A revisão do PO antes de salvar continua necessária. + +## Testes e evidências + +| Etapa | Evidência | +| --- | --- | +| Backend | 353 testes passaram, 0 falhas. Novos testes cobrem cenário nomeado e tipado, exigência de caminho principal e de acesso não autorizado, "perfil" e "administrador" como dado do domínio sem exigir permissão, acesso não autorizado com ENTÃO de sucesso recusado, termos de permissão fora da história ignorados, termos de implementação recusados, história incompleta sem chamar o modelo e revisão antiga sem `permission_terms` | +| Serviço de IA | 17 testes do harness passaram, incluindo schema com 1 a 8 cenários, reforço de permissão no prompt e orçamento de tokens | +| Interface | 292 testes passaram. Novos testes cobrem o modo visível só com editor disponível, envio da história e dos cenários salvos, tipos exibidos, cópia para o editor, bloqueio local e recusa do backend por história incompleta, preservação das sugestões ao salvar e formulário em edição não sobrescrito | +| API, PostgreSQL e IA em containers | O script `npm run test:integration:harness` ganhou a verificação da S2-11 (400 sem chamar o modelo e cenários com caminho principal e acesso não autorizado). Ele roda no CI; não foi executado localmente por falta de Docker na máquina de desenvolvimento | + +Assim como na S2-10, os testes usam respostas simuladas do modelo: verificam contrato, regras e preservação da edição, não a qualidade semântica do Ollama real. + +Pendências para fechar o card: + +- capturas de tela da sugestão de cenários (`docs/evidence/S2-11-*.png`) com o modelo local; +- revisão do PR por outra pessoa. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9ec715a..080a57c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -171,8 +171,14 @@ paths: pelo backend no projeto autorizado; não podem ser fornecidos pelo cliente. Levantamento aceita campos do nível e texto_livre. Consulta/recomendação aceitam pergunta. Combinações válidas são levantamento/questionar,padronizar,cenarios; consulta/responder; recomendacao/similares. - Cenários detalhados são exclusivos de PBI. Schemas JSON estritos por operação e nível estão - disponíveis em getHarnessConfiguration. Qualquer falha de geração ou validação retorna 503. + Cenários detalhados são exclusivos de PBI e exigem a história completa (COMO UM, EU QUERO + e PARA QUE); sem ela a resposta é 400 HISTORIA_INCOMPLETA e o modelo não é consultado. + Cada cenário sugerido tem nome, tipo (caminho_principal, acesso_nao_autorizado ou alternativo), + dado, quando e entao. Há sempre um caminho_principal e, quando a história ou as regras citam + um termo de rules.permission_terms, também um acesso_nao_autorizado cujo entao descreve a recusa + (verificação lexical; não valida a regra de permissão em si) (S2-11). Schemas JSON + estritos por operação e nível estão disponíveis em getHarnessConfiguration. Qualquer falha + de geração ou validação retorna 503. security: [{cookieAuth: []}] parameters: - in: path @@ -210,7 +216,23 @@ paths: operation: {type: string} level: {type: string, enum: [epico, feature, pbi]} result: {type: object, description: Saída conforme o schema da operação/nível da revisão} - '400': {description: Solicitação inválida} + '400': + description: > + Solicitação inválida. Em cenarios com história incompleta, code é HISTORIA_INCOMPLETA + e details.campos lista os blocos vazios da história. + content: + application/json: + schema: + type: object + properties: + error: {type: string} + code: {type: string, enum: [VALIDATION_ERROR, HISTORIA_INCOMPLETA]} + details: + type: object + properties: + campos: + type: array + items: {type: string, enum: [historia_como_um, historia_eu_quero, historia_para_que]} '401': {description: Autenticação necessária} '403': {description: Perfil sem permissão} '404': {description: Projeto inexistente ou sem acesso} @@ -3210,6 +3232,15 @@ components: vague_terms: {type: array, maxItems: 40, items: {type: string, minLength: 1, maxLength: 60}} macro_detail_terms: {type: array, minItems: 1, maxItems: 40, items: {type: string, minLength: 1, maxLength: 60}} implementation_terms: {type: array, minItems: 1, maxItems: 40, items: {type: string, minLength: 1, maxLength: 60}} + permission_terms: + type: array + minItems: 1 + maxItems: 40 + items: {type: string, minLength: 1, maxLength: 60} + description: > + Expressões que indicam ação sujeita a permissão na história ou nas regras do PBI. Quando + algum aparece, as sugestões de cenários precisam incluir um acesso_nao_autorizado. + Opcional ao salvar; revisões sem a lista usam o padrão do código. HarnessRevision: type: object required: [mode, version, schema_version, config, created_at, created_by] diff --git a/frontend/src/api/api_copilot.test.ts b/frontend/src/api/api_copilot.test.ts index b34f40f..bff786e 100644 --- a/frontend/src/api/api_copilot.test.ts +++ b/frontend/src/api/api_copilot.test.ts @@ -1,9 +1,11 @@ import { afterEach, expect, it, vi } from "vitest"; import { askCopilot, + missingStoryFields, parseCopilotAnswer, type CopilotLevel, } from "./api_copilot"; +import { ApiError } from "./api_auth"; const answer = { mode: "levantamento", operation: "padronizar", @@ -69,3 +71,51 @@ it("bloqueia conteúdo acima do limite antes de enviá-lo sem truncar silenciosa ).rejects.toThrow("6.000"); expect(fetch).not.toHaveBeenCalled(); }); + +const scenario = { + nome: "Aprovar pedido", + tipo: "caminho_principal", + dado: "que há um pedido pendente", + quando: "o gestor aprova", + entao: "o pedido fica aprovado", +}; +const scenarios = { + mode: "levantamento", + operation: "cenarios", + level: "pbi", + version: 0, + schema_version: 1, + result: { scenarios: [scenario], questions: [] }, +}; +it("PBI-03.1.3: aceita cenários nomeados e tipados", () => { + expect(parseCopilotAnswer(scenarios, "cenarios", "pbi")).toEqual({ + operation: "cenarios", + version: 0, + scenarios: [scenario], + questions: [], + }); +}); +it.each([ + { scenarios: [{ ...scenario, tipo: "outro" }], questions: [] }, + { scenarios: [{ ...scenario, nome: "x".repeat(256) }], questions: [] }, + { scenarios: [{ ...scenario, entao: " " }], questions: [] }, + { scenarios: [{ ...scenario, extra: "x" }], questions: [] }, + { scenarios: [], questions: [] }, + { scenarios: [scenario] }, +])("PBI-03.1.3: rejeita resposta de cenários fora do contrato %#", (result) => { + expect(() => + parseCopilotAnswer({ ...scenarios, result }, "cenarios", "pbi"), + ).toThrow("Sua edição foi preservada"); +}); +it("identifica a recusa por história incompleta e os blocos faltantes", () => { + expect( + missingStoryFields( + new ApiError(400, { + code: "HISTORIA_INCOMPLETA", + details: { campos: ["historia_como_um"] }, + }), + ), + ).toEqual(["historia_como_um"]); + expect(missingStoryFields(new ApiError(400, { code: "VALIDATION_ERROR" }))).toBeNull(); + expect(missingStoryFields(new ApiError(503, {}))).toBeNull(); +}); diff --git a/frontend/src/api/api_copilot.ts b/frontend/src/api/api_copilot.ts index 845c82d..069703f 100644 --- a/frontend/src/api/api_copilot.ts +++ b/frontend/src/api/api_copilot.ts @@ -1,8 +1,8 @@ -import { apiRequest } from "./api_auth"; +import { ApiError, apiRequest } from "./api_auth"; import type { Criterion } from "./api_backlog"; export type CopilotLevel = "epico" | "feature" | "pbi"; -export type CopilotMode = "questionar" | "padronizar"; +export type CopilotMode = "questionar" | "padronizar" | "cenarios"; export const COPILOT_FIELDS = { epico: [ "titulo", @@ -39,6 +39,18 @@ export const DIMENSION_LABELS = { estados_alternativos: "Estados alternativos", interface: "Interface e compatibilidade", } as const; +export const SCENARIO_TYPE_LABELS = { + caminho_principal: "Caminho principal", + acesso_nao_autorizado: "Acesso não autorizado", + alternativo: "Alternativo", +} as const; +export interface ScenarioSuggestion { + nome: string; + tipo: keyof typeof SCENARIO_TYPE_LABELS; + dado: string; + quando: string; + entao: string; +} export type CopilotAnswer = | { operation: "questionar"; @@ -54,6 +66,12 @@ export type CopilotAnswer = version: number; proposal: Record | null; questions: string[]; + } + | { + operation: "cenarios"; + version: number; + scenarios: ScenarioSuggestion[]; + questions: string[]; }; export function copilotFields( @@ -147,6 +165,38 @@ export function parseCopilotAnswer( }); return { operation, version, summary: result.summary, questions }; } + if (operation === "cenarios") { + if ( + !keys(result, ["scenarios", "questions"]) || + !Array.isArray(result.scenarios) || + result.scenarios.length > 30 || + !Array.isArray(result.questions) || + result.questions.length > 30 || + !result.questions.every((item) => text(item)) || + (!result.scenarios.length && !result.questions.length) + ) + throw invalid(); + const scenarios = result.scenarios.map((item) => { + if ( + !record(item) || + !keys(item, ["nome", "tipo", "dado", "quando", "entao"]) || + !text(item.nome, 255) || + typeof item.tipo !== "string" || + !Object.prototype.hasOwnProperty.call(SCENARIO_TYPE_LABELS, item.tipo) || + !text(item.dado) || + !text(item.quando) || + !text(item.entao) + ) + throw invalid(); + return item as unknown as ScenarioSuggestion; + }); + return { + operation, + version, + scenarios, + questions: result.questions as string[], + }; + } if ( !keys(result, ["proposal", "questions"]) || !Array.isArray(result.questions) || @@ -176,6 +226,16 @@ export function parseCopilotAnswer( questions: result.questions as string[], }; } +// O backend recusa cenários sem história completa (PBI-03.1.3, cenário 3). +export function missingStoryFields(error: unknown): string[] | null { + if (!(error instanceof ApiError) || error.status !== 400) return null; + const body = error.details; + if (!record(body) || body.code !== "HISTORIA_INCOMPLETA") return null; + const campos = record(body.details) ? body.details.campos : null; + return Array.isArray(campos) + ? campos.filter((field): field is string => typeof field === "string") + : []; +} export async function askCopilot( projectId: string, level: CopilotLevel, diff --git a/frontend/src/assets/styles/copilot.css b/frontend/src/assets/styles/copilot.css index 7853fb1..8796f24 100644 --- a/frontend/src/assets/styles/copilot.css +++ b/frontend/src/assets/styles/copilot.css @@ -77,7 +77,24 @@ margin: 6px 0 0; white-space: pre-wrap; } +.copilot-scenarios { + padding-left: 24px; +} +.copilot-scenarios > li { + padding-bottom: 16px; + border-bottom: 1px solid var(--border-subtle, #1f2937); +} +.copilot-scenarios > li:last-child { + border-bottom: none; +} +.copilot-scenarios dl { + gap: 8px; + margin-block: 8px 12px; +} @media (max-width: 600px) { + .copilot-scenarios button { + width: 100%; + } .copilot-panel { padding: 14px; } diff --git a/frontend/src/views/backlog/CopilotPanel.tsx b/frontend/src/views/backlog/CopilotPanel.tsx index 8d37c34..6b35d21 100644 --- a/frontend/src/views/backlog/CopilotPanel.tsx +++ b/frontend/src/views/backlog/CopilotPanel.tsx @@ -5,9 +5,12 @@ import { copilotInput, DIMENSION_LABELS, FIELD_LABELS, + missingStoryFields, + SCENARIO_TYPE_LABELS, type CopilotAnswer, type CopilotLevel, type CopilotMode, + type ScenarioSuggestion, } from "../../api/api_copilot"; import type { Criterion } from "../../api/api_backlog"; import { ApiError } from "../../api/api_auth"; @@ -23,7 +26,17 @@ const SLOW_NOTICE_MS = ? configuredSlowMs : 5000; const REQUEST_TIMEOUT_MS = 65000; +const STORY_FIELDS = [ + "historia_como_um", + "historia_eu_quero", + "historia_para_que", +] as const; +const storyBlocks = (fields: readonly string[]) => + fields.map((field) => FIELD_LABELS[field].toUpperCase()).join(", "); function failure(error: unknown) { + const missing = missingStoryFields(error); + if (missing) + return `Complete a história do usuário antes de pedir cenários${missing.length ? ` (falta ${storyBlocks(missing)})` : ""}. Sua edição foi preservada.`; if (error instanceof ApiError) { if (error.status === 401) return "Sua sessão expirou. A análise não foi concluída."; @@ -53,15 +66,24 @@ export function CopilotPanel({ values, criteria = [], disabled = false, + onUseScenario, }: { projectId: string; level: CopilotLevel; values: object; criteria?: Criterion[] | null; disabled?: boolean; + // Sem este callback (PBI ainda não criado ou sem permissão) o modo de cenários não aparece. + onUseScenario?: (scenario: ScenarioSuggestion) => boolean; }) { const [selectedMode, setMode] = useState("questionar"); - const mode = level === "pbi" ? selectedMode : "padronizar"; + const canSuggestScenarios = level === "pbi" && !!onUseScenario; + const mode = + level !== "pbi" + ? "padronizar" + : selectedMode === "cenarios" && !canSuggestScenarios + ? "questionar" + : selectedMode; const radioName = useId(); const [answer, setAnswer] = useState(null); const [busy, setBusy] = useState(false); @@ -70,12 +92,23 @@ export function CopilotPanel({ const [note, setNote] = useState(""); const request = useRef(null); const fields = copilotInput(level, values, criteria); - const draftKey = JSON.stringify({ projectId, level, fields }); + // Em cenários, salvar uma sugestão muda os critérios mas não deve apagar as demais. + const draftKey = JSON.stringify({ + projectId, + level, + fields: mode === "cenarios" ? copilotFields(level, values) : fields, + }); const hasText = Object.values(copilotFields(level, values)).some((value) => value.trim(), ); const waitingCriteria = - level === "pbi" && mode === "questionar" && criteria === null; + level === "pbi" && mode !== "padronizar" && criteria === null; + const missingStory = + mode === "cenarios" + ? STORY_FIELDS.filter( + (field) => !copilotFields(level, values)[field]?.trim(), + ) + : []; useEffect(() => { request.current?.abort(); request.current = null; @@ -88,7 +121,14 @@ export function CopilotPanel({ useEffect(() => () => request.current?.abort(), []); async function analyze() { - if (disabled || waitingCriteria || !hasText || request.current) return; + if ( + disabled || + waitingCriteria || + !hasText || + missingStory.length || + request.current + ) + return; const controller = new AbortController(); request.current = controller; setBusy(true); @@ -165,6 +205,17 @@ export function CopilotPanel({ /> Padronizar texto + {canSuggestScenarios && ( + + )} ) : (

Padronização para {level === "epico" ? "épico" : "feature"}.

@@ -181,6 +232,12 @@ export function CopilotPanel({ na seção de critérios; a edição continua disponível.

)} + {hasText && missingStory.length > 0 && ( +

+ Complete a história do usuário para receber sugestões de cenários. + Falta preencher: {storyBlocks(missingStory)}. +

+ )} {!hasText && (

Preencha ao menos um campo para solicitar apoio. @@ -190,14 +247,22 @@ export function CopilotPanel({ {busy && ( + + ))} + + )} + {answer.questions.length > 0 && ( + <> +

Informações a esclarecer:

+
    + {answer.questions.map((question, index) => ( +
  • {question}
  • + ))} +
+ + )} + + ) : answer.operation === "questionar" ? ( <>

{answer.summary}

{answer.questions.length > 0 ? ( diff --git a/frontend/src/views/backlog/CopilotScenarios.test.tsx b/frontend/src/views/backlog/CopilotScenarios.test.tsx new file mode 100644 index 0000000..6c46cd9 --- /dev/null +++ b/frontend/src/views/backlog/CopilotScenarios.test.tsx @@ -0,0 +1,244 @@ +import { createRef } from "react"; +import { afterEach, expect, it, vi } from "vitest"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { CopilotPanel } from "./CopilotPanel"; +import { CriteriaEditor, type ScenarioDraftHandler } from "./CriteriaView"; +import type { Criterion } from "../../api/api_backlog"; + +const story = { + titulo: "Aprovar pedido", + historia_como_um: "gestor", + historia_eu_quero: "aprovar pedidos com o perfil de administrador", + historia_para_que: "liberar a entrega", + requer_interface: false, +}; +const principal = { + nome: "Aprovar pedido com permissão", + tipo: "caminho_principal", + dado: "que o gestor tem o perfil de administrador", + quando: "ele aprova um pedido pendente", + entao: "o pedido passa a constar como aprovado", +}; +const naoAutorizado = { + nome: "Recusar aprovação sem permissão", + tipo: "acesso_nao_autorizado", + dado: "que o usuário não tem o perfil de administrador", + quando: "ele tenta aprovar um pedido", + entao: "o sistema recusa a ação e o pedido permanece inalterado", +}; +const answer = { + mode: "levantamento", + operation: "cenarios", + level: "pbi", + version: 0, + schema_version: 1, + result: { scenarios: [principal, naoAutorizado], questions: [] }, +}; +const saved: Criterion = { + id: "c1", + entidade_tipo: "pbi", + entidade_id: "b1", + texto: null, + nome: principal.nome, + dado: principal.dado, + quando: principal.quando, + entao: principal.entao, +} as Criterion; +const json = (data: unknown, status = 200) => + Promise.resolve(new Response(JSON.stringify(data), { status })); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +it("PBI-03.1.3: modo de cenários só aparece quando a sugestão pode ir para o editor", () => { + const { rerender } = render( + , + ); + expect(screen.queryByLabelText("Sugerir cenários")).toBeNull(); + rerender( + true} + />, + ); + expect(screen.getByLabelText("Sugerir cenários")).toBeTruthy(); +}); + +it("PBI-03.1.3: sugere cenários DADO/QUANDO/ENTÃO com caminho principal e acesso não autorizado", async () => { + const fetch = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) => + json(answer), + ); + vi.stubGlobal("fetch", fetch); + const used = vi.fn(() => true); + render( + , + ); + fireEvent.click(screen.getByLabelText("Sugerir cenários")); + fireEvent.click(screen.getByRole("button", { name: "Sugerir cenários" })); + const list = await screen.findByRole("complementary", { + name: "Cenários sugeridos", + }); + const body = JSON.parse(String(fetch.mock.calls[0][1]?.body)); + expect(body).toMatchObject({ + mode: "levantamento", + operation: "cenarios", + level: "pbi", + }); + expect(body.fields.historia_eu_quero).toBe(story.historia_eu_quero); + expect(JSON.parse(body.fields.texto_livre).criterios_salvos).toHaveLength(1); + expect(list.textContent).toContain("Caminho principal"); + expect(list.textContent).toContain("Acesso não autorizado"); + expect(list.textContent).toContain(naoAutorizado.entao); + fireEvent.click( + screen.getByRole("button", { + name: `Usar o cenário ${naoAutorizado.nome} no editor de cenários`, + }), + ); + expect(used).toHaveBeenCalledWith(naoAutorizado); + expect(screen.getByText(/copiado para o editor de cenários/)).toBeTruthy(); +}); + +it("PBI-03.1.3: história incompleta pede o preenchimento sem consultar a IA", () => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + render( + true} + />, + ); + fireEvent.click(screen.getByLabelText("Sugerir cenários")); + expect( + screen.getByText(/Falta preencher: COMO UM, PARA QUE/), + ).toBeTruthy(); + const button = screen.getByRole("button", { name: "Sugerir cenários" }); + expect((button as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(button); + expect(fetch).not.toHaveBeenCalled(); +}); + +it("PBI-03.1.3: recusa do backend por história incompleta é explicada sem perder a edição", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => + json( + { + error: "Complete a história", + code: "HISTORIA_INCOMPLETA", + details: { campos: ["historia_eu_quero"] }, + }, + 400, + ), + ), + ); + render( + true} + />, + ); + fireEvent.click(screen.getByLabelText("Sugerir cenários")); + fireEvent.click(screen.getByRole("button", { name: "Sugerir cenários" })); + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("falta EU QUERO"); + expect(alert.textContent).toContain("Sua edição foi preservada"); +}); + +it("salvar um cenário sugerido mantém as demais sugestões; formulário em edição não é sobrescrito", async () => { + vi.stubGlobal("fetch", vi.fn(() => json(answer))); + const { rerender } = render( + false} + />, + ); + fireEvent.click(screen.getByLabelText("Sugerir cenários")); + fireEvent.click(screen.getByRole("button", { name: "Sugerir cenários" })); + await screen.findByRole("complementary", { name: "Cenários sugeridos" }); + fireEvent.click( + screen.getByRole("button", { + name: `Usar o cenário ${principal.nome} no editor de cenários`, + }), + ); + expect(screen.getByText(/Há um cenário em edição no formulário/)).toBeTruthy(); + rerender( + false} + />, + ); + expect( + screen.getByRole("complementary", { name: "Cenários sugeridos" }), + ).toBeTruthy(); +}); + +it("editor de cenários recebe a sugestão para revisão e não sobrescreve edição em andamento", async () => { + vi.stubGlobal("fetch", vi.fn(() => json({ items: [] }))); + const ref = createRef() as { + current: ScenarioDraftHandler | null; + }; + render( + , + ); + await waitFor(() => expect(ref.current).toBeTypeOf("function")); + const draft = { + nome: principal.nome, + dado: principal.dado, + quando: principal.quando, + entao: principal.entao, + }; + let applied = false; + act(() => { + applied = ref.current!(draft); + }); + expect(applied).toBe(true); + expect((screen.getByLabelText(/Nome/) as HTMLInputElement).value).toBe( + draft.nome, + ); + expect( + (document.getElementById("cenario-entao") as HTMLTextAreaElement).value, + ).toBe(draft.entao); + act(() => { + applied = ref.current!({ ...draft, nome: "Outro cenário" }); + }); + expect(applied).toBe(false); + expect((screen.getByLabelText(/Nome/) as HTMLInputElement).value).toBe( + draft.nome, + ); +}); diff --git a/frontend/src/views/backlog/CriteriaView.tsx b/frontend/src/views/backlog/CriteriaView.tsx index b217498..d606cc3 100644 --- a/frontend/src/views/backlog/CriteriaView.tsx +++ b/frontend/src/views/backlog/CriteriaView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type MutableRefObject } from "react"; import { ApiError } from "../../api/api_auth"; import { createCriterion, @@ -25,6 +25,10 @@ const emptyCenario = { entao: "", }; +export type ScenarioDraft = typeof emptyCenario; +// Preenche o formulário de cenário a partir de uma sugestão; false quando há edição em andamento. +export type ScenarioDraftHandler = (draft: ScenarioDraft) => boolean; + export function CriteriaEditor({ entidadeTipo, entidadeId, @@ -33,6 +37,7 @@ export function CriteriaEditor({ itemConcluido = false, justificativaObrigatoria = false, onCriteriaChange, + scenarioDraftRef, }: { entidadeTipo: CriterionEntityType; entidadeId: string; @@ -41,6 +46,7 @@ export function CriteriaEditor({ itemConcluido?: boolean; justificativaObrigatoria?: boolean; onCriteriaChange?: (items: Criterion[]) => void; + scenarioDraftRef?: MutableRefObject; }) { const [result, setResult] = useState({ state: "loading", @@ -107,6 +113,26 @@ export function CriteriaEditor({ const isCenario = entidadeTipo === "pbi"; + const formDirty = formOpen && Object.values(cenarioForm).some((value) => value.trim()); + useEffect(() => { + if (!scenarioDraftRef) return; + scenarioDraftRef.current = (draft) => { + if (!canEdit || !isCenario || formDirty || busy) return false; + setCenarioForm({ ...draft }); + setMessage(""); + setFormOpen(true); + window.setTimeout(() => { + const field = document.getElementById("cenario-nome"); + field?.scrollIntoView?.({ block: "center" }); + field?.focus(); + }); + return true; + }; + return () => { + scenarioDraftRef.current = null; + }; + }, [scenarioDraftRef, canEdit, isCenario, formDirty, busy]); + function recarregar() { setAttempt((value) => value + 1); } diff --git a/frontend/src/views/backlog/PbiDetail.tsx b/frontend/src/views/backlog/PbiDetail.tsx index 45ce2c2..b9b512b 100644 --- a/frontend/src/views/backlog/PbiDetail.tsx +++ b/frontend/src/views/backlog/PbiDetail.tsx @@ -1,5 +1,5 @@ import { CopilotPanel } from "./CopilotPanel"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ApiError } from "../../api/api_auth"; import { camposFaltantesDe, @@ -20,7 +20,7 @@ import { useUnsavedChangesGuard } from "../../viewmodels/useUnsavedChangesGuard" import { Button } from "../common/ui"; import { BacklogBreadcrumb } from "./BacklogBreadcrumb"; import { BacklogTechnologySelector } from "./BacklogTechnologySelector"; -import { CriteriaEditor } from "./CriteriaView"; +import { CriteriaEditor, type ScenarioDraftHandler } from "./CriteriaView"; import { DecisionsPanel } from "./DecisionsPanel"; import { SuggestionsPanel } from "./SuggestionsPanel"; import { ProvenanceBadge } from "./ProvenanceBadge"; @@ -97,6 +97,7 @@ export function PbiDetail({ const [scenarios, setScenarios] = useState(null); + const scenarioDraftRef = useRef(null); const qualityConfiguration = usePbiQualityConfiguration(); @@ -516,7 +517,19 @@ export function PbiDetail({ disabled={saving} /> - + + scenarioDraftRef.current?.({ nome, dado, quando, entao }) ?? false + : undefined + } + /> {justificationRequired && (
@@ -764,6 +777,7 @@ export function PbiDetail({ onCriteriaChange={ setScenarios } + scenarioDraftRef={scenarioDraftRef} />
diff --git a/frontend/src/views/projects/RepoAnalyzerView.test.tsx b/frontend/src/views/projects/RepoAnalyzerView.test.tsx index 38814af..998186d 100644 --- a/frontend/src/views/projects/RepoAnalyzerView.test.tsx +++ b/frontend/src/views/projects/RepoAnalyzerView.test.tsx @@ -67,6 +67,26 @@ it("alterna o relatório entre leitura e Markdown bruto", async () => { expect(screen.getByRole("button", { name: "Markdown" })).toHaveAttribute("aria-pressed", "true"); }); +it("mantém o formato ao atualizar e volta à leitura ao selecionar outra análise", async () => { + vi.stubGlobal("fetch", vi.fn(() => json([ + analysis({ status: "concluido", relatorio_markdown: "# Primeiro" }), + analysis({ id: "a-2", repositorio_url: "https://github.com/acme/outro", status: "concluido", relatorio_markdown: "# Segundo" }), + ]))); + render(); + + await screen.findByRole("heading", { name: "Primeiro" }); + fireEvent.click(screen.getByRole("button", { name: "Markdown" })); + expect(screen.getByRole("button", { name: "Markdown" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(screen.getByRole("button", { name: "Atualizar" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Atualizar" })).toHaveAttribute("aria-busy", "false")); + expect(screen.getByRole("button", { name: "Markdown" })).toHaveAttribute("aria-pressed", "true"); + + const history = screen.getByRole("complementary", { name: "Histórico de análises" }); + fireEvent.click(within(history).getByText("acme/outro")); + expect(await screen.findByRole("heading", { name: "Segundo" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Leitura" })).toHaveAttribute("aria-pressed", "true"); +}); + it("erro ao carregar oferece nova tentativa", async () => { const request = vi.fn() .mockImplementationOnce(() => json({ error: "x" }, 500)) diff --git a/frontend/src/views/projects/RepoAnalyzerView.tsx b/frontend/src/views/projects/RepoAnalyzerView.tsx index 6d2f931..b00c34a 100644 --- a/frontend/src/views/projects/RepoAnalyzerView.tsx +++ b/frontend/src/views/projects/RepoAnalyzerView.tsx @@ -256,6 +256,7 @@ export function RepoAnalyzerView({ projectId, canStart = true }: { projectId: st
{selected ? ( { setMode("preview"); setCopied(false); }, [analysis.id]); - const copy = async () => { try { await navigator.clipboard.writeText(report);