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
7 changes: 7 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import qualityRouter from "./modules/quality/quality.routes.js";
import { epicsCompatRouter } from "./modules/epics/epics.compat.routes.js";
import { repoAnalysesRouter } from './modules/repo-analyses/repo-analyses.routes';
import { epicDecisionsRouter, featureDecisionsRouter, pbiDecisionsRouter, projectDecisionsRouter } from "./modules/decisions/decisions.routes.js";
import { epicSuggestionsRouter, featureSuggestionsRouter, pbiSuggestionsRouter } from "./modules/suggestions/suggestions.routes.js";
import { backlogSearchRouter } from "./modules/backlog-search/backlog-search.routes.js";
import { documentsRouter } from "./modules/documents/documents.routes.js";
import { chunksRouter } from "./modules/documents/chunks.routes.js";
Expand Down Expand Up @@ -93,6 +94,11 @@ app.use("/api/v1/epics/:entityId/decisions", epicDecisionsRouter);
app.use("/api/v1/features/:entityId/decisions", featureDecisionsRouter);
app.use("/api/v1/pbis/:entityId/decisions", pbiDecisionsRouter);

// Ciclo humano de sugestões da IA e proveniência por campo (S2-13)
app.use("/api/v1/epics/:entityId/suggestions", epicSuggestionsRouter);
app.use("/api/v1/features/:entityId/suggestions", featureSuggestionsRouter);
app.use("/api/v1/pbis/:entityId/suggestions", pbiSuggestionsRouter);

// Busca textual no backlog (S1-17)
app.use("/api/v1/projects/:projectId/backlog-search", backlogSearchRouter);

Expand All @@ -118,6 +124,7 @@ app.get("/api/v1", (_req: Request, res: Response) => {
{ name: "pbis", status: "ready" },
{ name: "criteria", status: "ready" },
{ name: "decisions", status: "in_development" },
{ name: "suggestions", status: "ready" },
{ name: "ai-bridge", status: "ready" },
],
});
Expand Down
72 changes: 72 additions & 0 deletions backend/src/database/migration-014.db.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import test from "node:test";
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { validateTarget } from "./seed-lib.js";
import { applyUntil, loadMigration, migrationFiles, withDatabase } from "./migration-test-utils.js";

const SUGGESTIONS_MIGRATION = "014_suggestions_and_field_provenance.sql";

test("migration 014 vem depois da 013 e é a única com esse número", { skip: !process.env.ARCHIVE_TEST_DATABASE_URL }, async () => {
const files = await migrationFiles();
assert.ok(files.indexOf(SUGGESTIONS_MIGRATION) > files.indexOf("013_decision_records.sql"));
assert.equal(files.filter((file) => file.startsWith("014_")).length, 1);
});

