From 7359d01c516157a84efb9b694b162fc9104419a3 Mon Sep 17 00:00:00 2001
From: Garakis
Date: Tue, 20 Oct 2026 12:14:29 -0300
Subject: [PATCH 1/3] =?UTF-8?q?feat(copilot):=20sugerir=20cen=C3=A1rios=20?=
=?UTF-8?q?de=20aceita=C3=A7=C3=A3o=20a=20partir=20da=20hist=C3=B3ria=20(S?=
=?UTF-8?q?2-11)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Implementa o PBI-03.1.3 sobre a operação cenarios do harness da S2-09.
- Cenários sugeridos passam a ter nome e tipo (caminho_principal,
acesso_nao_autorizado ou alternativo).
- Histórias que citam rules.permission_terms exigem um cenário de acesso
não autorizado; toda resposta exige um caminho principal.
- História incompleta responde 400 HISTORIA_INCOMPLETA sem chamar o modelo.
- Painel do copiloto ganha o modo "Sugerir cenários"; "Usar no editor"
preenche o formulário de cenário para o PO revisar e salvar.
- Revisões antigas do harness recebem os termos de permissão padrão.
---
ai-service/harness.py | 20 +-
ai-service/tests/fixtures/harness.json | 61 ++++-
ai-service/tests/test_harness.py | 30 +++
backend/scripts/validate-harness.mts | 51 ++++
.../src/modules/harness/harness.contract.ts | 53 +++-
.../modules/harness/harness.service.test.ts | 177 +++++++++++++
.../src/modules/harness/harness.service.ts | 65 ++++-
docs/S2-09-HARNESS.md | 2 +-
docs/S2-11-CENARIOS.md | 49 ++++
docs/api/openapi.yaml | 36 ++-
frontend/src/api/api_copilot.test.ts | 50 ++++
frontend/src/api/api_copilot.ts | 64 ++++-
frontend/src/assets/styles/copilot.css | 17 ++
frontend/src/views/backlog/CopilotPanel.tsx | 147 ++++++++++-
.../views/backlog/CopilotScenarios.test.tsx | 244 ++++++++++++++++++
frontend/src/views/backlog/CriteriaView.tsx | 28 +-
frontend/src/views/backlog/PbiDetail.tsx | 20 +-
17 files changed, 1087 insertions(+), 27 deletions(-)
create mode 100644 docs/S2-11-CENARIOS.md
create mode 100644 frontend/src/views/backlog/CopilotScenarios.test.tsx
diff --git a/ai-service/harness.py b/ai-service/harness.py
index b459725..f182152 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."
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..b29d9c2 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,20 @@
"react",
"docker",
"tabela no banco"
+ ],
+ "permission_terms": [
+ "permissão",
+ "permissões",
+ "autorização",
+ "autorizado",
+ "autorizada",
+ "autorizados",
+ "perfil",
+ "perfis",
+ "administrador",
+ "administradores",
+ "proprietário",
+ "proprietária"
]
}
}
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..0f35c3b 100644
--- a/backend/src/modules/harness/harness.contract.ts
+++ b/backend/src/modules/harness/harness.contract.ts
@@ -35,6 +35,26 @@ export const FIELDS = {
"regras_observacoes",
],
} as const;
+export const SCENARIO_TYPES = [
+ "caminho_principal",
+ "acesso_nao_autorizado",
+ "alternativo",
+] as const;
+// Termos que indicam ação sujeita a permissão (PBI-03.1.3, cenário 2).
+export const PERMISSION_TERMS = [
+ "permissão",
+ "permissões",
+ "autorização",
+ "autorizado",
+ "autorizada",
+ "autorizados",
+ "perfil",
+ "perfis",
+ "administrador",
+ "administradores",
+ "proprietário",
+ "proprietária",
+] as const;
const text = z.string().trim().min(1).max(12000);
export const rulesSchema = z
.object({
@@ -47,6 +67,12 @@ export const rulesSchema = z
.array(z.string().trim().min(1).max(60))
.min(1)
.max(40),
+ // Revisões salvas antes da S2-11 não têm a lista e recebem o padrão.
+ permission_terms: z
+ .array(z.string().trim().min(1).max(60))
+ .min(1)
+ .max(40)
+ .default(() => [...PERMISSION_TERMS]),
})
.strict();
export const configSchema = z
@@ -57,7 +83,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 +127,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 +149,7 @@ export function defaultConfig(mode: HarnessMode): HarnessConfig {
"docker",
"tabela no banco",
],
+ permission_terms: [...PERMISSION_TERMS],
},
};
}
@@ -167,7 +194,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 +254,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..2380f9b 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,179 @@ 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") => ({
+ 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);
+ 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á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..e65db18 100644
--- a/backend/src/modules/harness/harness.service.ts
+++ b/backend/src/modules/harness/harness.service.ts
@@ -2,7 +2,10 @@ 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";
@@ -79,6 +82,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 +105,64 @@ 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");
+ 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 +205,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..cc42251
--- /dev/null
+++ b/docs/S2-11-CENARIOS.md
@@ -0,0 +1,49 @@
+# 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 um termo de `rules.permission_terms` (por exemplo perfil, permissão, administrador, proprietário), a resposta também precisa ter um `acesso_nao_autorizado`. 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.
+
+## Testes e evidências
+
+| Etapa | Evidência |
+| --- | --- |
+| Backend | 352 testes passaram, 0 falhas. Novos testes cobrem cenário nomeado e tipado, exigência de caminho principal e de acesso não autorizado, 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..b444d44 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -171,8 +171,13 @@ 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 (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 +215,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 +3231,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: >
+ Termos 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 && (