Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion ai-service/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"(?<![^\W_])" + re.escape(term) + r"(?![^\W_])", value, re.IGNORECASE) is not None

def requires_permission_scenario(fields: dict[str, str], rules: dict[str, list[str]]) -> 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]:
Expand Down Expand Up @@ -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):
Expand All @@ -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:
Expand All @@ -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)
Expand Down
67 changes: 66 additions & 1 deletion ai-service/tests/fixtures/harness.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -345,6 +358,8 @@
}
},
"required": [
"nome",
"tipo",
"dado",
"quando",
"entao"
Expand Down Expand Up @@ -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,
Expand All @@ -394,6 +422,8 @@
}
},
"required": [
"nome",
"tipo",
"dado",
"quando",
"entao"
Expand Down Expand Up @@ -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,
Expand All @@ -443,6 +486,8 @@
}
},
"required": [
"nome",
"tipo",
"dado",
"quando",
"entao"
Expand Down Expand Up @@ -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": {
Expand All @@ -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"
]
}
}
Expand Down
30 changes: 30 additions & 0 deletions ai-service/tests/test_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
51 changes: 51 additions & 0 deletions backend/scripts/validate-harness.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand Down
65 changes: 61 additions & 4 deletions backend/src/modules/harness/harness.contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
/(?<![\p{L}\p{N}])(?:recus|rejeit|bloque|impede|impedid|proib|nega(?:d[oa]s?|r)?(?![\p{L}])|acesso negado|sem permissão|não (?:é |são |foi |será )?(?:permitid|autorizad|concluíd|realizad|alterad)|não (?:pode|consegue|tem permissão)|permanece(?:m)? inalterad|mensagem de (?:erro|permissão|acesso))/iu;
const text = z.string().trim().min(1).max(12000);
export const rulesSchema = z
.object({
Expand All @@ -47,6 +79,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
Expand All @@ -57,7 +95,7 @@ export const configSchema = z
rules: rulesSchema,
})
.strict();
export type HarnessConfig = z.infer<typeof configSchema>;
export type HarnessConfig = z.output<typeof configSchema>;
export interface HarnessRevision {
mode: HarnessMode;
version: number;
Expand Down Expand Up @@ -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:
Expand All @@ -123,6 +161,7 @@ export function defaultConfig(mode: HarnessMode): HarnessConfig {
"docker",
"tabela no banco",
],
permission_terms: [...PERMISSION_TERMS],
},
};
}
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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,
})
Expand Down
Loading
Loading