test("migration 014 cria provenance_json e sugestao_ia, preserva dados legados e é idempotente", { skip: !process.env.ARCHIVE_TEST_DATABASE_URL }, async () => {
await withDatabase(validateTarget(process.env.ARCHIVE_TEST_DATABASE_URL, "test"), async (client, vectorAvailable) => {
await applyUntil(client, vectorAvailable, (file) => file === SUGGESTIONS_MIGRATION);

const project = randomUUID();
const epic = randomUUID();
await client.query("INSERT INTO projeto (id,nome,cliente,status) VALUES ($1,$2,'T','ativo')", [project, project]);
await client.query("INSERT INTO epico (id,projeto_id,titulo) VALUES ($1,$2,'Épico legado')", [epic, project]);

await applyUntil(client, vectorAvailable, () => false);

const epicRow = (await client.query("SELECT titulo, provenance_json FROM epico WHERE id=$1", [epic])).rows[0];
assert.equal(epicRow.titulo, "Épico legado", "linhas legadas são preservadas");
assert.deepEqual(epicRow.provenance_json, {}, "proveniência default é vazia, implicando human-authored");

const columns = (await client.query(
"SELECT table_name, column_name FROM information_schema.columns WHERE table_name IN ('epico','feature','pbi') AND column_name='provenance_json'",
)).rows.map((row) => row.table_name);
assert.deepEqual(columns.sort(), ["epico", "feature", "pbi"]);

const columnDefault = (await client.query(
"SELECT is_nullable, column_default FROM information_schema.columns WHERE table_name='pbi' AND column_name='provenance_json'",
)).rows[0];
assert.equal(columnDefault.is_nullable, "NO");

const suggestion = randomUUID();
await client.query(
`INSERT INTO sugestao_ia (id, entidade_tipo, entidade_id, campo, valor_sugerido) VALUES ($1,'epico',$2,'titulo','Novo título sugerido')`,
[suggestion, epic],
);
await assert.rejects(
client.query(`INSERT INTO sugestao_ia (entidade_tipo, entidade_id, campo, valor_sugerido) VALUES ('documento',$1,'titulo','x')`, [randomUUID()]),
/ck_sugestao_ia_entidade_tipo/,
);
await assert.rejects(
client.query(`INSERT INTO sugestao_ia (entidade_tipo, entidade_id, campo, valor_sugerido, status) VALUES ('epico',$1,'titulo','x','aceita')`, [randomUUID()]),
/ck_sugestao_ia_resolucao/,
"status resolvido exige resolvido_em/resolvido_por preenchidos",
);
await assert.rejects(
client.query(
`INSERT INTO sugestao_ia (entidade_tipo, entidade_id, campo, valor_sugerido) VALUES ('epico',$1,'titulo','duplicada')`,
[epic],
),
/uq_sugestao_ia_pendente/,
"duas propostas pendentes para o mesmo campo violam o índice único parcial",
);

const migration = await loadMigration(SUGGESTIONS_MIGRATION, vectorAvailable);
await client.query(migration);
await client.query(migration);

assert.equal((await client.query("SELECT count(*)::int AS n FROM sugestao_ia")).rows[0].n, 1, "reaplicar a migração não duplica ou remove linhas");
const indexes = (await client.query("SELECT indexname FROM pg_indexes WHERE tablename='sugestao_ia'")).rows.map((row) => row.indexname);
assert.ok(indexes.includes("uq_sugestao_ia_pendente"));
assert.ok(indexes.includes("idx_sugestao_ia_entidade"));
});
});
16 changes: 11 additions & 5 deletions backend/src/modules/epics/epics.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { auditService } from "../audit/audit.service.js";
import { assertWritable, lockHierarchy } from "../projects/hierarchy-archive.js";
import { assertJustificationForCompletedItem } from "../quality/completed-item-policy.js";
import { getEntityTechnologyIds, replaceEntityTechnologies } from "../technologies/entity-technologies.js";
import { markFieldsHumanAuthored } from "../provenance/provenance.js";
import { EPIC_REQUIRED_FIELDS, CreateEpicDTO, Epic, EpicQueryDTO, EpicWithStats, PaginatedEpics, UpdateEpicDTO } from "./epics.types.js";

export class EpicsRepository {
Expand Down Expand Up @@ -144,13 +145,14 @@ export class EpicsRepository {

const updates: string[] = [];
const values: unknown[] = [];
const changedFields: string[] = [];
let valIndex = 1;

if (data.titulo !== undefined) { updates.push(`titulo = $${valIndex}`); values.push(data.titulo.trim()); valIndex++; }
if (data.descricao !== undefined) { updates.push(`descricao = $${valIndex}`); values.push(data.descricao?.trim() ?? null); valIndex++; }
if (data.objetivo !== undefined) { updates.push(`objetivo = $${valIndex}`); values.push(data.objetivo?.trim() ?? null); valIndex++; }
if (data.escopo_macro !== undefined) { updates.push(`escopo_macro = $${valIndex}`); values.push(data.escopo_macro?.trim() ?? null); valIndex++; }
if (data.resultado_esperado !== undefined) { updates.push(`resultado_esperado = $${valIndex}`); values.push(data.resultado_esperado?.trim() ?? null); valIndex++; }
if (data.titulo !== undefined) { updates.push(`titulo = $${valIndex}`); values.push(data.titulo.trim()); valIndex++; changedFields.push("titulo"); }
if (data.descricao !== undefined) { updates.push(`descricao = $${valIndex}`); values.push(data.descricao?.trim() ?? null); valIndex++; changedFields.push("descricao"); }
if (data.objetivo !== undefined) { updates.push(`objetivo = $${valIndex}`); values.push(data.objetivo?.trim() ?? null); valIndex++; changedFields.push("objetivo"); }
if (data.escopo_macro !== undefined) { updates.push(`escopo_macro = $${valIndex}`); values.push(data.escopo_macro?.trim() ?? null); valIndex++; changedFields.push("escopo_macro"); }
if (data.resultado_esperado !== undefined) { updates.push(`resultado_esperado = $${valIndex}`); values.push(data.resultado_esperado?.trim() ?? null); valIndex++; changedFields.push("resultado_esperado"); }
if (data.prioridade !== undefined) { updates.push(`prioridade = $${valIndex}`); values.push(data.prioridade); valIndex++; }

updates.push(`updated_at = CURRENT_TIMESTAMP`);
Expand All @@ -161,6 +163,10 @@ export class EpicsRepository {
values,
);
const updated = result.rows[0];
// A human submitted this edit through the ordinary update endpoint: any
// changed free-text field is explicitly human-authored from now on,
// overriding whatever AI provenance it may have had before (S2-13).
await markFieldsHumanAuthored(client, "epico", id, changedFields);
await replaceEntityTechnologies(client, "epico", id, data.tecnologias_ids);
const updatedWithTechnologies = {
...updated,
Expand Down
1 change: 1 addition & 0 deletions backend/src/modules/epics/epics.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export interface Epic {
tecnologias_ids?: string[];
prioridade: EpicPriority;
status: EpicStatus;
provenance_json?: Record<string, string>;
created_at: Date | string;
updated_at: Date | string;
}
Expand Down
12 changes: 9 additions & 3 deletions backend/src/modules/features/features.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { auditService } from "../audit/audit.service.js";
import { assertWritable, lockHierarchy } from "../projects/hierarchy-archive.js";
import { assertJustificationForCompletedItem } from "../quality/completed-item-policy.js";
import { getEntityTechnologyIds, replaceEntityTechnologies } from "../technologies/entity-technologies.js";
import { markFieldsHumanAuthored } from "../provenance/provenance.js";
import { FEATURE_REQUIRED_FIELDS, CreateFeatureDTO, Feature, FeatureQueryDTO, FeatureWithStats, PaginatedFeatures, UpdateFeatureDTO } from "./features.types.js";

const SELECT_WITH_STATS = `
Expand Down Expand Up @@ -139,11 +140,12 @@ export class FeaturesRepository {

const updates: string[] = [];
const values: unknown[] = [];
const changedFields: string[] = [];
let valIndex = 1;

if (data.titulo !== undefined) { updates.push(`titulo = $${valIndex}`); values.push(data.titulo.trim()); valIndex++; }
if (data.descricao !== undefined) { updates.push(`descricao = $${valIndex}`); values.push(data.descricao?.trim() ?? null); valIndex++; }
if (data.objetivo !== undefined) { updates.push(`objetivo = $${valIndex}`); values.push(data.objetivo?.trim() ?? null); valIndex++; }
if (data.titulo !== undefined) { updates.push(`titulo = $${valIndex}`); values.push(data.titulo.trim()); valIndex++; changedFields.push("titulo"); }
if (data.descricao !== undefined) { updates.push(`descricao = $${valIndex}`); values.push(data.descricao?.trim() ?? null); valIndex++; changedFields.push("descricao"); }
if (data.objetivo !== undefined) { updates.push(`objetivo = $${valIndex}`); values.push(data.objetivo?.trim() ?? null); valIndex++; changedFields.push("objetivo"); }
if (data.prioridade !== undefined) { updates.push(`prioridade = $${valIndex}`); values.push(data.prioridade); valIndex++; }

updates.push(`updated_at = CURRENT_TIMESTAMP`);
Expand All @@ -154,6 +156,10 @@ export class FeaturesRepository {
values,
);
const updated = result.rows[0];
// A human submitted this edit through the ordinary update endpoint: any
// changed free-text field is explicitly human-authored from now on,
// overriding whatever AI provenance it may have had before (S2-13).
await markFieldsHumanAuthored(client, "feature", id, changedFields);
await replaceEntityTechnologies(client, "feature", id, data.tecnologias_ids);
const updatedWithTechnologies = {
...updated,
Expand Down
1 change: 1 addition & 0 deletions backend/src/modules/features/features.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export interface Feature {
tecnologias_ids?: string[];
prioridade: FeaturePriority;
status: FeatureStatus;
provenance_json?: Record<string, string>;
created_at: Date | string;
updated_at: Date | string;
}
Expand Down
16 changes: 11 additions & 5 deletions backend/src/modules/pbis/pbis.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { auditService } from "../audit/audit.service.js";
import { assertWritable, lockHierarchy } from "../projects/hierarchy-archive.js";
import { assertJustificationForCompletedItem, normalizeJustification } from "../quality/completed-item-policy.js";
import { getEntityTechnologyIds, replaceEntityTechnologies } from "../technologies/entity-technologies.js";
import { markFieldsHumanAuthored } from "../provenance/provenance.js";
import { CreatePbiDTO, PaginatedPbis, Pbi, PbiQueryDTO, PbiWithContext, UpdatePbiDTO } from "./pbis.types.js";

const SELECT_WITH_CONTEXT = `
Expand Down Expand Up @@ -148,13 +149,14 @@ export class PbisRepository {

const updates: string[] = [];
const values: unknown[] = [];
const changedFields: string[] = [];
let valIndex = 1;

if (data.titulo !== undefined) { updates.push(`titulo = $${valIndex}`); values.push(data.titulo.trim()); valIndex++; }
if (data.historia_como_um !== undefined) { updates.push(`historia_como_um = $${valIndex}`); values.push(data.historia_como_um.trim()); valIndex++; }
if (data.historia_eu_quero !== undefined) { updates.push(`historia_eu_quero = $${valIndex}`); values.push(data.historia_eu_quero.trim()); valIndex++; }
if (data.historia_para_que !== undefined) { updates.push(`historia_para_que = $${valIndex}`); values.push(data.historia_para_que.trim()); valIndex++; }
if (data.regras_observacoes !== undefined) { updates.push(`regras_observacoes = $${valIndex}`); values.push(data.regras_observacoes?.trim() ?? null); valIndex++; }
if (data.titulo !== undefined) { updates.push(`titulo = $${valIndex}`); values.push(data.titulo.trim()); valIndex++; changedFields.push("titulo"); }
if (data.historia_como_um !== undefined) { updates.push(`historia_como_um = $${valIndex}`); values.push(data.historia_como_um.trim()); valIndex++; changedFields.push("historia_como_um"); }
if (data.historia_eu_quero !== undefined) { updates.push(`historia_eu_quero = $${valIndex}`); values.push(data.historia_eu_quero.trim()); valIndex++; changedFields.push("historia_eu_quero"); }
if (data.historia_para_que !== undefined) { updates.push(`historia_para_que = $${valIndex}`); values.push(data.historia_para_que.trim()); valIndex++; changedFields.push("historia_para_que"); }
if (data.regras_observacoes !== undefined) { updates.push(`regras_observacoes = $${valIndex}`); values.push(data.regras_observacoes?.trim() ?? null); valIndex++; changedFields.push("regras_observacoes"); }
if (data.tipo !== undefined) { updates.push(`tipo = $${valIndex}`); values.push(data.tipo); valIndex++; }
if (data.prioridade !== undefined) { updates.push(`prioridade = $${valIndex}`); values.push(data.prioridade); valIndex++; }
if (data.requer_interface !== undefined) { updates.push(`requer_interface = $${valIndex}`); values.push(data.requer_interface); valIndex++; }
Expand All @@ -167,6 +169,10 @@ export class PbisRepository {
values,
);
const updated = result.rows[0];
// A human submitted this edit through the ordinary update endpoint: any
// changed free-text field is explicitly human-authored from now on,
// overriding whatever AI provenance it may have had before (S2-13).
await markFieldsHumanAuthored(client, "pbi", id, changedFields);
await replaceEntityTechnologies(client, "pbi", id, data.tecnologias_ids);
const updatedWithTechnologies = {
...updated,
Expand Down
1 change: 1 addition & 0 deletions backend/src/modules/pbis/pbis.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export interface Pbi {
status: PbiStatus;
score_completude: number | null;
provenance: string;
provenance_json?: Record<string, string>;
created_at: Date | string;
updated_at: Date | string;
}
Expand Down
55 changes: 55 additions & 0 deletions backend/src/modules/provenance/provenance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { PoolClient } from "pg";

// S2-13: proveniência por campo. "human-authored" é o padrão implícito de
// qualquer campo ausente do mapa — nunca precisa ser escrito explicitamente
// por uma edição manual comum, apenas quando uma sugestão de IA é resolvida.
export type ProvenanceValue = "human-authored" | "ai-accepted" | "ai-edited";

export type SuggestibleEntityType = "epico" | "feature" | "pbi";

export const SUGGESTIBLE_ENTITY_TYPES: readonly SuggestibleEntityType[] = ["epico", "feature", "pbi"];

// Somente campos de texto livre do guia de especificação aceitam sugestão;
// enums (status, prioridade, tipo), listas (tecnologias_ids) e campos
// derivados (score, contadores) ficam fora do ciclo de sugestão da IA.
export const SUGGESTIBLE_FIELDS: Record<SuggestibleEntityType, readonly string[]> = {
epico: ["titulo", "descricao", "objetivo", "escopo_macro", "resultado_esperado"],
feature: ["titulo", "descricao", "objetivo"],
pbi: ["titulo", "historia_como_um", "historia_eu_quero", "historia_para_que", "regras_observacoes"],
};

const MAX_FIELD_LENGTH: Record<string, number> = {
titulo: 255,
};
const DEFAULT_MAX_LENGTH = 5000;

export function maxLengthFor(field: string): number {
return MAX_FIELD_LENGTH[field] ?? DEFAULT_MAX_LENGTH;
}

export function isSuggestibleField(kind: SuggestibleEntityType, field: string): boolean {
return SUGGESTIBLE_FIELDS[kind].includes(field);
}

/**
* Marks the given fields as human-authored on the entity's provenance map.
* Called from the ordinary update() of epics/features/pbis, inside the same
* transaction, right after the field values themselves are written — a plain
* edit by a person always overrides any earlier AI provenance for that field.
* Fields outside the suggestible allow-list (status, prioridade, etc.) are
* silently ignored: provenance only tracks free-text specification fields.
*/
export async function markFieldsHumanAuthored(
client: PoolClient,
kind: SuggestibleEntityType,
id: string,
fields: readonly string[],
): Promise<void> {
const relevant = fields.filter((field) => isSuggestibleField(kind, field));
if (relevant.length === 0) return;
const patch: Record<string, ProvenanceValue> = Object.fromEntries(relevant.map((field) => [field, "human-authored"]));
await client.query(
`UPDATE ${kind} SET provenance_json = COALESCE(provenance_json, '{}'::jsonb) || $2::jsonb WHERE id = $1`,
[id, JSON.stringify(patch)],
);
}
Loading
Loading