From 3a7bfcf59b190490d2ab3e227929b971538c618c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vitor=20Hugo=20Belorio=20Sim=C3=A3o?= Date: Mon, 6 Apr 2026 14:42:32 -0300 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(admin):=20alinhar=20esteira=20?= =?UTF-8?q?governada=20de=20propostas=20e=20iteracoes=20de=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementa a nova semantica da esteira administrativa para propostas de tools, separando triagem humana, execucao da pipeline, revisao de codigo e ativacao governada. Tambem consolida iteracoes de geracao na mesma versao funcional, conecta refatoracao guiada por feedback da diretoria e adiciona compatibilidade com artefatos legados de revisao, aprovacao e source publicado no runtime. --- admin_app/api/routes/panel_tools.py | 122 +++ admin_app/api/routes/tools.py | 124 +++ admin_app/api/schemas.py | 33 + admin_app/db/models/tool_artifact.py | 3 + admin_app/services/tool_generation_service.py | 39 + admin_app/services/tool_management_service.py | 777 ++++++++++++++++-- admin_app/view/rendering.py | 42 +- admin_app/view/router.py | 56 +- admin_app/view/static/scripts/panel.js | 162 +++- tests/test_admin_panel_tools_web.py | 131 ++- tests/test_admin_tool_management_service.py | 697 +++++++++++++++- tests/test_admin_tools_web.py | 26 +- tests/test_admin_view_bootstrap.py | 14 +- 13 files changed, 2080 insertions(+), 146 deletions(-) diff --git a/admin_app/api/routes/panel_tools.py b/admin_app/api/routes/panel_tools.py index 109976b..abf623e 100644 --- a/admin_app/api/routes/panel_tools.py +++ b/admin_app/api/routes/panel_tools.py @@ -15,6 +15,7 @@ from admin_app.api.schemas import ( AdminToolGovernanceTransitionResponse, AdminToolManagementActionResponse, AdminToolOverviewResponse, + AdminToolOptionalGovernanceDecisionRequest, AdminToolPublicationListResponse, AdminToolReviewDecisionRequest, AdminToolReviewDetailResponse, @@ -154,6 +155,66 @@ def panel_tool_pipeline_run( return _build_pipeline_response(payload) +@router.post( + "/drafts/{version_id}/authorize-generation", + response_model=AdminToolGovernanceTransitionResponse, +) +def panel_tool_draft_authorize_generation( + version_id: str, + decision: AdminToolGovernanceDecisionRequest, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_panel_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS) + ), +): + try: + payload = service.authorize_generation( + version_id, + actor_staff_account_id=current_staff.id, + actor_name=current_staff.display_name, + actor_role=current_staff.role, + decision_notes=decision.decision_notes, + ) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + return _build_governance_transition_response(payload) + + +@router.post( + "/drafts/{version_id}/close", + response_model=AdminToolGovernanceTransitionResponse, +) +def panel_tool_draft_close( + version_id: str, + decision: AdminToolOptionalGovernanceDecisionRequest, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_panel_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS) + ), +): + try: + payload = service.close_proposal( + version_id, + actor_staff_account_id=current_staff.id, + actor_name=current_staff.display_name, + actor_role=current_staff.role, + decision_notes=decision.decision_notes, + ) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + return _build_governance_transition_response(payload) + + @router.get( "/review-queue", response_model=AdminToolReviewQueueResponse, @@ -224,6 +285,66 @@ def panel_tool_review_queue_review( return _build_governance_transition_response(payload) +@router.post( + "/review-queue/{version_id}/request-changes", + response_model=AdminToolGovernanceTransitionResponse, +) +def panel_tool_review_queue_request_changes( + version_id: str, + decision: AdminToolGovernanceDecisionRequest, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_panel_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS) + ), +): + try: + payload = service.request_changes( + version_id, + actor_staff_account_id=current_staff.id, + actor_name=current_staff.display_name, + actor_role=current_staff.role, + decision_notes=decision.decision_notes, + ) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + return _build_governance_transition_response(payload) + + +@router.post( + "/review-queue/{version_id}/close", + response_model=AdminToolGovernanceTransitionResponse, +) +def panel_tool_review_queue_close( + version_id: str, + decision: AdminToolOptionalGovernanceDecisionRequest, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_panel_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS) + ), +): + try: + payload = service.close_proposal( + version_id, + actor_staff_account_id=current_staff.id, + actor_name=current_staff.display_name, + actor_role=current_staff.role, + decision_notes=decision.decision_notes, + ) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + return _build_governance_transition_response(payload) + + @router.post( "/review-queue/{version_id}/approve", response_model=AdminToolGovernanceTransitionResponse, @@ -414,6 +535,7 @@ def _build_review_detail_response(payload: dict) -> AdminToolReviewDetailRespons generated_callable=payload["generated_callable"], generated_source_code=payload["generated_source_code"], execution=payload.get("execution"), + generation_context=payload["generation_context"], human_gate=payload["human_gate"], decision_history=payload["decision_history"], next_steps=payload["next_steps"], diff --git a/admin_app/api/routes/tools.py b/admin_app/api/routes/tools.py index 550555d..de9d8d5 100644 --- a/admin_app/api/routes/tools.py +++ b/admin_app/api/routes/tools.py @@ -15,6 +15,7 @@ from admin_app.api.schemas import ( AdminToolGovernanceTransitionResponse, AdminToolManagementActionResponse, AdminToolOverviewResponse, + AdminToolOptionalGovernanceDecisionRequest, AdminToolPublicationListResponse, AdminToolReviewDecisionRequest, AdminToolReviewDetailResponse, @@ -24,6 +25,8 @@ from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal from admin_app.services import ToolManagementService from shared.contracts import AdminPermission, StaffRole, role_has_permission +# API de intake (processo de captação e triagem inicial de demandas, requisitos ou solicitações antes do início efetivo do projeto), pipeline, review, publish, deactivate e rollback de tools. + router = APIRouter(prefix="/tools", tags=["tools"]) @@ -154,6 +157,66 @@ def tool_pipeline_run( return _build_pipeline_response(payload) +@router.post( + "/drafts/{version_id}/authorize-generation", + response_model=AdminToolGovernanceTransitionResponse, +) +def tool_draft_authorize_generation( + version_id: str, + decision: AdminToolGovernanceDecisionRequest, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS) + ), +): + try: + payload = service.authorize_generation( + version_id, + actor_staff_account_id=current_staff.id, + actor_name=current_staff.display_name, + actor_role=current_staff.role, + decision_notes=decision.decision_notes, + ) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + return _build_governance_transition_response(payload) + + +@router.post( + "/drafts/{version_id}/close", + response_model=AdminToolGovernanceTransitionResponse, +) +def tool_draft_close( + version_id: str, + decision: AdminToolOptionalGovernanceDecisionRequest, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS) + ), +): + try: + payload = service.close_proposal( + version_id, + actor_staff_account_id=current_staff.id, + actor_name=current_staff.display_name, + actor_role=current_staff.role, + decision_notes=decision.decision_notes, + ) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + return _build_governance_transition_response(payload) + + @router.get( "/review-queue", response_model=AdminToolReviewQueueResponse, @@ -224,6 +287,66 @@ def tool_review_queue_review( return _build_governance_transition_response(payload) +@router.post( + "/review-queue/{version_id}/request-changes", + response_model=AdminToolGovernanceTransitionResponse, +) +def tool_review_queue_request_changes( + version_id: str, + decision: AdminToolGovernanceDecisionRequest, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS) + ), +): + try: + payload = service.request_changes( + version_id, + actor_staff_account_id=current_staff.id, + actor_name=current_staff.display_name, + actor_role=current_staff.role, + decision_notes=decision.decision_notes, + ) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + return _build_governance_transition_response(payload) + + +@router.post( + "/review-queue/{version_id}/close", + response_model=AdminToolGovernanceTransitionResponse, +) +def tool_review_queue_close( + version_id: str, + decision: AdminToolOptionalGovernanceDecisionRequest, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS) + ), +): + try: + payload = service.close_proposal( + version_id, + actor_staff_account_id=current_staff.id, + actor_name=current_staff.display_name, + actor_role=current_staff.role, + decision_notes=decision.decision_notes, + ) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + return _build_governance_transition_response(payload) + + @router.post( "/review-queue/{version_id}/approve", response_model=AdminToolGovernanceTransitionResponse, @@ -414,6 +537,7 @@ def _build_review_detail_response(payload: dict) -> AdminToolReviewDetailRespons generated_callable=payload["generated_callable"], generated_source_code=payload["generated_source_code"], execution=payload.get("execution"), + generation_context=payload["generation_context"], human_gate=payload["human_gate"], decision_history=payload["decision_history"], next_steps=payload["next_steps"], diff --git a/admin_app/api/schemas.py b/admin_app/api/schemas.py index 1faf0ed..55c40d5 100644 --- a/admin_app/api/schemas.py +++ b/admin_app/api/schemas.py @@ -20,6 +20,7 @@ from shared.contracts import ( ToolParameterType, ) +# Contratos de request/response. class AdminRootResponse(BaseModel): service: str @@ -795,8 +796,24 @@ class AdminToolGovernanceDecisionRequest(BaseModel): return value.strip() +class AdminToolOptionalGovernanceDecisionRequest(BaseModel): + decision_notes: str | None = Field(default=None, max_length=2000) + + @field_validator("decision_notes", mode="before") + @classmethod + def normalize_decision_notes(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = str(value).strip() + return normalized or None + + class AdminToolReviewHumanGateResponse(BaseModel): current_gate: str + authorize_generation_action_available: bool + run_pipeline_action_available: bool + request_changes_action_available: bool + close_proposal_action_available: bool review_action_available: bool approval_action_available: bool publication_action_available: bool @@ -808,6 +825,18 @@ class AdminToolReviewHumanGateResponse(BaseModel): requires_code_review_confirmation: bool +class AdminToolGenerationContextResponse(BaseModel): + latest_generation_iteration: int = Field(ge=0) + next_generation_iteration: int = Field(ge=1) + latest_generation_mode: str | None = None + next_generation_mode: str + generation_iterations_count: int = Field(ge=0) + has_previous_generation: bool + pending_change_request: bool + latest_generated_source_checksum: str | None = None + latest_change_request_notes: str | None = None + + class AdminToolReviewHistoryEntryResponse(BaseModel): action_key: str label: str @@ -841,6 +870,7 @@ class AdminToolReviewDetailResponse(BaseModel): generated_callable: str generated_source_code: str execution: AdminToolPipelineExecutionResponse | None = None + generation_context: AdminToolGenerationContextResponse human_gate: AdminToolReviewHumanGateResponse decision_history: list[AdminToolReviewHistoryEntryResponse] = Field(default_factory=list) next_steps: list[str] = Field(default_factory=list) @@ -993,9 +1023,12 @@ class AdminToolDraftSubmissionPolicyResponse(BaseModel): mode: str submitter_role: StaffRole | None = None submitter_can_publish_now: bool + submitter_can_authorize_generation_now: bool direct_publication_blocked: bool + requires_generation_authorization: bool requires_director_approval: bool required_approver_role: StaffRole + required_generation_permission: AdminPermission required_review_permission: AdminPermission required_publish_permission: AdminPermission diff --git a/admin_app/db/models/tool_artifact.py b/admin_app/db/models/tool_artifact.py index 465a427..1d5a97e 100644 --- a/admin_app/db/models/tool_artifact.py +++ b/admin_app/db/models/tool_artifact.py @@ -18,6 +18,9 @@ class ToolArtifactStage(str, Enum): class ToolArtifactKind(str, Enum): GENERATION_REQUEST = "generation_request" VALIDATION_REPORT = "validation_report" + GENERATION_AUTHORIZATION = "generation_authorization" + GENERATION_CHANGE_REQUEST = "generation_change_request" + PROPOSAL_CLOSURE = "proposal_closure" DIRECTOR_REVIEW = "director_review" DIRECTOR_APPROVAL = "director_approval" PUBLICATION_RELEASE = "publication_release" diff --git a/admin_app/services/tool_generation_service.py b/admin_app/services/tool_generation_service.py index 7c0e452..8cf86a1 100644 --- a/admin_app/services/tool_generation_service.py +++ b/admin_app/services/tool_generation_service.py @@ -140,6 +140,9 @@ class ToolGenerationService: description: str, business_goal: str, parameters: list[dict], + previous_source_code: str | None = None, + change_request_notes: str | None = None, + generation_iteration: int = 1, ) -> str: """Monta o prompt estruturado de geração enviado ao modelo. @@ -203,7 +206,37 @@ class ToolGenerationService: "O bot atua em um sistema de atendimento automatizado.", ) + normalized_previous_source = str(previous_source_code or "").strip() + normalized_change_request_notes = str(change_request_notes or "").strip() + prompt_mode = "geracao_inicial" + refinement_block = "" + if normalized_previous_source and normalized_change_request_notes: + prompt_mode = "refatoracao_guiada_por_feedback" + refinement_block = ( + "MODO DE EXECUCAO:\n" + "- Esta nao e uma geracao do zero. Refatore a implementacao existente.\n" + "- Preserve o contrato governado, o objetivo de negocio e os parametros da tool.\n" + "- Corrija explicitamente os pontos apontados pela revisao humana.\n\n" + "FEEDBACK HUMANO:\n" + f"{normalized_change_request_notes}\n\n" + "CODIGO ANTERIOR A SER REFATORADO:\n" + f"```python\n{normalized_previous_source}\n```\n\n" + ) + elif normalized_previous_source: + prompt_mode = "regeneracao_com_contexto_previo" + refinement_block = ( + "MODO DE EXECUCAO:\n" + "- Existe um codigo anterior para esta mesma versao.\n" + "- Use-o como referencia para manter continuidade e consistencia na implementacao.\n\n" + "CODIGO ANTERIOR DE REFERENCIA:\n" + f"```python\n{normalized_previous_source}\n```\n\n" + ) + return ( + "CONTEXTO DA EXECUCAO:\n" + f"- Iteracao de geracao: {int(generation_iteration)}\n" + f"- Modo do prompt: {prompt_mode}\n\n" + f"{refinement_block}" "Você é um especialista em Python que gera implementações realistas de tools " "para um bot de atendimento.\n\n" f"CONTEXTO DO DOMÍNIO:\n{domain_context}\n\n" @@ -263,6 +296,9 @@ class ToolGenerationService: business_goal: str, parameters: list[dict], preferred_model: str | None = None, + previous_source_code: str | None = None, + change_request_notes: str | None = None, + generation_iteration: int = 1, ) -> dict[str, Any]: """Gera o código Python da tool a partir dos metadados do draft. @@ -281,6 +317,9 @@ class ToolGenerationService: description=description, business_goal=business_goal, parameters=parameters, + previous_source_code=previous_source_code, + change_request_notes=change_request_notes, + generation_iteration=generation_iteration, ) model_sequence = self._build_model_sequence(preferred_model) diff --git a/admin_app/services/tool_management_service.py b/admin_app/services/tool_management_service.py index d04063b..e347f3f 100644 --- a/admin_app/services/tool_management_service.py +++ b/admin_app/services/tool_management_service.py @@ -104,6 +104,11 @@ _REVIEW_QUEUE_STATUSES = ( ToolLifecycleStatus.FAILED, ) _HUMAN_DECISION_NOTES_MIN_LENGTH = 12 +_GENERATION_GATE_ARTIFACT_KINDS = ( + ToolArtifactKind.GENERATION_AUTHORIZATION, + ToolArtifactKind.GENERATION_CHANGE_REQUEST, + ToolArtifactKind.PROPOSAL_CLOSURE, +) class ToolManagementService: @@ -191,6 +196,10 @@ class ToolManagementService: raise ValueError( f"A pipeline de geracao exige status em (draft, failed), mas a versao esta em '{version.status.value}'." ) + if version.status == ToolLifecycleStatus.DRAFT and not self._can_runner_execute_generation(version, normalized_role): + raise ValueError( + "Versoes propostas por colaborador exigem autorizacao de diretor antes de consumir a geracao de codigo." + ) draft = self.draft_repository.get_by_tool_name(version.tool_name) if draft is None: @@ -296,6 +305,15 @@ class ToolManagementService: if version is not None: repository_session.refresh(version) + @staticmethod + def _submitter_can_authorize_generation(submitter_role: StaffRole | str | None) -> bool: + if submitter_role is None: + return True + return role_has_permission( + normalize_staff_role(submitter_role), + AdminPermission.REVIEW_TOOL_GENERATIONS, + ) + def _build_submission_policy( self, *, @@ -307,13 +325,17 @@ class ToolManagementService: if normalized_role is not None else False ) + submitter_can_authorize_generation_now = self._submitter_can_authorize_generation(submitter_role) return { "mode": "draft_only", "submitter_role": normalized_role, "submitter_can_publish_now": submitter_can_publish_now, + "submitter_can_authorize_generation_now": submitter_can_authorize_generation_now, "direct_publication_blocked": True, + "requires_generation_authorization": not submitter_can_authorize_generation_now, "requires_director_approval": True, "required_approver_role": StaffRole.DIRETOR, + "required_generation_permission": AdminPermission.REVIEW_TOOL_GENERATIONS, "required_review_permission": AdminPermission.REVIEW_TOOL_GENERATIONS, "required_publish_permission": AdminPermission.PUBLISH_TOOLS, } @@ -537,7 +559,10 @@ class ToolManagementService: validation_payload = dict(validation_artifact.payload_json or {}) automated_validation = self._extract_latest_automated_validation(version.id) + generation_context = self._build_generation_iteration_context(version=version) generated_source_code = str(validation_payload.get("generated_source_code") or "").strip() + if not generated_source_code: + generated_source_code = str(generation_context.get("latest_generated_source_code") or "").strip() worker_execution = self._get_generation_pipeline_worker_execution(version.version_id) automated_validation_summary = automated_validation.get("summary") if not generated_source_code and isinstance(worker_execution, dict): @@ -570,6 +595,17 @@ class ToolManagementService: "generated_callable": GENERATED_TOOL_ENTRYPOINT, "generated_source_code": generated_source_code, "execution": worker_execution, + "generation_context": { + "latest_generation_iteration": int(generation_context.get("latest_generation_iteration") or 0), + "next_generation_iteration": int(generation_context.get("next_generation_iteration") or 1), + "latest_generation_mode": generation_context.get("latest_generation_mode"), + "next_generation_mode": generation_context.get("generation_mode") or "initial_generation", + "generation_iterations_count": len(generation_context.get("generation_iterations") or []), + "has_previous_generation": bool(generation_context.get("latest_generated_source_code")), + "pending_change_request": self._build_review_gate(version) == "changes_requested", + "latest_generated_source_checksum": generation_context.get("latest_generated_source_checksum"), + "latest_change_request_notes": generation_context.get("latest_change_request_notes"), + }, "human_gate": self._build_human_review_gate(version), "decision_history": self._list_governance_history_entries(version.id), "next_steps": self._build_review_detail_next_steps( @@ -640,6 +676,10 @@ class ToolManagementService: raise ValueError( f"A pipeline de geracao exige status em (draft, failed), mas a versao esta em '{version.status.value}'." ) + if version.status == ToolLifecycleStatus.DRAFT and not self._can_runner_execute_generation(version, normalized_role): + raise ValueError( + "Versoes propostas por colaborador exigem autorizacao de diretor antes de consumir a geracao de codigo." + ) draft = self.draft_repository.get_by_tool_name(version.tool_name) if draft is None: @@ -651,9 +691,18 @@ class ToolManagementService: # ---- Fase 7: Geração de código via LLM isolado do runtime de atendimento ---- # O tool_generation_service é None em modo de compatibilidade (usa stub). # Quando presente, gera código real usando o modelo do runtime de geração. + generation_context = self._build_generation_iteration_context(version=version) + generation_iteration = generation_context["next_generation_iteration"] + generation_mode = generation_context["generation_mode"] + previous_source_code = generation_context["latest_generated_source_code"] + previous_source_checksum = generation_context["latest_generated_source_checksum"] + change_request_notes = generation_context["feedback_notes_for_generation"] + llm_generated_source: str | None = None llm_generation_model: str | None = None llm_generation_issues: list[str] = [] + llm_prompt_rendered: str | None = None + llm_generation_elapsed_ms: float | None = None if self.tool_generation_service is not None: preferred_model = str(version.generation_model or "").strip() or None @@ -666,11 +715,16 @@ class ToolManagementService: business_goal=version.business_goal, parameters=list(metadata.parameters_json or []), preferred_model=preferred_model, + previous_source_code=previous_source_code, + change_request_notes=change_request_notes, + generation_iteration=generation_iteration, ) ) llm_generated_source = generation_result.get("generated_source_code") llm_generation_model = generation_result.get("generation_model_used") llm_generation_issues = list(generation_result.get("issues") or []) + llm_prompt_rendered = str(generation_result.get("prompt_rendered") or "").strip() or None + llm_generation_elapsed_ms = generation_result.get("elapsed_ms") # ---- fim Fase 7 ---- repository_session = self._resolve_repository_session() @@ -705,6 +759,12 @@ class ToolManagementService: llm_generated_source=None, llm_generation_model=llm_generation_model, llm_generation_issues=llm_generation_issues, + generation_iteration=generation_iteration, + generation_mode=generation_mode, + feedback_notes=change_request_notes, + previous_source_checksum=previous_source_checksum, + prompt_rendered=llm_prompt_rendered, + generation_elapsed_ms=llm_generation_elapsed_ms, commit=artifact_commit, ) if repository_session is not None: @@ -747,6 +807,12 @@ class ToolManagementService: llm_generated_source=llm_generated_source, llm_generation_model=llm_generation_model, llm_generation_issues=llm_generation_issues, + generation_iteration=generation_iteration, + generation_mode=generation_mode, + feedback_notes=change_request_notes, + previous_source_checksum=previous_source_checksum, + prompt_rendered=llm_prompt_rendered, + generation_elapsed_ms=llm_generation_elapsed_ms, commit=artifact_commit, ) automated_validation_result = self._execute_automated_contract_validation( @@ -756,6 +822,10 @@ class ToolManagementService: actor_staff_account_id=runner_staff_account_id, actor_name=runner_name, llm_generated_source=llm_generated_source, + generation_iteration=generation_iteration, + generation_mode=generation_mode, + change_request_notes=change_request_notes, + previous_source_checksum=previous_source_checksum, commit=artifact_commit, ) pipeline_status = ( @@ -821,6 +891,98 @@ class ToolManagementService: "next_steps": next_steps, } + def authorize_generation( + self, + version_id: str, + *, + actor_staff_account_id: int, + actor_name: str, + actor_role: StaffRole | str, + decision_notes: str, + ) -> dict: + normalized_notes = self._normalize_human_decision_notes(decision_notes) + return self._record_governance_decision_without_status_change( + version_id, + allowed_current_statuses=(ToolLifecycleStatus.DRAFT,), + actor_staff_account_id=actor_staff_account_id, + actor_name=actor_name, + actor_role=actor_role, + required_permission=AdminPermission.REVIEW_TOOL_GENERATIONS, + artifact_kind=ToolArtifactKind.GENERATION_AUTHORIZATION, + artifact_summary="Diretoria autorizou a proposta a consumir a etapa de geracao de codigo.", + success_message="Geracao autorizada com sucesso pela diretoria. A versao agora pode seguir para a pipeline de codigo.", + decision_notes=normalized_notes, + next_steps=[ + "Execute a pipeline de geracao quando quiser transformar a proposta em codigo governado.", + "Depois da geracao, a versao ainda precisara passar por validacao automatica, revisao humana e aprovacao final.", + ], + extra_payload={"generation_gate": "authorized"}, + ) + + def request_changes( + self, + version_id: str, + *, + actor_staff_account_id: int, + actor_name: str, + actor_role: StaffRole | str, + decision_notes: str, + ) -> dict: + normalized_notes = self._normalize_human_decision_notes(decision_notes) + return self._transition_version_status( + version_id, + target_status=ToolLifecycleStatus.DRAFT, + allowed_current_statuses=(ToolLifecycleStatus.GENERATED,), + actor_staff_account_id=actor_staff_account_id, + actor_name=actor_name, + actor_role=actor_role, + required_permission=AdminPermission.REVIEW_TOOL_GENERATIONS, + artifact_kind=ToolArtifactKind.GENERATION_CHANGE_REQUEST, + artifact_summary="Diretoria solicitou ajustes e uma nova iteracao de geracao para a versao governada.", + success_message="Diretoria solicitou ajustes com sucesso. A versao voltou para draft e pode seguir para nova geracao.", + decision_notes=normalized_notes, + next_steps=[ + "Ajuste a proposta conforme o parecer registrado e execute uma nova geracao quando estiver pronto.", + "A versao so retorna para revisao humana depois que a nova iteracao passar pelas validacoes automaticas.", + ], + extra_payload={"generation_gate": "changes_requested"}, + ) + + def close_proposal( + self, + version_id: str, + *, + actor_staff_account_id: int, + actor_name: str, + actor_role: StaffRole | str, + decision_notes: str | None = None, + ) -> dict: + normalized_notes = str(decision_notes or "").strip() or None + return self._transition_version_status( + version_id, + target_status=ToolLifecycleStatus.ARCHIVED, + allowed_current_statuses=( + ToolLifecycleStatus.DRAFT, + ToolLifecycleStatus.GENERATED, + ToolLifecycleStatus.FAILED, + ), + actor_staff_account_id=actor_staff_account_id, + actor_name=actor_name, + actor_role=actor_role, + required_permission=AdminPermission.REVIEW_TOOL_GENERATIONS, + artifact_kind=ToolArtifactKind.PROPOSAL_CLOSURE, + artifact_summary="Diretoria encerrou a proposta governada antes da ativacao no produto.", + success_message="Proposta encerrada com sucesso e removida da esteira ativa de geracao governada.", + decision_notes=normalized_notes, + next_steps=[ + "A proposta permanece apenas para historico e auditoria administrativa.", + "Se o time decidir retomar a ideia, o ideal e abrir uma nova submissao de tool com escopo revisado.", + ], + extra_payload={ + "closure_reason": "closed_without_feedback" if normalized_notes is None else "closed_with_feedback", + }, + ) + def review_version( self, version_id: str, @@ -838,6 +1000,7 @@ class ToolManagementService: if self.version_repository is not None else None ) + generation_context = self._build_generation_iteration_context(version=version) if version is not None else {} if version is not None and version.status == ToolLifecycleStatus.GENERATED: if not reviewed_generated_code: raise ValueError( @@ -864,6 +1027,11 @@ class ToolManagementService: "A diretoria ainda precisa aprovar formalmente a versao antes da publicacao.", "Depois da aprovacao, a publicacao ativa a tool no catalogo governado do produto.", ], + extra_payload={ + "reviewed_generation_iteration": generation_context.get("latest_generation_iteration"), + "reviewed_generation_mode": generation_context.get("latest_generation_mode"), + "reviewed_generation_checksum": generation_context.get("latest_generated_source_checksum"), + }, ) def approve_version( @@ -876,8 +1044,15 @@ class ToolManagementService: decision_notes: str, ) -> dict: normalized_notes = self._normalize_human_decision_notes(decision_notes) + normalized_version_id = str(version_id or "").strip().lower() + version = ( + self.version_repository.get_by_version_id(normalized_version_id) + if self.version_repository is not None + else None + ) + generation_context = self._build_generation_iteration_context(version=version) if version is not None else {} return self._transition_version_status( - version_id, + normalized_version_id, target_status=ToolLifecycleStatus.APPROVED, allowed_current_statuses=(ToolLifecycleStatus.VALIDATED,), actor_staff_account_id=approver_staff_account_id, @@ -892,6 +1067,11 @@ class ToolManagementService: "A publicacao administrativa ainda precisa ser executada antes da ativacao.", "Enquanto a versao estiver apenas aprovada, ela permanece fora do catalogo ativo do produto.", ], + extra_payload={ + "approved_generation_iteration": generation_context.get("latest_generation_iteration"), + "approved_generation_mode": generation_context.get("latest_generation_mode"), + "approved_generation_checksum": generation_context.get("latest_generated_source_checksum"), + }, ) def publish_version( @@ -1106,6 +1286,7 @@ class ToolManagementService: decision_notes: str | None = None, reviewed_generated_code: bool | None = None, next_steps: list[str], + extra_payload: dict | None = None, ) -> dict: normalized_role = normalize_staff_role(actor_role) if not role_has_permission(normalized_role, required_permission): @@ -1185,6 +1366,7 @@ class ToolManagementService: actor_role=normalized_role, decision_notes=decision_notes, reviewed_generated_code=reviewed_generated_code, + extra_payload=extra_payload, commit=artifact_commit, ) if repository_session is not None: @@ -1218,6 +1400,98 @@ class ToolManagementService: "next_steps": next_steps, } + def _record_governance_decision_without_status_change( + self, + version_id: str, + *, + allowed_current_statuses: tuple[ToolLifecycleStatus, ...], + actor_staff_account_id: int, + actor_name: str, + actor_role: StaffRole | str, + required_permission: AdminPermission, + artifact_kind: ToolArtifactKind, + artifact_summary: str, + success_message: str, + decision_notes: str | None = None, + reviewed_generated_code: bool | None = None, + next_steps: list[str], + extra_payload: dict | None = None, + ) -> dict: + normalized_role = normalize_staff_role(actor_role) + if not role_has_permission(normalized_role, required_permission): + raise PermissionError( + f"Papel '{normalized_role.value}' sem permissao administrativa '{required_permission.value}'." + ) + if ( + self.draft_repository is None + or self.version_repository is None + or self.metadata_repository is None + ): + raise RuntimeError( + "Fluxo de governanca de tools ainda nao esta completamente conectado ao armazenamento administrativo." + ) + + normalized_version_id = str(version_id or "").strip().lower() + version = self.version_repository.get_by_version_id(normalized_version_id) + if version is None: + raise LookupError("Versao administrativa nao encontrada.") + + latest_versions_for_tool = self.version_repository.list_versions(tool_name=version.tool_name) + if latest_versions_for_tool and latest_versions_for_tool[0].version_id != version.version_id: + raise ValueError( + "Somente a versao mais recente da tool pode seguir pela governanca administrativa." + ) + if version.status not in allowed_current_statuses: + expected_statuses = ", ".join(status.value for status in allowed_current_statuses) + raise ValueError( + f"A decisao solicitada exige status em ({expected_statuses}), mas a versao esta em '{version.status.value}'." + ) + + draft = self.draft_repository.get_by_tool_name(version.tool_name) + if draft is None: + raise RuntimeError("Draft raiz da tool nao encontrado para a versao governada.") + + repository_session = self._resolve_repository_session() + artifact_commit = False if repository_session is not None else None + + try: + self._persist_governance_artifact( + draft=draft, + version=version, + artifact_kind=artifact_kind, + summary=artifact_summary, + previous_status=version.status, + current_status=version.status, + actor_staff_account_id=actor_staff_account_id, + actor_name=actor_name, + actor_role=normalized_role, + decision_notes=decision_notes, + reviewed_generated_code=reviewed_generated_code, + extra_payload=extra_payload, + commit=artifact_commit, + ) + if repository_session is not None: + self._commit_repository_session( + repository_session, + draft=draft, + version=version, + ) + except Exception: + if repository_session is not None: + repository_session.rollback() + raise + + return { + "message": success_message, + "version_id": version.version_id, + "tool_name": version.tool_name, + "version_number": version.version_number, + "status": version.status, + "queue_entry": self._serialize_review_queue_entry(version), + "publication": None, + "next_steps": next_steps, + } + def create_draft_submission( self, payload: dict, @@ -1263,7 +1537,7 @@ class ToolManagementService: "warnings": warnings, "next_steps": [ "Persistir o draft administrativo em armazenamento proprio do admin na fase 5.", - "Encaminhar a tool para revisao e aprovacao de um diretor.", + "Registrar a autorizacao de geracao antes de consumir o modelo quando a proposta vier de colaborador.", "Executar pipeline de geracao, validacao e publicacao antes da ativacao no produto.", ], } @@ -1275,6 +1549,8 @@ class ToolManagementService: atomic_write_options = {"commit": False} if repository_session is not None else {} artifact_commit = False if repository_session is not None else None owner_display_name = owner_name or "Autor administrativo" + owner_can_authorize_generation = self._submitter_can_authorize_generation(owner_role) + normalized_owner_role = normalize_staff_role(owner_role) if owner_role is not None else StaffRole.DIRETOR existing_draft = self.draft_repository.get_by_tool_name(normalized["tool_name"]) next_version_number = self._resolve_next_version_number(normalized["tool_name"], existing_draft) @@ -1366,6 +1642,25 @@ class ToolManagementService: owner_name=owner_display_name, commit=artifact_commit, ) + if owner_can_authorize_generation: + self._persist_governance_artifact( + draft=draft, + version=version, + artifact_kind=ToolArtifactKind.GENERATION_AUTHORIZATION, + summary="Autorizacao inicial registrada para seguir da proposta para a geracao de codigo.", + previous_status=ToolLifecycleStatus.DRAFT, + current_status=ToolLifecycleStatus.DRAFT, + actor_staff_account_id=owner_staff_account_id, + actor_name=owner_display_name, + actor_role=normalized_owner_role, + decision_notes="Autorizacao inicial registrada no momento do cadastro por um perfil com poder de seguir para geracao.", + extra_payload={ + "generation_gate": "authorized", + "authorized_by_submitter": True, + "authorization_scope": "initial_submission", + }, + commit=artifact_commit, + ) if repository_session is not None: self._commit_repository_session( @@ -1384,11 +1679,19 @@ class ToolManagementService: "submission_policy": submission_policy, "draft_preview": self._serialize_draft_preview(draft, version), "warnings": warnings, - "next_steps": [ - f"Executar a pipeline de geracao para a versao v{draft.current_version_number} antes da validacao.", - "Depois da geracao, validar a versao e encaminhar para aprovacao de diretor.", - "Persistir artefatos e publicacoes associados a cada versao governada.", - ], + "next_steps": ( + [ + f"Executar a pipeline de geracao para a versao v{draft.current_version_number} quando quiser transformar a proposta em codigo governado.", + "Depois da geracao, validar a versao e encaminhar para aprovacao de diretor.", + "Persistir artefatos e publicacoes associados a cada versao governada.", + ] + if owner_can_authorize_generation + else [ + "Aguardar a autorizacao de um diretor antes de consumir a etapa de geracao de codigo.", + f"Depois da autorizacao, execute a pipeline da versao v{draft.current_version_number} para seguir para validacao e revisao.", + "Persistir artefatos e publicacoes associados a cada versao governada.", + ] + ), } def preview_draft_submission( @@ -1434,7 +1737,7 @@ class ToolManagementService: "warnings": warnings, "next_steps": [ "Persistir a nova versao administrativa para consolidar o historico da tool.", - "Encaminhar a versao para revisao e aprovacao de um diretor.", + "Registrar a autorizacao de geracao antes de consumir o modelo quando a proposta vier de colaborador.", "Executar pipeline de geracao, validacao e publicacao antes da ativacao no produto.", ], } @@ -1713,6 +2016,10 @@ class ToolManagementService: actor_staff_account_id: int, actor_name: str, llm_generated_source: str | None = None, + generation_iteration: int, + generation_mode: str, + change_request_notes: str | None = None, + previous_source_checksum: str | None = None, commit: bool | None = None, ) -> dict: previous_validation_payload = {} @@ -1802,12 +2109,16 @@ class ToolManagementService: draft=draft, version=version, metadata=metadata, - intake_validation=previous_validation_payload, + previous_validation_payload=previous_validation_payload, automated_checks=automated_checks, validation_issues=all_validation_issues, signature_schema_blueprint=signature_schema_blueprint, import_loading_result=import_loading_result, smoke_test_result=smoke_test_result, + generation_iteration=generation_iteration, + generation_mode=generation_mode, + change_request_notes=change_request_notes, + previous_source_checksum=previous_source_checksum, ) if self.artifact_repository is not None: @@ -1845,12 +2156,16 @@ class ToolManagementService: draft: ToolDraft, version: ToolVersion, metadata: ToolMetadata, - intake_validation: dict, + previous_validation_payload: dict, automated_checks: list[dict], validation_issues: list[str], signature_schema_blueprint: dict, import_loading_result: dict, smoke_test_result: dict, + generation_iteration: int, + generation_mode: str, + change_request_notes: str | None = None, + previous_source_checksum: str | None = None, ) -> dict: publication_envelope = None if not validation_issues: @@ -1859,6 +2174,38 @@ class ToolManagementService: metadata=metadata, ).model_dump(mode="json") + generated_source_code = ( + str(import_loading_result.get("rendered_source") or "").strip() + or self._render_generated_tool_module_source( + version=version, + metadata=metadata, + signature_schema_blueprint=signature_schema_blueprint, + ) + ) + generated_source_checksum = self._compute_source_checksum(generated_source_code) + validation_entry = { + "generation_iteration": int(generation_iteration), + "generation_mode": generation_mode, + "validation_status": "passed" if not validation_issues else "failed", + "validated_at": datetime.now(UTC).isoformat(), + "blocking_issues": list(validation_issues), + "automated_checks": list(automated_checks), + "generated_source_checksum": generated_source_checksum, + "change_request_notes": change_request_notes, + "previous_source_checksum": previous_source_checksum, + } + validation_iterations = self._extract_validation_iterations(previous_validation_payload) + validation_iterations = [ + entry + for entry in validation_iterations + if int(entry.get("generation_iteration") or 0) != int(generation_iteration) + ] + validation_iterations.append(dict(validation_entry)) + validation_iterations = sorted( + validation_iterations, + key=lambda entry: int(entry.get("generation_iteration") or 0), + ) + return { "source": "admin_generation_pipeline", "tool_name": draft.tool_name, @@ -1867,7 +2214,7 @@ class ToolManagementService: "version_id": version.version_id, "validation_status": "passed" if not validation_issues else "failed", "validation_scope": "tool_contract", - "warnings": list((intake_validation or {}).get("warnings") or []), + "warnings": list((previous_validation_payload or {}).get("warnings") or []), "blocking_issues": list(validation_issues), "parameter_count": len(version.parameters_json or []), "required_parameter_count": version.required_parameter_count, @@ -1877,20 +2224,20 @@ class ToolManagementService: + _AUTOMATED_IMPORT_LOADING_VALIDATION_RULES + _AUTOMATED_SMOKE_TEST_RULES ), - "intake_validation": dict(intake_validation or {}), + "intake_validation": dict(previous_validation_payload or {}), "automated_checks": list(automated_checks), "signature_schema": dict(signature_schema_blueprint), "import_loading": dict(import_loading_result), "smoke_tests": dict(smoke_test_result), - "generated_source_code": ( - str(import_loading_result.get("rendered_source") or "").strip() - or self._render_generated_tool_module_source( - version=version, - metadata=metadata, - signature_schema_blueprint=signature_schema_blueprint, - ) - ), + "generation_iteration": int(generation_iteration), + "generation_mode": generation_mode, + "change_request_notes": change_request_notes, + "previous_source_checksum": previous_source_checksum, + "generated_source_checksum": generated_source_checksum, + "generated_source_code": generated_source_code, "publication_envelope": publication_envelope, + "latest_validation": dict(validation_entry), + "validation_iterations": validation_iterations, } def _build_generated_signature_and_parameter_schema( @@ -2360,18 +2707,197 @@ class ToolManagementService: return version return None + @staticmethod + def _compute_source_checksum(source_code: str | None) -> str | None: + normalized_source = str(source_code or "").strip() + if not normalized_source: + return None + return hashlib.sha256(normalized_source.encode("utf-8")).hexdigest() + + @staticmethod + def _extract_generation_iterations(payload: dict | None) -> list[dict]: + normalized_payload = dict(payload or {}) + iterations = [ + dict(entry) + for entry in list(normalized_payload.get("generation_iterations") or []) + if isinstance(entry, dict) + ] + if iterations: + return sorted(iterations, key=lambda entry: int(entry.get("generation_iteration") or 0)) + latest_generation = normalized_payload.get("latest_generation") + if isinstance(latest_generation, dict) and latest_generation: + return [dict(latest_generation)] + if normalized_payload.get("generated_at") or normalized_payload.get("generation_iteration"): + return [{ + "generation_iteration": int(normalized_payload.get("generation_iteration") or 1), + "generation_mode": normalized_payload.get("generation_mode") or "legacy_generation", + "triggered_by": normalized_payload.get("triggered_by"), + "triggered_by_role": normalized_payload.get("triggered_by_role"), + "generated_at": normalized_payload.get("generated_at"), + "generation_model_used": normalized_payload.get("generation_model_used"), + "generation_issues": list(normalized_payload.get("generation_issues") or []), + "generation_source": normalized_payload.get("generation_source"), + "pipeline_status": normalized_payload.get("pipeline_status"), + "prompt_rendered": normalized_payload.get("prompt_rendered"), + "elapsed_ms": normalized_payload.get("elapsed_ms"), + "feedback_notes": normalized_payload.get("feedback_notes"), + "previous_source_checksum": normalized_payload.get("previous_source_checksum"), + "generated_source_checksum": normalized_payload.get("generated_source_checksum"), + }] + return [] + + @staticmethod + def _extract_validation_iterations(payload: dict | None) -> list[dict]: + normalized_payload = dict(payload or {}) + iterations = [ + dict(entry) + for entry in list(normalized_payload.get("validation_iterations") or []) + if isinstance(entry, dict) + ] + if iterations: + return sorted(iterations, key=lambda entry: int(entry.get("generation_iteration") or 0)) + latest_validation = normalized_payload.get("latest_validation") + if isinstance(latest_validation, dict) and latest_validation: + return [dict(latest_validation)] + if normalized_payload.get("generated_source_code") or normalized_payload.get("automated_checks"): + return [{ + "generation_iteration": int(normalized_payload.get("generation_iteration") or 1), + "generation_mode": normalized_payload.get("generation_mode") or "legacy_generation", + "validation_status": normalized_payload.get("validation_status"), + "validated_at": normalized_payload.get("validated_at"), + "blocking_issues": list(normalized_payload.get("blocking_issues") or []), + "automated_checks": list(normalized_payload.get("automated_checks") or []), + "generated_source_checksum": normalized_payload.get("generated_source_checksum") + or ToolManagementService._compute_source_checksum(normalized_payload.get("generated_source_code")), + "change_request_notes": normalized_payload.get("change_request_notes"), + "previous_source_checksum": normalized_payload.get("previous_source_checksum"), + }] + return [] + + def _render_legacy_generated_source_from_metadata( + self, + *, + version: ToolVersion, + metadata: ToolMetadata, + ) -> str: + signature_schema_blueprint = self._build_generated_signature_and_parameter_schema(metadata=metadata) + return self._render_generated_tool_module_source( + version=version, + metadata=metadata, + signature_schema_blueprint=signature_schema_blueprint, + ) + + def _build_generation_iteration_context( + self, + *, + version: ToolVersion | None = None, + tool_version_id: int | None = None, + ) -> dict: + resolved_version = version + resolved_tool_version_id = tool_version_id + if resolved_version is not None: + resolved_tool_version_id = resolved_version.id + elif resolved_tool_version_id is not None: + resolved_version = self._get_version_by_tool_version_id(resolved_tool_version_id) + + generation_payload = {} + validation_payload = {} + if self.artifact_repository is not None and resolved_tool_version_id is not None: + generation_artifact = self.artifact_repository.get_by_tool_version_and_kind( + resolved_tool_version_id, + ToolArtifactKind.GENERATION_REQUEST, + ) + validation_artifact = self.artifact_repository.get_by_tool_version_and_kind( + resolved_tool_version_id, + ToolArtifactKind.VALIDATION_REPORT, + ) + if generation_artifact is not None: + generation_payload = dict(generation_artifact.payload_json or {}) + if validation_artifact is not None: + validation_payload = dict(validation_artifact.payload_json or {}) + + metadata = None + if self.metadata_repository is not None and resolved_tool_version_id is not None: + metadata = self.metadata_repository.get_by_tool_version_id(resolved_tool_version_id) + + generation_iterations = self._extract_generation_iterations(generation_payload) + validation_iterations = self._extract_validation_iterations(validation_payload) + latest_generation = dict(generation_iterations[-1]) if generation_iterations else {} + latest_validation = dict(validation_iterations[-1]) if validation_iterations else {} + latest_generation_iteration = int( + latest_generation.get("generation_iteration") + or latest_validation.get("generation_iteration") + or generation_payload.get("generation_iteration") + or validation_payload.get("generation_iteration") + or 0 + ) + latest_generated_source_code = str(validation_payload.get("generated_source_code") or "").strip() or None + latest_generated_source_checksum = str(validation_payload.get("generated_source_checksum") or "").strip() or None + if ( + latest_generated_source_code is None + and latest_generation_iteration > 0 + and resolved_version is not None + and metadata is not None + and bool(validation_iterations) + ): + latest_generated_source_code = self._render_legacy_generated_source_from_metadata( + version=resolved_version, + metadata=metadata, + ) + if latest_generated_source_code and not latest_generated_source_checksum: + latest_generated_source_checksum = self._compute_source_checksum(latest_generated_source_code) + + latest_change_request_notes = None + pending_change_request = False + if resolved_tool_version_id is not None: + latest_change_request = self._get_latest_governance_artifact( + resolved_tool_version_id, + artifact_kinds=(ToolArtifactKind.GENERATION_CHANGE_REQUEST,), + ) + if latest_change_request is not None: + latest_change_request_notes = str((latest_change_request.payload_json or {}).get("decision_notes") or "").strip() or None + if resolved_version is not None: + pending_change_request = ( + resolved_version.status == ToolLifecycleStatus.DRAFT + and self._build_review_gate(resolved_version) == "changes_requested" + ) + + feedback_notes_for_generation = latest_change_request_notes if pending_change_request else None + if feedback_notes_for_generation: + generation_mode = "change_request_refinement" + elif latest_generated_source_code and resolved_version is not None and resolved_version.status == ToolLifecycleStatus.FAILED: + generation_mode = "failed_pipeline_retry" + elif latest_generated_source_code: + generation_mode = "regeneration_with_context" + else: + generation_mode = "initial_generation" + + return { + "latest_generation_iteration": latest_generation_iteration, + "next_generation_iteration": latest_generation_iteration + 1, + "latest_generation_mode": latest_generation.get("generation_mode") or latest_validation.get("generation_mode"), + "latest_generated_source_code": latest_generated_source_code, + "latest_generated_source_checksum": latest_generated_source_checksum, + "latest_change_request_notes": latest_change_request_notes, + "feedback_notes_for_generation": feedback_notes_for_generation, + "generation_mode": generation_mode, + "generation_iterations": generation_iterations, + "validation_iterations": validation_iterations, + } + def _get_generated_source_code_for_version(self, tool_version_id: int) -> str: - if self.artifact_repository is None: + if self.version_repository is None or self.metadata_repository is None: raise RuntimeError( - "Nao foi possivel sincronizar o runtime do product sem os artefatos de validacao da tool." + "Nao foi possivel sincronizar o runtime do product sem versionamento e metadados administrativos da tool." ) - validation_artifact = self.artifact_repository.get_by_tool_version_and_kind( - tool_version_id, - ToolArtifactKind.VALIDATION_REPORT, - ) - if validation_artifact is None: - raise RuntimeError("Artefato de validacao nao encontrado para sincronizar a tool publicada no product.") - generated_source_code = str((validation_artifact.payload_json or {}).get("generated_source_code") or "").strip() + version = self._get_version_by_tool_version_id(tool_version_id) + if version is None: + raise RuntimeError("Versao administrativa nao encontrada para sincronizar a tool publicada no product.") + metadata = self.metadata_repository.get_by_tool_version_id(tool_version_id) + if metadata is None: + raise RuntimeError("Metadados persistidos da versao nao encontrados para sincronizar a tool publicada no product.") + generation_context = self._build_generation_iteration_context(version=version) + generated_source_code = str(generation_context.get("latest_generated_source_code") or "").strip() if not generated_source_code: raise RuntimeError("O codigo gerado da tool publicada nao foi encontrado para sincronizacao do runtime.") return generated_source_code @@ -2468,17 +2994,60 @@ class ToolManagementService: llm_generated_source: str | None = None, llm_generation_model: str | None = None, llm_generation_issues: list[str] | None = None, + generation_iteration: int, + generation_mode: str, + feedback_notes: str | None = None, + previous_source_checksum: str | None = None, + prompt_rendered: str | None = None, + generation_elapsed_ms: float | None = None, commit: bool | None = None, ) -> None: if self.artifact_repository is None: return + existing_payload = {} + existing_artifact = self.artifact_repository.get_by_tool_version_and_kind( + version.id, + ToolArtifactKind.GENERATION_REQUEST, + ) + if existing_artifact is not None: + existing_payload = dict(existing_artifact.payload_json or {}) + + generation_iterations = self._extract_generation_iterations(existing_payload) + current_generation = { + "generation_iteration": int(generation_iteration), + "generation_mode": generation_mode, + "triggered_by": actor_name, + "triggered_by_role": actor_role.value, + "generated_at": datetime.now(UTC).isoformat(), + "generation_model_used": llm_generation_model, + "generation_issues": list(llm_generation_issues or []), + "generation_source": "llm" if llm_generated_source else "stub", + "pipeline_status": "completed", + "feedback_notes": feedback_notes, + "previous_source_checksum": previous_source_checksum, + "generated_source_checksum": self._compute_source_checksum(llm_generated_source), + "prompt_rendered": prompt_rendered, + "elapsed_ms": generation_elapsed_ms, + } + generation_iterations = [ + entry + for entry in generation_iterations + if int(entry.get("generation_iteration") or 0) != int(generation_iteration) + ] + generation_iterations.append(dict(current_generation)) + generation_iterations = sorted( + generation_iterations, + key=lambda entry: int(entry.get("generation_iteration") or 0), + ) + artifact_write_options = {"commit": commit} if commit is not None else {} generation_payload = self._build_generation_artifact_payload( draft=draft, version=version, summary=version.summary, stored_parameters=list(version.parameters_json or []), + generation_model=llm_generation_model or version.generation_model, ) generation_payload.update( { @@ -2486,11 +3055,19 @@ class ToolManagementService: "pipeline_status": "completed", "triggered_by": actor_name, "triggered_by_role": actor_role.value, - "generated_at": datetime.now(UTC).isoformat(), - # ---- Fase 7: rastreabilidade da geração via LLM ---- + "generated_at": current_generation["generated_at"], "generation_model_used": llm_generation_model, "generation_issues": list(llm_generation_issues or []), "generation_source": "llm" if llm_generated_source else "stub", + "generation_iteration": int(generation_iteration), + "generation_mode": generation_mode, + "feedback_notes": feedback_notes, + "previous_source_checksum": previous_source_checksum, + "generated_source_checksum": current_generation["generated_source_checksum"], + "prompt_rendered": prompt_rendered, + "elapsed_ms": generation_elapsed_ms, + "latest_generation": dict(current_generation), + "generation_iterations": generation_iterations, } ) self.artifact_repository.upsert_version_artifact( @@ -2635,7 +3212,7 @@ class ToolManagementService: ) display_name = metadata.display_name if metadata is not None else version.tool_name.replace("_", " ").title() automated_validation = self._extract_latest_automated_validation(version.id) - gate = self._build_review_gate(version.status) + gate = self._build_review_gate(version) automated_validation_status = automated_validation.get("status") automated_validation_summary = automated_validation.get("summary") @@ -2679,7 +3256,7 @@ class ToolManagementService: } def _version_has_generated_source(self, version_id: str) -> bool: - if self.version_repository is None or self.artifact_repository is None: + if self.version_repository is None: return False normalized_version_id = str(version_id or "").strip().lower() @@ -2687,13 +3264,8 @@ class ToolManagementService: if version is None: return False - validation_artifact = self.artifact_repository.get_by_tool_version_and_kind( - version.id, - ToolArtifactKind.VALIDATION_REPORT, - ) - if validation_artifact is None: - return False - generated_source_code = str((validation_artifact.payload_json or {}).get("generated_source_code") or "").strip() + generation_context = self._build_generation_iteration_context(version=version) + generated_source_code = str(generation_context.get("latest_generated_source_code") or "").strip() return bool(generated_source_code) def _find_latest_archived_version( @@ -2724,6 +3296,25 @@ class ToolManagementService: ) return normalized_notes + @staticmethod + def _resolve_legacy_human_governance_reference( + payload: dict, + *, + iteration_key: str, + checksum_key: str, + generation_context: dict, + ) -> tuple[int, str | None]: + resolved_iteration = int(payload.get(iteration_key) or 0) + resolved_checksum = str(payload.get(checksum_key) or "").strip() or None + if resolved_iteration > 0: + return resolved_iteration, resolved_checksum + + latest_generation_iteration = int(generation_context.get("latest_generation_iteration") or 0) + latest_generation_mode = str(generation_context.get("latest_generation_mode") or "").strip() or None + if latest_generation_iteration == 1 and latest_generation_mode in {None, "", "legacy_generation"}: + return latest_generation_iteration, generation_context.get("latest_generated_source_checksum") + return resolved_iteration, resolved_checksum + def _ensure_human_governance_ready_for_activation(self, tool_version_id: int) -> None: if self.artifact_repository is None: raise RuntimeError( @@ -2741,6 +3332,21 @@ class ToolManagementService: review_payload = dict(review_artifact.payload_json or {}) if review_artifact is not None else {} approval_payload = dict(approval_artifact.payload_json or {}) if approval_artifact is not None else {} + generation_context = self._build_generation_iteration_context(tool_version_id=tool_version_id) + latest_generation_iteration = int(generation_context.get("latest_generation_iteration") or 0) + latest_generation_checksum = generation_context.get("latest_generated_source_checksum") + reviewed_generation_iteration, reviewed_generation_checksum = self._resolve_legacy_human_governance_reference( + review_payload, + iteration_key="reviewed_generation_iteration", + checksum_key="reviewed_generation_checksum", + generation_context=generation_context, + ) + approved_generation_iteration, approved_generation_checksum = self._resolve_legacy_human_governance_reference( + approval_payload, + iteration_key="approved_generation_iteration", + checksum_key="approved_generation_checksum", + generation_context=generation_context, + ) if not review_payload.get("decision_notes") or not bool(review_payload.get("reviewed_generated_code")): raise ValueError( @@ -2750,6 +3356,26 @@ class ToolManagementService: raise ValueError( "A ativacao exige uma aprovacao humana registrada com parecer explicito da diretoria." ) + if latest_generation_iteration <= 0: + raise ValueError( + "A ativacao exige pelo menos uma iteracao de geracao concluida e validada para esta versao." + ) + if reviewed_generation_iteration != latest_generation_iteration: + raise ValueError( + "A ativacao exige uma revisao humana referente a iteracao mais recente do codigo gerado." + ) + if approved_generation_iteration != latest_generation_iteration: + raise ValueError( + "A ativacao exige uma aprovacao humana referente a iteracao mais recente do codigo gerado." + ) + if latest_generation_checksum and reviewed_generation_checksum not in {None, latest_generation_checksum}: + raise ValueError( + "A ativacao detectou revisao humana associada a um codigo diferente da ultima iteracao aprovada." + ) + if latest_generation_checksum and approved_generation_checksum not in {None, latest_generation_checksum}: + raise ValueError( + "A ativacao detectou aprovacao humana associada a um codigo diferente da ultima iteracao aprovada." + ) def _build_human_review_gate(self, version: ToolVersion) -> dict: rollback_candidate = None @@ -2758,8 +3384,24 @@ class ToolManagementService: tool_name=version.tool_name, excluding_version_id=version.id, ) + current_gate = self._build_review_gate(version) return { - "current_gate": ToolManagementService._build_review_gate(version.status), + "current_gate": current_gate, + "authorize_generation_action_available": version.status == ToolLifecycleStatus.DRAFT and current_gate == "generation_decision_required", + "run_pipeline_action_available": version.status in { + ToolLifecycleStatus.DRAFT, + ToolLifecycleStatus.FAILED, + } and current_gate in { + "generation_pipeline_required", + "changes_requested", + "pipeline_retry_required", + }, + "request_changes_action_available": version.status == ToolLifecycleStatus.GENERATED, + "close_proposal_action_available": version.status in { + ToolLifecycleStatus.DRAFT, + ToolLifecycleStatus.GENERATED, + ToolLifecycleStatus.FAILED, + }, "review_action_available": version.status == ToolLifecycleStatus.GENERATED, "approval_action_available": version.status == ToolLifecycleStatus.VALIDATED, "publication_action_available": version.status == ToolLifecycleStatus.APPROVED, @@ -2768,6 +3410,7 @@ class ToolManagementService: "rollback_target_version_id": rollback_candidate.version_id if rollback_candidate is not None else None, "rollback_target_version_number": rollback_candidate.version_number if rollback_candidate is not None else None, "requires_decision_notes": version.status in { + ToolLifecycleStatus.DRAFT, ToolLifecycleStatus.GENERATED, ToolLifecycleStatus.VALIDATED, ToolLifecycleStatus.ACTIVE, @@ -2805,8 +3448,8 @@ class ToolManagementService: else: next_steps_by_status = { ToolLifecycleStatus.DRAFT: [ - "Execute a pipeline de geracao para produzir o modulo governado antes da revisao humana.", - "Enquanto a versao estiver em draft, ela permanece fora da aprovacao e da ativacao.", + "Confirme se a proposta ja recebeu a decisao humana necessaria antes de consumir a geracao de codigo.", + "Depois da autorizacao, execute a pipeline para produzir o modulo governado antes da revisao humana.", ], ToolLifecycleStatus.GENERATED: [ "Analise o codigo completo gerado, confirme a leitura manual e registre a revisao da diretoria.", @@ -2866,6 +3509,9 @@ class ToolManagementService: def _serialize_governance_history_entry(artifact) -> dict: payload = dict(artifact.payload_json or {}) label_by_kind = { + ToolArtifactKind.GENERATION_AUTHORIZATION: "Autorizacao de geracao registrada", + ToolArtifactKind.GENERATION_CHANGE_REQUEST: "Ajustes solicitados pela diretoria", + ToolArtifactKind.PROPOSAL_CLOSURE: "Proposta encerrada pela diretoria", ToolArtifactKind.DIRECTOR_REVIEW: "Revisao humana registrada", ToolArtifactKind.DIRECTOR_APPROVAL: "Aprovacao humana registrada", ToolArtifactKind.PUBLICATION_RELEASE: "Publicacao administrativa registrada", @@ -2885,10 +3531,49 @@ class ToolManagementService: "recorded_at": artifact.updated_at or artifact.created_at, } - @staticmethod - def _build_review_gate(status: ToolLifecycleStatus) -> str: + def _get_latest_governance_artifact( + self, + tool_version_id: int, + *, + artifact_kinds: tuple[ToolArtifactKind, ...] | None = None, + ): + if self.artifact_repository is None: + return None + artifacts = self.artifact_repository.list_artifacts( + tool_version_id=tool_version_id, + artifact_stage=ToolArtifactStage.GOVERNANCE, + ) + if artifact_kinds is not None: + allowed_kinds = set(artifact_kinds) + artifacts = [artifact for artifact in artifacts if artifact.artifact_kind in allowed_kinds] + return artifacts[0] if artifacts else None + + def _can_runner_execute_generation( + self, + version: ToolVersion, + runner_role: StaffRole | str, + ) -> bool: + normalized_role = normalize_staff_role(runner_role) + if version.status == ToolLifecycleStatus.FAILED: + return True + if role_has_permission(normalized_role, AdminPermission.REVIEW_TOOL_GENERATIONS): + return True + return self._build_review_gate(version) in {"generation_pipeline_required", "changes_requested"} + + def _build_review_gate(self, version: ToolVersion) -> str: + status = version.status + if status == ToolLifecycleStatus.DRAFT: + latest_generation_gate_artifact = self._get_latest_governance_artifact( + version.id, + artifact_kinds=_GENERATION_GATE_ARTIFACT_KINDS, + ) + if latest_generation_gate_artifact is not None: + if latest_generation_gate_artifact.artifact_kind == ToolArtifactKind.GENERATION_CHANGE_REQUEST: + return "changes_requested" + if latest_generation_gate_artifact.artifact_kind == ToolArtifactKind.GENERATION_AUTHORIZATION: + return "generation_pipeline_required" + return "generation_decision_required" gate_by_status = { - ToolLifecycleStatus.DRAFT: "generation_pipeline_required", ToolLifecycleStatus.GENERATED: "validation_required", ToolLifecycleStatus.VALIDATED: "director_approval_required", ToolLifecycleStatus.APPROVED: "director_publication_required", diff --git a/admin_app/view/rendering.py b/admin_app/view/rendering.py index ebe2564..9207956 100644 --- a/admin_app/view/rendering.py +++ b/admin_app/view/rendering.py @@ -1,4 +1,4 @@ -from html import escape +from html import escape from admin_app.view.view_models import ( AdminBotMonitoringPageView, @@ -554,9 +554,9 @@ def render_tool_review_page( Governanca de tools Revisao e ativacao -

Fluxo visual de aprovacao no painel

+

Fluxo governado de proposta, codigo e ativacao

- Esta tela conecta a sessao web do painel aos snapshots administrativos de tools para que o time consiga revisar a fila, conferir contratos e acompanhar o catalogo ativo. + Esta tela conecta a sessao web do painel a cada etapa da proposta: triagem para gerar, iteracoes de codigo, decisao humana e catalogo ativo do produto.

@@ -578,7 +578,7 @@ def render_tool_review_page(

Fila de revisao

0
-

Items aguardando leitura tecnica ou aprovacao humana.

+

Propostas aguardando triagem, leitura tecnica ou aprovacao humana.

@@ -607,7 +607,7 @@ def render_tool_review_page(

Pipeline visual

Etapas que a tela acompanha

-

Os cards abaixo resumem o trajeto de uma tool desde a analise ate a ativacao no produto.

+

Os cards abaixo resumem o trajeto da proposta, da triagem para geracao e das iteracoes de codigo ate a ativacao no produto.

@@ -623,8 +623,8 @@ def render_tool_review_page(

Fila atual

-

Revisao tecnica e aprovacao

-

A fila abaixo e lida da superficie web do painel e respeita o papel da sessao autenticada.

+

Triagem e revisao por etapa

+

A fila abaixo mostra em que gate cada proposta esta e qual decisao humana ou tecnica vem a seguir.

Bootstrap
@@ -644,7 +644,7 @@ def render_tool_review_page(

Checklist de aprovacao

Playbook para a decisao humana

-

Aprovacao e ativacao continuam controladas pelo papel administrativo e pela leitura do contrato compartilhado.

+

Triagem, leitura do codigo, aprovacao e ativacao continuam controladas pelo papel administrativo e pelo contrato compartilhado.

@@ -678,7 +678,7 @@ def render_tool_review_page(

Detalhe da versao

Revisao humana antes da ativacao

-

Selecione uma versao da fila para validar o contrato, inspecionar o codigo completo gerado e registrar a decisao da diretoria.

+

Selecione uma proposta da fila para triar a geracao, inspecionar a iteracao atual do codigo e registrar a decisao da diretoria.

Nenhum item
@@ -723,14 +723,14 @@ def render_tool_review_page(
- + -
Use este campo para revisar a implementacao completa antes de validar, aprovar e ativar a nova tool.
+
Use este campo para revisar a implementacao completa da iteracao atual antes de solicitar ajustes, aprovar ou ativar a tool.
- +
As notas da decisao ficam persistidas na trilha administrativa da versao.
@@ -742,9 +742,13 @@ def render_tool_review_page(
- - - + + + + + + +
@@ -1009,7 +1013,7 @@ def render_tool_intake_page(
Ir para revisao @@ -1068,14 +1072,14 @@ def render_tool_intake_page(

Preview do draft

-

Resultado da validacao

+

Resultado da proposta

Aguardando
-

Nenhum pre-cadastro validado ainda

-

Assim que o formulario for validado, o resumo do draft aparece aqui com avisos e proximos passos.

+

Nenhuma proposta criada ainda

+

Assim que o formulario for enviado, o resumo do draft aparece aqui com os proximos passos de triagem antes da geracao.

diff --git a/admin_app/view/router.py b/admin_app/view/router.py index 8c26d97..25a73ea 100644 --- a/admin_app/view/router.py +++ b/admin_app/view/router.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, Request from fastapi.responses import HTMLResponse, RedirectResponse, Response from admin_app.api.dependencies import get_optional_panel_staff_context @@ -673,7 +673,7 @@ def _build_tool_intake_view( app_name=settings.admin_app_name, title="Cadastro de nova tool", subtitle=( - "Formulario guiado para o colaborador estruturar uma nova tool, validar o pre-draft e encaminhar a proposta para revisao de diretor." + "Formulario guiado para estruturar uma proposta de tool, registrar o draft versionado e encaminhar a triagem humana antes de qualquer geracao de codigo." ), environment=settings.admin_environment, version=settings.admin_version, @@ -711,9 +711,9 @@ def _build_tool_review_view(request: Request, settings: AdminSettings) -> AdminT return AdminToolReviewPageView( app_name=settings.admin_app_name, - title="Revisao, aprovacao e ativacao", + title="Triagem, revisao e ativacao", subtitle=( - "Hub visual para o time interno acompanhar a fila de revisao, validar o contrato compartilhado e inspecionar o catalogo de tools ativas antes da ativacao." + "Hub visual para acompanhar a proposta da tool desde a triagem para geracao, passando pelas iteracoes de codigo, ate a ativacao controlada no catalogo do produto." ), environment=settings.admin_environment, version=settings.admin_version, @@ -725,54 +725,54 @@ def _build_tool_review_view(request: Request, settings: AdminSettings) -> AdminT workflow=( AdminToolReviewWorkflowStep( eyebrow="Cadastro manual", - title="Persistir o draft", - description="Receber o cadastro manual da tool e consolidar a versao administrativa inicial.", + title="Criar a proposta", + description="Receber o cadastro manual da tool e consolidar o draft administrativo sem consumir geracao de codigo.", status_label="Draft", status_variant="info", ), AdminToolReviewWorkflowStep( - eyebrow="Pipeline", - title="Executar geracao", - description="Rodar a etapa de geracao da implementacao isolada antes da validacao da versao.", - status_label="Geracao", + eyebrow="Triagem humana", + title="Triar antes de gerar", + description="Decidir se a proposta realmente merece consumir geracao de codigo ou deve ser encerrada antes disso.", + status_label="Triagem", status_variant="warning", ), AdminToolReviewWorkflowStep( - eyebrow="Validacao", - title="Conferir a versao gerada", - description="Validar a versao produzida pelo pipeline antes da aprovacao humana da diretoria.", - status_label="Validacao", + eyebrow="Pipeline", + title="Gerar ou refatorar", + description="Executar a geracao isolada, registrar a iteracao correspondente e validar automaticamente o codigo antes da leitura humana.", + status_label="Geracao", status_variant="info", ), AdminToolReviewWorkflowStep( eyebrow="Decisao humana", - title="Aprovar com criterio", - description="A diretoria revisa a versao validada e decide se ela pode seguir para publicacao controlada.", - status_label="Aprovacao", + title="Ler e decidir", + description="A diretoria revisa a iteracao atual do codigo, pede ajustes quando necessario ou valida a versao para aprovacao final.", + status_label="Revisao", status_variant="warning", ), AdminToolReviewWorkflowStep( eyebrow="Publicacao", - title="Ativar no catalogo", - description="Usar o catalogo publicado como referencia para a versao que chega ao runtime de produto.", + title="Ativar com governanca", + description="Publicar apenas a iteracao aprovada e sincronizar o catalogo que abastece o runtime de produto.", status_label="Ativacao", status_variant="success", ), ), review_notes=( - "Conferir se o gate do item combina com o estado esperado do lifecycle.", - "Observar se a descricao e o objetivo operacional da tool estao claros para o time.", - "Ler o codigo completo gerado antes de validar manualmente a versao.", + "Conferir se a proposta esta no gate correto: triagem para geracao, ajustes solicitados ou leitura do codigo atual.", + "Observar se a descricao, o objetivo operacional e os parametros deixam claro o valor de negocio da tool.", + "Ler o codigo completo da iteracao atual antes de validar, solicitar ajustes ou encerrar a proposta.", ), approval_notes=( - "Verificar nome, descricao e semantica dos parametros antes da aprovacao.", - "Confirmar se a tool respeita a separacao entre admin e product definida nas ADRs.", - "Checar se a publicacao planejada e auditavel e segura para o runtime de produto.", + "Verificar nome, descricao, semantica dos parametros e a iteracao que esta sendo aprovada antes da ativacao.", + "Confirmar se a tool respeita a separacao entre admin e product definida nas ADRs e nos contratos compartilhados.", + "Checar se a publicacao planejada e auditavel, segura e vinculada ao codigo mais recente revisado pela diretoria.", ), activation_notes=( - "Publicacoes ativas exigem papel com permissao publish_tools.", - "A leitura do catalogo e feita via sessao web do painel para facilitar a operacao do navegador.", - "Sem permissao de publicacao, a tela continua util para revisao, mas bloqueia o catalogo ativo.", + "Publicacoes ativas exigem papel com permissao publish_tools e aprovacao humana vinculada a iteracao mais recente.", + "A leitura do catalogo e feita via sessao web do painel para facilitar a operacao do navegador e a auditoria do fluxo.", + "Sem permissao de publicacao, a tela continua util para triagem e revisao, mas bloqueia a ativacao no catalogo ativo.", ), ) diff --git a/admin_app/view/static/scripts/panel.js b/admin_app/view/static/scripts/panel.js index a354cc9..dc14708 100644 --- a/admin_app/view/static/scripts/panel.js +++ b/admin_app/view/static/scripts/panel.js @@ -1,4 +1,4 @@ -document.documentElement.dataset.panelReady = "true"; +document.documentElement.dataset.panelReady = "true"; const loginForm = document.querySelector('[data-admin-login-form="true"]'); const reviewBoard = document.querySelector('[data-admin-tool-review-board="true"]'); @@ -126,9 +126,13 @@ function mountToolReviewBoard(board) { const decisionNotes = board.querySelector("[data-tool-review-decision-notes]"); const decisionHint = board.querySelector("[data-tool-review-decision-hint]"); const reviewedGeneratedCode = board.querySelector("[data-tool-review-reviewed-code]"); + const authorizeGenerationButton = board.querySelector('[data-tool-review-action="authorize-generation"]'); + const runPipelineButton = board.querySelector('[data-tool-review-action="run-pipeline"]'); const reviewButton = board.querySelector('[data-tool-review-action="review"]'); + const requestChangesButton = board.querySelector('[data-tool-review-action="request-changes"]'); const approveButton = board.querySelector('[data-tool-review-action="approve"]'); const publishButton = board.querySelector('[data-tool-review-action="publish"]'); + const closeProposalButton = board.querySelector('[data-tool-review-action="close"]'); const deactivateButton = board.querySelector('[data-tool-review-action="deactivate"]'); const rollbackButton = board.querySelector('[data-tool-review-action="rollback"]'); @@ -178,7 +182,7 @@ function mountToolReviewBoard(board) { }); } - [reviewButton, approveButton, publishButton, deactivateButton, rollbackButton].forEach((button) => { + [authorizeGenerationButton, runPipelineButton, reviewButton, requestChangesButton, approveButton, publishButton, closeProposalButton, deactivateButton, rollbackButton].forEach((button) => { if (!(button instanceof HTMLButtonElement)) { return; } @@ -293,10 +297,13 @@ function mountToolReviewBoard(board) { decision_notes: String(decisionNotes?.value || "").trim(), reviewed_generated_code: Boolean(reviewedGeneratedCode?.checked), }; - } else if (actionKey === "approve" || actionKey === "deactivate" || actionKey === "rollback") { + } else if (["authorize-generation", "request-changes", "approve", "deactivate", "rollback"].includes(actionKey)) { payload = { decision_notes: String(decisionNotes?.value || "").trim(), }; + } else if (actionKey === "close") { + const normalizedNotes = String(decisionNotes?.value || "").trim(); + payload = normalizedNotes ? { decision_notes: normalizedNotes } : {}; } toggleActionLoading(actionKey, true); @@ -337,11 +344,33 @@ function mountToolReviewBoard(board) { if (!encodedVersionId) { return ""; } + const reviewQueueBase = String(board.dataset.reviewQueueEndpoint || "").replace(/\/+$/, ""); + const publicationsBase = String(board.dataset.publicationsEndpoint || "").replace(/\/+$/, ""); + const draftsBase = reviewQueueBase.endsWith("/review-queue") + ? `${reviewQueueBase.slice(0, -"/review-queue".length)}/drafts` + : ""; + const pipelineBase = reviewQueueBase.endsWith("/review-queue") + ? `${reviewQueueBase.slice(0, -"/review-queue".length)}/pipeline` + : ""; + const currentGate = String(lastRenderedHumanGate?.current_gate || "").trim(); + const usesDraftGovernanceRoute = ["generation_decision_required", "generation_pipeline_required", "changes_requested"].includes(currentGate); if (actionKey === "publish" || actionKey === "deactivate" || actionKey === "rollback") { - return `${board.dataset.publicationsEndpoint}/${encodedVersionId}/${actionKey}`; + return `${publicationsBase}/${encodedVersionId}/${actionKey}`; + } + if (actionKey === "review" || actionKey === "approve" || actionKey === "request-changes") { + return `${reviewQueueBase}/${encodedVersionId}/${actionKey}`; } - if (actionKey === "review" || actionKey === "approve") { - return `${board.dataset.reviewQueueEndpoint}/${encodedVersionId}/${actionKey}`; + if (actionKey === "authorize-generation") { + return draftsBase ? `${draftsBase}/${encodedVersionId}/authorize-generation` : ""; + } + if (actionKey === "run-pipeline") { + return pipelineBase ? `${pipelineBase}/${encodedVersionId}/run` : ""; + } + if (actionKey === "close") { + if (usesDraftGovernanceRoute && draftsBase) { + return `${draftsBase}/${encodedVersionId}/close`; + } + return `${reviewQueueBase}/${encodedVersionId}/close`; } return ""; } @@ -357,9 +386,13 @@ function mountToolReviewBoard(board) { function toggleActionLoading(actionKey, isLoading) { const buttonsByAction = { + "authorize-generation": authorizeGenerationButton, + "run-pipeline": runPipelineButton, review: reviewButton, + "request-changes": requestChangesButton, approve: approveButton, publish: publishButton, + close: closeProposalButton, deactivate: deactivateButton, rollback: rollbackButton, }; @@ -410,6 +443,76 @@ function mountToolReviewBoard(board) { parameterTypes.innerHTML = `Bloqueado`; } + function describeReviewGate(gate) { + const normalizedGate = String(gate || "").trim(); + const gateMap = { + generation_decision_required: { + label: "Aguardando triagem para gerar", + description: "A proposta foi criada, mas ainda nao deve consumir geracao de codigo." + }, + generation_pipeline_required: { + label: "Pronta para gerar", + description: "A triagem humana autorizou a pipeline. Falta executar a geracao isolada." + }, + generation_pipeline_queued: { + label: "Pipeline enfileirada", + description: "O worker do admin ja recebeu a geracao e vai iniciar a iteracao assim que houver slot." + }, + generation_pipeline_running: { + label: "Pipeline em execucao", + description: "O worker do admin esta gerando ou validando a iteracao atual desta proposta." + }, + generation_worker_failed: { + label: "Worker falhou", + description: "A execucao ass?ncrona falhou antes de concluir a pipeline." + }, + changes_requested: { + label: "Ajustes solicitados", + description: "A mesma versao aguarda uma nova iteracao guiada pelo ultimo parecer humano." + }, + validation_required: { + label: "Codigo aguardando leitura", + description: "A geracao terminou e o codigo atual precisa de leitura humana da diretoria." + }, + director_approval_required: { + label: "Aguardando aprovacao final", + description: "A leitura do codigo ja aconteceu e falta a aprovacao formal antes da ativacao." + }, + director_publication_required: { + label: "Pronta para ativacao", + description: "A iteracao atual ja foi revisada e aprovada. Falta ativar no catalogo governado." + }, + publication_active: { + label: "Publicacao ativa", + description: "A versao ja abastece o catalogo governado do produto." + }, + pipeline_retry_required: { + label: "Retry tecnico necessario", + description: "A pipeline falhou tecnicamente e precisa de nova execucao antes de voltar para a revisao humana." + }, + archived_history: { + label: "Proposta arquivada", + description: "A proposta saiu da esteira ativa e permanece apenas para historico e auditoria." + } + }; + return gateMap[normalizedGate] || { + label: normalizedGate || "Governanca pendente", + description: "A proposta continua no fluxo governado aguardando a proxima decisao humana ou tecnica." + }; + } + + function describeGenerationMode(mode) { + const normalizedMode = String(mode || "").trim(); + const modeMap = { + initial_generation: "Geracao inicial", + change_request_refinement: "Refatoracao guiada por feedback", + failed_pipeline_retry: "Retry tecnico da pipeline", + regeneration_with_context: "Regeneracao com contexto anterior", + legacy_generation: "Geracao legada" + }; + return modeMap[normalizedMode] || normalizedMode || "Nenhuma geracao concluida ainda"; + } + function renderReviewQueue(payload, preferredVersionId = "") { const items = Array.isArray(payload?.items) ? payload.items : []; setText("[data-tool-review-queue-count]", String(items.length)); @@ -417,13 +520,17 @@ function mountToolReviewBoard(board) { queueList.innerHTML = items.length > 0 ? items.map((item) => { const isSelected = String(item?.version_id || "") === String(preferredVersionId || selectedVersionId || ""); + const gatePresentation = describeReviewGate(item?.gate); const validationSummary = item?.automated_validation_summary ? `
Pipeline: ${escapeHtml(item.automated_validation_summary)}
` : ""; + const ownerMarkup = item?.owner_name + ? `
Owner: ${escapeHtml(item.owner_name)}
` + : ""; const queuedAt = item?.queued_at ? `
Atualizado: ${escapeHtml(formatDateTime(item.queued_at))}
` : ""; - return `
${escapeHtml(item.gate || "revisao")}

${escapeHtml(item.display_name || item.tool_name || "Tool")}

${escapeHtml(item.tool_name || "")}
${escapeHtml(item.status || "pendente")}

${escapeHtml(item.summary || payload?.message || "Item aguardando analise do time.")}

${validationSummary}${queuedAt}
`; + return `
${escapeHtml(gatePresentation.label)}

${escapeHtml(item.display_name || item.tool_name || "Tool")}

${escapeHtml(item.tool_name || "")}
${escapeHtml(item.status || "pendente")}

${escapeHtml(item.summary || payload?.message || "Item aguardando analise do time.")}

${escapeHtml(gatePresentation.description)}
${validationSummary}${ownerMarkup}${queuedAt}
`; }).join("") : `

Fila sem itens no momento

${escapeHtml(payload?.message || "Nenhuma tool aguardando revisao agora.")}

`; } @@ -542,6 +649,7 @@ function mountToolReviewBoard(board) { const history = Array.isArray(payload?.decision_history) ? payload.decision_history : []; const nextSteps = Array.isArray(payload?.next_steps) ? payload.next_steps : []; const humanGate = payload?.human_gate || null; + const generationContext = payload?.generation_context || null; const hasSourceCode = Boolean(String(payload?.generated_source_code || "").trim()); detailStatus.textContent = payload?.status || "versao"; @@ -551,6 +659,11 @@ function mountToolReviewBoard(board) { const parameterMarkup = parameters.length > 0 ? parameters.map((item) => `
${escapeHtml(item.name || "parametro")}
${escapeHtml(item.description || "")}
${escapeHtml(item.parameter_type || "string")}${item.required ? " *" : ""}
`).join("") : `
Esta versao nao declarou parametros de entrada.
`; + const latestGenerationMode = describeGenerationMode(generationContext?.latest_generation_mode); + const nextGenerationMode = describeGenerationMode(generationContext?.next_generation_mode || "initial_generation"); + const changeRequestMarkup = generationContext?.latest_change_request_notes + ? `
Ultimo parecer para ajuste: ${escapeHtml(generationContext.latest_change_request_notes)}
` + : ""; detailMeta.innerHTML = `
Tool: ${escapeHtml(payload?.tool_name || "-")}
@@ -560,6 +673,12 @@ function mountToolReviewBoard(board) {
Modulo: ${escapeHtml(payload?.generated_module || "-")}
Entrypoint: ${escapeHtml(payload?.generated_callable || "run")}
Resumo da pipeline: ${escapeHtml(payload?.automated_validation_summary || "Sem resumo de validacao automatica.")}
+
Ultima iteracao de geracao: ${escapeHtml(String(generationContext?.latest_generation_iteration || 0))}
+
Modo da ultima geracao: ${escapeHtml(latestGenerationMode)}
+
Proxima iteracao prevista: ${escapeHtml(String(generationContext?.next_generation_iteration || 1))}
+
Proxima execucao da pipeline: ${escapeHtml(nextGenerationMode)}
+
Total de iteracoes registradas: ${escapeHtml(String(generationContext?.generation_iterations_count || 0))}
+ ${changeRequestMarkup}
${parameterMarkup}
Objetivo de negocio: ${escapeHtml(payload?.business_goal || "Nao informado")}
@@ -608,7 +727,7 @@ function mountToolReviewBoard(board) { reviewedGeneratedCode.checked = false; } if (decisionHint instanceof HTMLElement) { - decisionHint.textContent = buildDecisionHint(humanGate, hasSourceCode); + decisionHint.textContent = buildDecisionHint(humanGate, hasSourceCode, generationContext); } configureActionPanel(humanGate, hasSourceCode); } @@ -616,9 +735,13 @@ function mountToolReviewBoard(board) { function configureActionPanel(humanGate, hasSourceCode) { lastRenderedHumanGate = humanGate; lastRenderedHasSourceCode = hasSourceCode; + configureActionButton(authorizeGenerationButton, Boolean(humanGate?.authorize_generation_action_available)); + configureActionButton(runPipelineButton, Boolean(humanGate?.run_pipeline_action_available)); configureActionButton(reviewButton, Boolean(humanGate?.review_action_available) && hasSourceCode); + configureActionButton(requestChangesButton, Boolean(humanGate?.request_changes_action_available) && hasSourceCode); configureActionButton(approveButton, Boolean(humanGate?.approval_action_available)); configureActionButton(publishButton, Boolean(humanGate?.publication_action_available)); + configureActionButton(closeProposalButton, Boolean(humanGate?.close_proposal_action_available)); configureActionButton(deactivateButton, Boolean(humanGate?.deactivation_action_available)); configureActionButton(rollbackButton, Boolean(humanGate?.rollback_action_available)); @@ -646,16 +769,31 @@ function mountToolReviewBoard(board) { button.disabled = !isEnabled; } - function buildDecisionHint(humanGate, hasSourceCode) { + function buildDecisionHint(humanGate, hasSourceCode, generationContext) { if (!humanGate) { return "As notas da decisao ficam persistidas na trilha administrativa da versao."; } + if (humanGate.authorize_generation_action_available) { + return "A proposta ainda esta em draft. Registre um parecer e autorize a geracao somente quando fizer sentido consumir codigo."; + } + if (humanGate.run_pipeline_action_available) { + return `A proposta ja pode seguir para a pipeline. A proxima execucao prevista e ${escapeHtml(describeGenerationMode(generationContext?.next_generation_mode || "initial_generation")).toLowerCase()} na iteracao ${escapeHtml(String(generationContext?.next_generation_iteration || 1))}.`; + } + if (humanGate.current_gate === "generation_pipeline_required") { + return `A triagem humana ja autorizou a pipeline. A proxima execucao prevista e ${escapeHtml(describeGenerationMode(generationContext?.next_generation_mode || "initial_generation")).toLowerCase()} na iteracao ${escapeHtml(String(generationContext?.next_generation_iteration || 1))}.`; + } + if (humanGate.current_gate === "changes_requested") { + return `A diretoria solicitou ajustes. A proxima execucao prevista e ${escapeHtml(describeGenerationMode(generationContext?.next_generation_mode || "change_request_refinement")).toLowerCase()} na iteracao ${escapeHtml(String(generationContext?.next_generation_iteration || 1))}.`; + } if (humanGate.review_action_available && !hasSourceCode) { return "A revisao humana fica habilitada assim que o codigo completo gerado estiver disponivel para leitura."; } if (humanGate.review_action_available) { return "Para validar a versao, registre o parecer e confirme explicitamente que o codigo completo foi revisado."; } + if (humanGate.request_changes_action_available) { + return "Se o codigo ainda nao estiver bom, registre o parecer para solicitar ajustes ou encerre a proposta sem seguir para ativacao."; + } if (humanGate.approval_action_available) { return "A aprovacao formal ainda exige um parecer explicito da diretoria antes da publicacao."; } @@ -790,7 +928,7 @@ function mountToolIntakePage(page) { function toggleSubmitting(isLoading) { submitButton.disabled = isLoading; submitSpinner.classList.toggle("d-none", !isLoading); - submitLabel.textContent = isLoading ? "Validando..." : "Validar pre-cadastro"; + submitLabel.textContent = isLoading ? "Criando..." : "Criar proposta"; } function clearFeedback() { @@ -831,7 +969,7 @@ function mountToolIntakePage(page) {
Aprovacao: ${draft?.requires_director_approval ? "Diretor obrigatorio" : "Nao"}
${submissionPolicy - ? `
Governanca desta submissao
Modo: ${escapeHtml(submissionPolicy.mode || "draft_only")}. Papel atual: ${escapeHtml(submissionPolicy.submitter_role || "nao informado")}. Publicacao direta: ${submissionPolicy.direct_publication_blocked ? "bloqueada neste fluxo" : "permitida"}. Permissao final de publicacao: ${escapeHtml(submissionPolicy.required_publish_permission || "publish_tools")}.<\/div><\/div>` + ? `
Governanca desta proposta
Modo: ${escapeHtml(submissionPolicy.mode || "draft_only")}. Papel atual: ${escapeHtml(submissionPolicy.submitter_role || "nao informado")}. Esta tela apenas cria o draft administrativo; a geracao de codigo continua bloqueada ate a triagem humana. Permissao final de publicacao: ${escapeHtml(submissionPolicy.required_publish_permission || "publish_tools")}.<\/div><\/div>` : ""}
${parameters.length > 0 @@ -843,7 +981,7 @@ function mountToolIntakePage(page) {
Avisos
${warnings.length > 0 ? `
    ${warnings.map((item) => `
  • ${escapeHtml(item)}
  • `).join("")}
` - : `
Nenhum aviso extra para este pre-cadastro.
`} + : `
Nenhum aviso extra para esta proposta.
`}
Proximos passos
diff --git a/tests/test_admin_panel_tools_web.py b/tests/test_admin_panel_tools_web.py index 4b3d2ea..f76c8fb 100644 --- a/tests/test_admin_panel_tools_web.py +++ b/tests/test_admin_panel_tools_web.py @@ -1,4 +1,4 @@ -import unittest +import unittest from datetime import datetime, timezone from fastapi.testclient import TestClient @@ -397,8 +397,11 @@ class AdminPanelToolsWebTests(unittest.TestCase): self.assertEqual(payload["submission_policy"]["mode"], "draft_only") self.assertEqual(payload["submission_policy"]["submitter_role"], "colaborador") self.assertFalse(payload["submission_policy"]["submitter_can_publish_now"]) + self.assertFalse(payload["submission_policy"]["submitter_can_authorize_generation_now"]) self.assertTrue(payload["submission_policy"]["direct_publication_blocked"]) + self.assertTrue(payload["submission_policy"]["requires_generation_authorization"]) self.assertEqual(payload["submission_policy"]["required_approver_role"], "diretor") + self.assertEqual(payload["submission_policy"]["required_generation_permission"], "review_tool_generations") self.assertEqual(payload["submission_policy"]["required_publish_permission"], "publish_tools") self.assertEqual(payload["draft_preview"]["status"], "draft") self.assertEqual(payload["draft_preview"]["tool_name"], "consultar_vendas_periodo") @@ -557,10 +560,13 @@ class AdminPanelToolsWebTests(unittest.TestCase): payload = response.json() self.assertEqual(payload["tool_name"], "emitir_resumo_locacao") self.assertTrue(payload["human_gate"]["review_action_available"]) + self.assertEqual(payload["generation_context"]["latest_generation_iteration"], 1) + self.assertEqual(payload["generation_context"]["generation_iterations_count"], 1) + self.assertEqual(payload["generation_context"]["latest_generation_mode"], "initial_generation") self.assertIn("async def run", payload["generated_source_code"]) self.assertEqual(len(payload["automated_validations"]), 4) - def test_panel_tools_collaborator_can_run_generation_pipeline_after_manual_intake(self): + def test_panel_tools_director_can_authorize_generation_for_collaborator_draft(self): client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) try: intake_response = client.post( @@ -575,26 +581,117 @@ class AdminPanelToolsWebTests(unittest.TestCase): }, ) version_id = intake_response.json()["draft_preview"]["version_id"] - response = client.post(f"/admin/panel/tools/pipeline/{version_id}/run") + app.dependency_overrides[get_current_panel_staff_principal] = lambda: AuthenticatedStaffPrincipal( + id=11, + email="diretor@empresa.com", + display_name="Diretoria", + role=StaffRole.DIRETOR, + is_active=True, + ) + response = client.post( + f"/admin/panel/tools/drafts/{version_id}/authorize-generation", + json={"decision_notes": "A proposta pode seguir para geracao governada apos triagem inicial."}, + ) + detail_response = client.get(f"/admin/panel/tools/review-queue/{version_id}") finally: app.dependency_overrides.clear() self.assertEqual(intake_response.status_code, 200) self.assertEqual(response.status_code, 200) - payload = response.json() - self.assertEqual(payload["status"], "generated") - self.assertEqual(payload["current_step"], "validation") - self.assertEqual(payload["queue_entry"]["gate"], "validation_required") - self.assertEqual(payload["queue_entry"]["automated_validation_status"], "passed") - self.assertEqual(payload["queue_entry"]["automated_validation_summary"], "4/4 validacoes automaticas passaram antes da revisao humana.") - automated_checks = {check["key"]: check for check in payload["automated_validations"]} - self.assertEqual(automated_checks["tool_contract"]["status"], "passed") - self.assertEqual(automated_checks["tool_signature_schema"]["status"], "passed") - self.assertEqual(automated_checks["tool_import_loading"]["status"], "passed") - self.assertEqual(automated_checks["tool_smoke_tests"]["status"], "passed") - steps_by_key = {step["key"]: step for step in payload["steps"]} - self.assertEqual(steps_by_key["generation"]["state"], "completed") - self.assertEqual(steps_by_key["validation"]["state"], "current") + self.assertEqual(response.json()["status"], "draft") + self.assertEqual(response.json()["queue_entry"]["gate"], "generation_pipeline_required") + self.assertEqual(detail_response.status_code, 200) + self.assertEqual(detail_response.json()["human_gate"]["current_gate"], "generation_pipeline_required") + self.assertTrue(detail_response.json()["human_gate"]["run_pipeline_action_available"]) + + def test_panel_tools_director_can_request_changes_after_generation(self): + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR) + try: + intake_response = client.post( + "/admin/panel/tools/drafts/intake", + json={ + "domain": "locacao", + "tool_name": "emitir_resumo_locacao", + "display_name": "Emitir resumo de locacao", + "description": "Resume contratos de locacao com filtros operacionais para o time interno.", + "business_goal": "Dar visibilidade rapida aos contratos e aos principais dados da locacao.", + "parameters": [], + }, + ) + version_id = intake_response.json()["draft_preview"]["version_id"] + client.post(f"/admin/panel/tools/pipeline/{version_id}/run") + response = client.post( + f"/admin/panel/tools/review-queue/{version_id}/request-changes", + json={"decision_notes": "O codigo precisa de ajustes antes de voltar para revisao humana."}, + ) + detail_response = client.get(f"/admin/panel/tools/review-queue/{version_id}") + finally: + app.dependency_overrides.clear() + + self.assertEqual(intake_response.status_code, 200) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["status"], "draft") + self.assertEqual(response.json()["queue_entry"]["gate"], "changes_requested") + self.assertEqual(detail_response.status_code, 200) + self.assertEqual(detail_response.json()["human_gate"]["current_gate"], "changes_requested") + self.assertTrue(detail_response.json()["human_gate"]["run_pipeline_action_available"]) + self.assertTrue(detail_response.json()["generation_context"]["pending_change_request"]) + self.assertEqual(detail_response.json()["generation_context"]["next_generation_mode"], "change_request_refinement") + self.assertIn("ajustes", (detail_response.json()["generation_context"]["latest_change_request_notes"] or "").lower()) + + def test_panel_tools_director_can_close_generated_proposal_without_notes(self): + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR) + try: + intake_response = client.post( + "/admin/panel/tools/drafts/intake", + json={ + "domain": "locacao", + "tool_name": "emitir_resumo_locacao", + "display_name": "Emitir resumo de locacao", + "description": "Resume contratos de locacao com filtros operacionais para o time interno.", + "business_goal": "Dar visibilidade rapida aos contratos e aos principais dados da locacao.", + "parameters": [], + }, + ) + version_id = intake_response.json()["draft_preview"]["version_id"] + client.post(f"/admin/panel/tools/pipeline/{version_id}/run") + response = client.post( + f"/admin/panel/tools/review-queue/{version_id}/close", + json={}, + ) + detail_response = client.get(f"/admin/panel/tools/review-queue/{version_id}") + finally: + app.dependency_overrides.clear() + + self.assertEqual(intake_response.status_code, 200) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["status"], "archived") + self.assertEqual(detail_response.status_code, 200) + self.assertEqual(detail_response.json()["status"], "archived") + self.assertFalse(detail_response.json()["human_gate"]["close_proposal_action_available"]) + + def test_panel_tools_collaborator_cannot_run_generation_pipeline_before_director_authorization(self): + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) + try: + intake_response = client.post( + "/admin/panel/tools/drafts/intake", + json={ + "domain": "locacao", + "tool_name": "emitir_resumo_locacao", + "display_name": "Emitir resumo de locacao", + "description": "Resume contratos de locacao com filtros operacionais para o time interno.", + "business_goal": "Dar visibilidade rapida aos contratos e aos principais dados da locacao.", + "parameters": [], + }, + ) + version_id = intake_response.json()["draft_preview"]["version_id"] + response = client.post(f"/admin/panel/tools/pipeline/{version_id}/run") + finally: + app.dependency_overrides.clear() + + self.assertEqual(intake_response.status_code, 200) + self.assertEqual(response.status_code, 409) + self.assertIn("autorizacao de diretor", response.json()["detail"].lower()) def test_panel_tools_publications_require_director_publication_permission(self): client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) diff --git a/tests/test_admin_tool_management_service.py b/tests/test_admin_tool_management_service.py index d827431..1fd8e84 100644 --- a/tests/test_admin_tool_management_service.py +++ b/tests/test_admin_tool_management_service.py @@ -532,7 +532,7 @@ class AdminToolManagementServiceTests(unittest.TestCase): self.assertEqual(len(self.draft_repository.drafts), 1) self.assertEqual(len(self.version_repository.versions), 2) self.assertEqual(len(self.metadata_repository.metadata_entries), 2) - self.assertEqual(len(self.artifact_repository.artifacts), 4) + self.assertEqual(len(self.artifact_repository.artifacts), 6) self.assertEqual(self.draft_repository.drafts[0].current_version_number, 2) self.assertEqual(self.draft_repository.drafts[0].version_count, 2) self.assertEqual(self.draft_repository.drafts[0].owner_display_name, "Coordenacao de Locacao") @@ -672,6 +672,638 @@ class AdminToolManagementServiceTests(unittest.TestCase): self.assertEqual(payload["items"][0]["gate"], "generation_pipeline_required") self.assertIn(ToolLifecycleStatus.APPROVED, payload["supported_statuses"]) + def test_collaborator_draft_requires_director_authorization_before_generation(self): + intake_payload = self.service.create_draft_submission( + { + "domain": "revisao", + "tool_name": "consultar_revisao_aberta", + "display_name": "Consultar revisao aberta", + "description": "Consulta revisoes abertas com filtros administrativos para a oficina.", + "business_goal": "Ajudar o time a localizar revisoes abertas com mais contexto operacional.", + "parameters": [], + }, + owner_staff_account_id=8, + owner_name="Operacao de Oficina", + owner_role=StaffRole.COLABORADOR, + ) + + payload = self.service.build_review_queue_payload() + + self.assertEqual(payload["items"][0]["version_id"], intake_payload["draft_preview"]["version_id"]) + self.assertEqual(payload["items"][0]["gate"], "generation_decision_required") + + detail = self.service.build_review_detail_payload(intake_payload["draft_preview"]["version_id"]) + self.assertEqual(detail["human_gate"]["current_gate"], "generation_decision_required") + self.assertTrue(detail["human_gate"]["authorize_generation_action_available"]) + self.assertFalse(detail["human_gate"]["run_pipeline_action_available"]) + + transition = self.service.authorize_generation( + intake_payload["draft_preview"]["version_id"], + actor_staff_account_id=99, + actor_name="Diretoria", + actor_role=StaffRole.DIRETOR, + decision_notes="A proposta esta alinhada e pode consumir a etapa de geracao governada.", + ) + + self.assertEqual(transition["status"], ToolLifecycleStatus.DRAFT) + self.assertEqual(transition["queue_entry"]["gate"], "generation_pipeline_required") + detail_after_authorization = self.service.build_review_detail_payload(intake_payload["draft_preview"]["version_id"]) + self.assertTrue(detail_after_authorization["human_gate"]["run_pipeline_action_available"]) + + def test_request_changes_returns_generated_version_to_draft_with_changes_gate(self): + intake_payload = self.service.create_draft_submission( + { + "domain": "revisao", + "tool_name": "consultar_revisao_aberta", + "display_name": "Consultar revisao aberta", + "description": "Consulta revisoes abertas com filtros administrativos para a oficina.", + "business_goal": "Ajudar o time a localizar revisoes abertas com mais contexto operacional.", + "parameters": [], + }, + owner_staff_account_id=8, + owner_name="Operacao de Oficina", + ) + version_id = intake_payload["draft_preview"]["version_id"] + + self.service.run_generation_pipeline( + version_id, + runner_staff_account_id=8, + runner_name="Operacao de Oficina", + runner_role=StaffRole.COLABORADOR, + ) + payload = self.service.request_changes( + version_id, + actor_staff_account_id=99, + actor_name="Diretoria", + actor_role=StaffRole.DIRETOR, + decision_notes="A proposta precisa ajustar o codigo e a estrutura antes de nova validacao.", + ) + + self.assertEqual(payload["status"], ToolLifecycleStatus.DRAFT) + self.assertEqual(payload["queue_entry"]["gate"], "changes_requested") + detail = self.service.build_review_detail_payload(version_id) + self.assertEqual(detail["human_gate"]["current_gate"], "changes_requested") + self.assertTrue(detail["human_gate"]["run_pipeline_action_available"]) + self.assertEqual(detail["decision_history"][-1]["action_key"], ToolArtifactKind.GENERATION_CHANGE_REQUEST.value) + + def test_close_proposal_archives_generated_version_without_notes(self): + intake_payload = self.service.create_draft_submission( + { + "domain": "revisao", + "tool_name": "consultar_revisao_aberta", + "display_name": "Consultar revisao aberta", + "description": "Consulta revisoes abertas com filtros administrativos para a oficina.", + "business_goal": "Ajudar o time a localizar revisoes abertas com mais contexto operacional.", + "parameters": [], + }, + owner_staff_account_id=8, + owner_name="Operacao de Oficina", + ) + version_id = intake_payload["draft_preview"]["version_id"] + + self.service.run_generation_pipeline( + version_id, + runner_staff_account_id=8, + runner_name="Operacao de Oficina", + runner_role=StaffRole.COLABORADOR, + ) + payload = self.service.close_proposal( + version_id, + actor_staff_account_id=99, + actor_name="Diretoria", + actor_role=StaffRole.DIRETOR, + decision_notes=None, + ) + + self.assertEqual(payload["status"], ToolLifecycleStatus.ARCHIVED) + self.assertEqual(payload["queue_entry"]["gate"], "archived_history") + detail = self.service.build_review_detail_payload(version_id) + self.assertFalse(detail["human_gate"]["close_proposal_action_available"]) + self.assertEqual(detail["decision_history"][-1]["action_key"], ToolArtifactKind.PROPOSAL_CLOSURE.value) + self.assertIsNone(detail["decision_history"][-1]["decision_notes"]) + + def test_rerun_generation_after_change_request_tracks_new_iteration_without_new_version(self): + intake_payload = self.service.create_draft_submission( + { + "domain": "revisao", + "tool_name": "consultar_revisao_aberta", + "display_name": "Consultar revisao aberta", + "description": "Consulta revisoes abertas com filtros administrativos para a oficina.", + "business_goal": "Ajudar o time a localizar revisoes abertas com mais contexto operacional.", + "parameters": [ + { + "name": "placa", + "parameter_type": "string", + "description": "Placa usada na busca da revisao.", + "required": True, + } + ], + }, + owner_staff_account_id=8, + owner_name="Operacao de Oficina", + owner_role=StaffRole.COLABORADOR, + ) + version_id = intake_payload["draft_preview"]["version_id"] + version_number = intake_payload["draft_preview"]["version_number"] + + self.service.authorize_generation( + version_id, + actor_staff_account_id=99, + actor_name="Diretoria", + actor_role=StaffRole.DIRETOR, + decision_notes="A proposta pode consumir a primeira geracao governada.", + ) + self.service.run_generation_pipeline( + version_id, + runner_staff_account_id=8, + runner_name="Operacao de Oficina", + runner_role=StaffRole.COLABORADOR, + ) + feedback_notes = "Ajuste o retorno para destacar o resumo operacional e preserve a assinatura atual." + self.service.request_changes( + version_id, + actor_staff_account_id=99, + actor_name="Diretoria", + actor_role=StaffRole.DIRETOR, + decision_notes=feedback_notes, + ) + + second_generation = self.service.run_generation_pipeline( + version_id, + runner_staff_account_id=8, + runner_name="Operacao de Oficina", + runner_role=StaffRole.COLABORADOR, + ) + + self.assertEqual(second_generation["version_id"], version_id) + self.assertEqual(second_generation["version_number"], version_number) + self.assertEqual(second_generation["status"], ToolLifecycleStatus.GENERATED) + generation_artifact = next( + artifact + for artifact in self.artifact_repository.artifacts + if artifact.artifact_kind == ToolArtifactKind.GENERATION_REQUEST + ) + self.assertEqual(generation_artifact.payload_json["generation_iteration"], 2) + self.assertEqual(generation_artifact.payload_json["generation_mode"], "change_request_refinement") + self.assertEqual(generation_artifact.payload_json["feedback_notes"], feedback_notes) + self.assertEqual(len(generation_artifact.payload_json["generation_iterations"]), 2) + self.assertEqual( + generation_artifact.payload_json["generation_iterations"][-1]["generation_iteration"], + 2, + ) + validation_artifact = next( + artifact + for artifact in self.artifact_repository.artifacts + if artifact.artifact_kind == ToolArtifactKind.VALIDATION_REPORT + ) + self.assertEqual(validation_artifact.payload_json["generation_iteration"], 2) + self.assertEqual(validation_artifact.payload_json["generation_mode"], "change_request_refinement") + self.assertEqual(validation_artifact.payload_json["change_request_notes"], feedback_notes) + self.assertEqual(len(validation_artifact.payload_json["validation_iterations"]), 2) + self.assertEqual( + validation_artifact.payload_json["validation_iterations"][-1]["generation_iteration"], + 2, + ) + + def test_review_detail_and_runtime_snapshot_recover_legacy_generated_source_from_metadata(self): + intake_payload = self.service.create_draft_submission( + { + "domain": "locacao", + "tool_name": "emitir_resumo_locacao", + "display_name": "Emitir resumo de locacao", + "description": "Resume contratos de locacao com filtros operacionais para o time interno.", + "business_goal": "Dar visibilidade rapida aos contratos e aos principais dados da locacao.", + "parameters": [ + { + "name": "contrato_id", + "parameter_type": "string", + "description": "Identificador do contrato consultado.", + "required": True, + } + ], + }, + owner_staff_account_id=7, + owner_name="Equipe Interna", + owner_role=StaffRole.COLABORADOR, + ) + version_id = intake_payload["draft_preview"]["version_id"] + self.service.authorize_generation( + version_id, + actor_staff_account_id=99, + actor_name="Diretoria", + actor_role=StaffRole.DIRETOR, + decision_notes="A proposta foi autorizada para gerar codigo governado.", + ) + self.service.run_generation_pipeline( + version_id, + runner_staff_account_id=7, + runner_name="Equipe Interna", + runner_role=StaffRole.COLABORADOR, + ) + self.service.review_version( + version_id, + reviewer_staff_account_id=99, + reviewer_name="Diretoria", + reviewer_role=StaffRole.DIRETOR, + decision_notes="Revisao humana concluida para publicar a iteracao legada.", + reviewed_generated_code=True, + ) + self.service.approve_version( + version_id, + approver_staff_account_id=99, + approver_name="Diretoria", + approver_role=StaffRole.DIRETOR, + decision_notes="Aprovacao final registrada para publicacao governada.", + ) + + version = self.version_repository.get_by_version_id(version_id) + validation_artifact = self.artifact_repository.get_by_tool_version_and_kind( + version.id, + ToolArtifactKind.VALIDATION_REPORT, + ) + generation_artifact = self.artifact_repository.get_by_tool_version_and_kind( + version.id, + ToolArtifactKind.GENERATION_REQUEST, + ) + validation_payload = dict(validation_artifact.payload_json or {}) + validation_payload.pop("generated_source_code", None) + validation_payload.pop("generated_source_checksum", None) + validation_artifact.payload_json = validation_payload + generation_payload = dict(generation_artifact.payload_json or {}) + generation_payload.pop("generation_iterations", None) + generation_payload.pop("latest_generation", None) + generation_payload.pop("generation_mode", None) + generation_artifact.payload_json = generation_payload + + detail = self.service.build_review_detail_payload(version_id) + self.assertIn("async def run", detail["generated_source_code"]) + self.assertEqual(detail["generation_context"]["latest_generation_mode"], "legacy_generation") + self.assertTrue(self.service._version_has_generated_source(version_id)) + + import shutil + from pathlib import Path + + sandbox_root = Path.cwd() / ".tmp_test_admin_legacy_runtime_snapshot" + shutil.rmtree(sandbox_root, ignore_errors=True) + package_dir = sandbox_root / GENERATED_TOOLS_PACKAGE + manifest_path = package_dir / "published_runtime_tools.json" + + def build_file_path(tool_name: str): + return package_dir / f"{tool_name}.py" + + try: + with patch( + "admin_app.services.tool_management_service.get_generated_tools_runtime_dir", + return_value=package_dir, + ), patch( + "admin_app.services.tool_management_service.get_generated_tool_publication_manifest_path", + return_value=manifest_path, + ), patch( + "admin_app.services.tool_management_service.build_generated_tool_file_path", + side_effect=build_file_path, + ): + payload = self.service.publish_version( + version_id, + publisher_staff_account_id=99, + publisher_name="Diretoria", + publisher_role=StaffRole.DIRETOR, + ) + self.assertEqual(payload["status"], ToolLifecycleStatus.ACTIVE) + self.assertTrue(build_file_path("emitir_resumo_locacao").exists()) + self.assertIn( + "async def run", + build_file_path("emitir_resumo_locacao").read_text(encoding="utf-8"), + ) + finally: + shutil.rmtree(sandbox_root, ignore_errors=True) + + def test_failed_generation_without_validation_does_not_reconstruct_legacy_source(self): + class _FailingGenerationService: + async def generate_tool_source(self, **kwargs): + return { + "generated_source_code": None, + "generation_model_used": "gemini-3-pro-preview", + "issues": ["Vertex AI indisponivel para gerar o codigo nesta tentativa."], + "prompt_rendered": "prompt de geracao com falha", + "elapsed_ms": 17.0, + } + + self.service.tool_generation_service = _FailingGenerationService() + intake_payload = self.service.create_draft_submission( + { + "domain": "revisao", + "tool_name": "consultar_revisao_aberta", + "display_name": "Consultar revisao aberta", + "description": "Consulta revisoes abertas com filtros administrativos para a oficina.", + "business_goal": "Ajudar o time a localizar revisoes abertas com mais contexto operacional.", + "parameters": [], + }, + owner_staff_account_id=8, + owner_name="Operacao de Oficina", + owner_role=StaffRole.COLABORADOR, + ) + version_id = intake_payload["draft_preview"]["version_id"] + + self.service.authorize_generation( + version_id, + actor_staff_account_id=99, + actor_name="Diretoria", + actor_role=StaffRole.DIRETOR, + decision_notes="A proposta pode seguir para geracao governada apos triagem inicial.", + ) + + payload = self.service.run_generation_pipeline( + version_id, + runner_staff_account_id=8, + runner_name="Operacao de Oficina", + runner_role=StaffRole.COLABORADOR, + ) + + self.assertEqual(payload["status"], ToolLifecycleStatus.FAILED) + detail = self.service.build_review_detail_payload(version_id) + self.assertEqual(detail["generated_source_code"], "") + self.assertFalse(detail["generation_context"]["has_previous_generation"]) + self.assertFalse(self.service._version_has_generated_source(version_id)) + + version = self.version_repository.get_by_version_id(version_id) + with self.assertRaisesRegex(RuntimeError, "codigo gerado"): + self.service._get_generated_source_code_for_version(version.id) + + def test_publish_allows_legacy_single_iteration_review_and_approval_metadata(self): + intake_payload = self.service.create_draft_submission( + { + "domain": "revisao", + "tool_name": "consultar_revisao_aberta", + "display_name": "Consultar revisao aberta", + "description": "Consulta revisoes abertas com filtros administrativos para a oficina.", + "business_goal": "Ajudar o time a localizar revisoes abertas com mais contexto operacional.", + "parameters": [], + }, + owner_staff_account_id=99, + owner_name="Diretoria", + owner_role=StaffRole.DIRETOR, + ) + version_id = intake_payload["draft_preview"]["version_id"] + version = self.version_repository.get_by_version_id(version_id) + + self.service.run_generation_pipeline( + version_id, + runner_staff_account_id=99, + runner_name="Diretoria", + runner_role=StaffRole.DIRETOR, + ) + self.service.review_version( + version_id, + reviewer_staff_account_id=99, + reviewer_name="Diretoria", + reviewer_role=StaffRole.DIRETOR, + decision_notes="Revisao humana registrada antes da migracao do contexto de iteracao.", + reviewed_generated_code=True, + ) + self.service.approve_version( + version_id, + approver_staff_account_id=99, + approver_name="Diretoria", + approver_role=StaffRole.DIRETOR, + decision_notes="Aprovacao humana registrada antes da migracao do contexto de iteracao.", + ) + + generation_artifact = self.artifact_repository.get_by_tool_version_and_kind(version.id, ToolArtifactKind.GENERATION_REQUEST) + validation_artifact = self.artifact_repository.get_by_tool_version_and_kind(version.id, ToolArtifactKind.VALIDATION_REPORT) + review_artifact = self.artifact_repository.get_by_tool_version_and_kind(version.id, ToolArtifactKind.DIRECTOR_REVIEW) + approval_artifact = self.artifact_repository.get_by_tool_version_and_kind(version.id, ToolArtifactKind.DIRECTOR_APPROVAL) + + generation_payload = dict(generation_artifact.payload_json or {}) + generation_payload.pop("generation_iterations", None) + generation_payload.pop("latest_generation", None) + generation_payload.pop("generation_mode", None) + generation_artifact.payload_json = generation_payload + + validation_payload = dict(validation_artifact.payload_json or {}) + validation_payload.pop("validation_iterations", None) + validation_payload.pop("latest_validation", None) + validation_payload.pop("generation_mode", None) + validation_artifact.payload_json = validation_payload + + review_payload = dict(review_artifact.payload_json or {}) + review_payload.pop("reviewed_generation_iteration", None) + review_payload.pop("reviewed_generation_mode", None) + review_payload.pop("reviewed_generation_checksum", None) + review_artifact.payload_json = review_payload + + approval_payload = dict(approval_artifact.payload_json or {}) + approval_payload.pop("approved_generation_iteration", None) + approval_payload.pop("approved_generation_mode", None) + approval_payload.pop("approved_generation_checksum", None) + approval_artifact.payload_json = approval_payload + + payload = self.service.publish_version( + version_id, + publisher_staff_account_id=99, + publisher_name="Diretoria", + publisher_role=StaffRole.DIRETOR, + ) + + self.assertEqual(payload["status"], ToolLifecycleStatus.ACTIVE) + self.assertEqual(payload["version_id"], version_id) + + def test_rollback_allows_legacy_single_iteration_review_and_approval_metadata(self): + first_intake = self.service.create_draft_submission( + { + "domain": "vendas", + "tool_name": "consultar_funil_comercial", + "display_name": "Consultar funil comercial", + "description": "Consulta o funil comercial consolidado para acompanhamento administrativo.", + "business_goal": "Dar visibilidade ao time interno sobre os principais gargalos do funil.", + "parameters": [], + }, + owner_staff_account_id=7, + owner_name="Equipe Interna", + ) + first_version_id = first_intake["draft_preview"]["version_id"] + self.service.run_generation_pipeline(first_version_id, runner_staff_account_id=7, runner_name="Equipe Interna", runner_role=StaffRole.COLABORADOR) + self.service.review_version( + first_version_id, + reviewer_staff_account_id=99, + reviewer_name="Diretoria", + reviewer_role=StaffRole.DIRETOR, + decision_notes="Primeira versao revisada antes da migracao do contexto de iteracao.", + reviewed_generated_code=True, + ) + self.service.approve_version( + first_version_id, + approver_staff_account_id=99, + approver_name="Diretoria", + approver_role=StaffRole.DIRETOR, + decision_notes="Primeira versao aprovada antes da migracao do contexto de iteracao.", + ) + self.service.publish_version(first_version_id, publisher_staff_account_id=99, publisher_name="Diretoria", publisher_role=StaffRole.DIRETOR) + + first_version = self.version_repository.get_by_version_id(first_version_id) + generation_artifact = self.artifact_repository.get_by_tool_version_and_kind(first_version.id, ToolArtifactKind.GENERATION_REQUEST) + validation_artifact = self.artifact_repository.get_by_tool_version_and_kind(first_version.id, ToolArtifactKind.VALIDATION_REPORT) + review_artifact = self.artifact_repository.get_by_tool_version_and_kind(first_version.id, ToolArtifactKind.DIRECTOR_REVIEW) + approval_artifact = self.artifact_repository.get_by_tool_version_and_kind(first_version.id, ToolArtifactKind.DIRECTOR_APPROVAL) + + generation_payload = dict(generation_artifact.payload_json or {}) + generation_payload.pop("generation_iterations", None) + generation_payload.pop("latest_generation", None) + generation_payload.pop("generation_mode", None) + generation_artifact.payload_json = generation_payload + + validation_payload = dict(validation_artifact.payload_json or {}) + validation_payload.pop("validation_iterations", None) + validation_payload.pop("latest_validation", None) + validation_payload.pop("generation_mode", None) + validation_artifact.payload_json = validation_payload + + review_payload = dict(review_artifact.payload_json or {}) + review_payload.pop("reviewed_generation_iteration", None) + review_payload.pop("reviewed_generation_mode", None) + review_payload.pop("reviewed_generation_checksum", None) + review_artifact.payload_json = review_payload + + approval_payload = dict(approval_artifact.payload_json or {}) + approval_payload.pop("approved_generation_iteration", None) + approval_payload.pop("approved_generation_mode", None) + approval_payload.pop("approved_generation_checksum", None) + approval_artifact.payload_json = approval_payload + + second_intake = self.service.create_draft_submission( + { + "domain": "vendas", + "tool_name": "consultar_funil_comercial", + "display_name": "Consultar funil comercial", + "description": "Consulta o funil comercial consolidado com novos filtros administrativos.", + "business_goal": "Dar visibilidade ao time interno sobre gargalos do funil com cortes adicionais.", + "parameters": [], + }, + owner_staff_account_id=7, + owner_name="Equipe Interna", + ) + second_version_id = second_intake["draft_preview"]["version_id"] + self.service.run_generation_pipeline(second_version_id, runner_staff_account_id=7, runner_name="Equipe Interna", runner_role=StaffRole.COLABORADOR) + self.service.review_version( + second_version_id, + reviewer_staff_account_id=99, + reviewer_name="Diretoria", + reviewer_role=StaffRole.DIRETOR, + decision_notes="Segunda versao revisada para substituir a publicacao anterior.", + reviewed_generated_code=True, + ) + self.service.approve_version( + second_version_id, + approver_staff_account_id=99, + approver_name="Diretoria", + approver_role=StaffRole.DIRETOR, + decision_notes="Segunda versao aprovada para substituir a publicacao anterior.", + ) + self.service.publish_version(second_version_id, publisher_staff_account_id=99, publisher_name="Diretoria", publisher_role=StaffRole.DIRETOR) + + payload = self.service.rollback_version( + second_version_id, + actor_staff_account_id=99, + actor_name="Diretoria", + actor_role=StaffRole.DIRETOR, + decision_notes="Rollback compativel com artefatos legados de revisao e aprovacao.", + ) + + self.assertEqual(payload["status"], ToolLifecycleStatus.ACTIVE) + self.assertEqual(payload["version_id"], first_version_id) + + def test_publish_requires_review_and_approval_for_latest_generation_iteration(self): + intake_payload = self.service.create_draft_submission( + { + "domain": "revisao", + "tool_name": "consultar_revisao_aberta", + "display_name": "Consultar revisao aberta", + "description": "Consulta revisoes abertas com filtros administrativos para a oficina.", + "business_goal": "Ajudar o time a localizar revisoes abertas com mais contexto operacional.", + "parameters": [ + { + "name": "placa", + "parameter_type": "string", + "description": "Placa usada na busca da revisao.", + "required": True, + } + ], + }, + owner_staff_account_id=99, + owner_name="Diretoria", + owner_role=StaffRole.DIRETOR, + ) + version_id = intake_payload["draft_preview"]["version_id"] + version = self.version_repository.get_by_version_id(version_id) + draft = self.draft_repository.get_by_tool_name("consultar_revisao_aberta") + metadata = self.metadata_repository.get_by_tool_version_id(version.id) + + self.service.run_generation_pipeline( + version_id, + runner_staff_account_id=99, + runner_name="Diretoria", + runner_role=StaffRole.DIRETOR, + ) + review_payload = self.service.review_version( + version_id, + reviewer_staff_account_id=99, + reviewer_name="Diretoria", + reviewer_role=StaffRole.DIRETOR, + decision_notes="Revisao inicial completa do codigo governado.", + reviewed_generated_code=True, + ) + approve_payload = self.service.approve_version( + version_id, + approver_staff_account_id=99, + approver_name="Diretoria", + approver_role=StaffRole.DIRETOR, + decision_notes="Aprovacao inicial completa da versao governada.", + ) + self.assertEqual(review_payload["status"], ToolLifecycleStatus.VALIDATED) + self.assertEqual(approve_payload["status"], ToolLifecycleStatus.APPROVED) + + self.service._persist_generation_pipeline_artifact( + draft=draft, + version=version, + actor_staff_account_id=99, + actor_name="Diretoria", + actor_role=StaffRole.DIRETOR, + llm_generated_source=None, + llm_generation_model=None, + llm_generation_issues=[], + generation_iteration=2, + generation_mode="change_request_refinement", + feedback_notes="Foi identificada uma nova iteracao tecnica apos a aprovacao inicial.", + previous_source_checksum=self.service._compute_source_checksum( + self.service._get_generated_source_code_for_version(version.id) + ), + prompt_rendered="prompt de refatoracao tecnica", + generation_elapsed_ms=10.0, + commit=None, + ) + self.service._execute_automated_contract_validation( + draft=draft, + version=version, + metadata=metadata, + actor_staff_account_id=99, + actor_name="Diretoria", + llm_generated_source=None, + generation_iteration=2, + generation_mode="change_request_refinement", + change_request_notes="Foi identificada uma nova iteracao tecnica apos a aprovacao inicial.", + previous_source_checksum=self.service._compute_source_checksum( + self.service._get_generated_source_code_for_version(version.id) + ), + commit=None, + ) + + with self.assertRaisesRegex(ValueError, "iteracao mais recente"): + self.service.publish_version( + version_id, + publisher_staff_account_id=99, + publisher_name="Diretoria", + publisher_role=StaffRole.DIRETOR, + ) + def test_run_generation_pipeline_promotes_version_to_generated(self): intake_payload = self.service.create_draft_submission( { @@ -694,6 +1326,14 @@ class AdminToolManagementServiceTests(unittest.TestCase): owner_role=StaffRole.COLABORADOR, ) + self.service.authorize_generation( + intake_payload["draft_preview"]["version_id"], + actor_staff_account_id=99, + actor_name="Diretoria", + actor_role=StaffRole.DIRETOR, + decision_notes="A proposta pode seguir para geracao governada apos triagem inicial.", + ) + payload = self.service.run_generation_pipeline( intake_payload["draft_preview"]["version_id"], runner_staff_account_id=8, @@ -763,6 +1403,14 @@ class AdminToolManagementServiceTests(unittest.TestCase): ) self.metadata_repository.metadata_entries[0].display_name = "No" + self.service.authorize_generation( + intake_payload["draft_preview"]["version_id"], + actor_staff_account_id=99, + actor_name="Diretoria", + actor_role=StaffRole.DIRETOR, + decision_notes="A proposta pode seguir para geracao governada apos triagem inicial.", + ) + payload = self.service.run_generation_pipeline( intake_payload["draft_preview"]["version_id"], runner_staff_account_id=8, @@ -773,6 +1421,8 @@ class AdminToolManagementServiceTests(unittest.TestCase): self.assertEqual(payload["status"], ToolLifecycleStatus.FAILED) self.assertEqual(payload["current_step"], "generation") self.assertEqual(payload["queue_entry"]["gate"], "pipeline_retry_required") + failed_detail = self.service.build_review_detail_payload(intake_payload["draft_preview"]["version_id"]) + self.assertTrue(failed_detail["human_gate"]["run_pipeline_action_available"]) self.assertEqual(payload["queue_entry"]["automated_validation_status"], "failed") self.assertIn("revisar contrato da tool", payload["queue_entry"]["automated_validation_summary"].lower()) automated_checks = {check["key"]: check for check in payload["automated_validations"]} @@ -814,6 +1464,14 @@ class AdminToolManagementServiceTests(unittest.TestCase): owner_role=StaffRole.COLABORADOR, ) + self.service.authorize_generation( + intake_payload["draft_preview"]["version_id"], + actor_staff_account_id=99, + actor_name="Diretoria", + actor_role=StaffRole.DIRETOR, + decision_notes="A proposta pode seguir para geracao governada apos triagem inicial.", + ) + payload = self.service.run_generation_pipeline( intake_payload["draft_preview"]["version_id"], runner_staff_account_id=8, @@ -864,6 +1522,14 @@ class AdminToolManagementServiceTests(unittest.TestCase): "_render_generated_tool_module_source", return_value="async def run(:\n pass\n", ): + self.service.authorize_generation( + intake_payload["draft_preview"]["version_id"], + actor_staff_account_id=99, + actor_name="Diretoria", + actor_role=StaffRole.DIRETOR, + decision_notes="A proposta pode seguir para geracao governada apos triagem inicial.", + ) + payload = self.service.run_generation_pipeline( intake_payload["draft_preview"]["version_id"], runner_staff_account_id=8, @@ -915,6 +1581,14 @@ class AdminToolManagementServiceTests(unittest.TestCase): "_render_generated_tool_module_source", return_value='async def run(*, placa):\n raise RuntimeError("smoke test boom")\n', ): + self.service.authorize_generation( + intake_payload["draft_preview"]["version_id"], + actor_staff_account_id=99, + actor_name="Diretoria", + actor_role=StaffRole.DIRETOR, + decision_notes="A proposta pode seguir para geracao governada apos triagem inicial.", + ) + payload = self.service.run_generation_pipeline( intake_payload["draft_preview"]["version_id"], runner_staff_account_id=8, @@ -1100,12 +1774,18 @@ class AdminToolManagementServiceTests(unittest.TestCase): self.assertEqual(payload["status"], ToolLifecycleStatus.APPROVED) self.assertEqual(payload["human_gate"]["publication_action_available"], True) + self.assertEqual(payload["generation_context"]["latest_generation_iteration"], 1) + self.assertEqual(payload["generation_context"]["generation_iterations_count"], 1) + self.assertEqual(payload["generation_context"]["latest_generation_mode"], "initial_generation") + self.assertTrue(payload["generation_context"]["has_previous_generation"]) + self.assertIsNone(payload["generation_context"]["latest_change_request_notes"]) self.assertIn("async def run", payload["generated_source_code"]) self.assertEqual(len(payload["automated_validations"]), 4) - self.assertEqual(len(payload["decision_history"]), 2) - self.assertEqual(payload["decision_history"][0]["action_key"], ToolArtifactKind.DIRECTOR_REVIEW.value) - self.assertTrue(payload["decision_history"][0]["reviewed_generated_code"]) - self.assertIn("aprovacao formal", payload["decision_history"][1]["decision_notes"].lower()) + self.assertEqual(len(payload["decision_history"]), 3) + self.assertEqual(payload["decision_history"][0]["action_key"], ToolArtifactKind.GENERATION_AUTHORIZATION.value) + self.assertEqual(payload["decision_history"][1]["action_key"], ToolArtifactKind.DIRECTOR_REVIEW.value) + self.assertTrue(payload["decision_history"][1]["reviewed_generated_code"]) + self.assertIn("aprovacao formal", payload["decision_history"][2]["decision_notes"].lower()) def test_publishing_new_version_archives_previous_active_version(self): first_intake = self.service.create_draft_submission( @@ -1362,6 +2042,13 @@ class AdminToolManagementTransactionalPersistenceTests(unittest.TestCase): ) version_id = intake_payload["draft_preview"]["version_id"] + service.authorize_generation( + version_id, + actor_staff_account_id=99, + actor_name="Diretoria", + actor_role=StaffRole.DIRETOR, + decision_notes="A proposta foi autorizada pela diretoria antes da geracao governada.", + ) service.run_generation_pipeline( version_id, runner_staff_account_id=7, diff --git a/tests/test_admin_tools_web.py b/tests/test_admin_tools_web.py index 08b357c..787a296 100644 --- a/tests/test_admin_tools_web.py +++ b/tests/test_admin_tools_web.py @@ -454,8 +454,11 @@ class AdminToolsWebTests(unittest.TestCase): self.assertEqual(payload["submission_policy"]["mode"], "draft_only") self.assertEqual(payload["submission_policy"]["submitter_role"], "colaborador") self.assertFalse(payload["submission_policy"]["submitter_can_publish_now"]) + self.assertFalse(payload["submission_policy"]["submitter_can_authorize_generation_now"]) self.assertTrue(payload["submission_policy"]["direct_publication_blocked"]) + self.assertTrue(payload["submission_policy"]["requires_generation_authorization"]) self.assertEqual(payload["submission_policy"]["required_approver_role"], "diretor") + self.assertEqual(payload["submission_policy"]["required_generation_permission"], "review_tool_generations") self.assertEqual(payload["submission_policy"]["required_publish_permission"], "publish_tools") self.assertEqual(payload["draft_preview"]["status"], "draft") self.assertEqual(payload["draft_preview"]["domain"], "orquestracao") @@ -588,10 +591,14 @@ class AdminToolsWebTests(unittest.TestCase): payload = response.json() self.assertEqual(payload["tool_name"], "consultar_revisao_aberta") self.assertTrue(payload["human_gate"]["review_action_available"]) + self.assertFalse(payload["human_gate"]["run_pipeline_action_available"]) + self.assertEqual(payload["generation_context"]["latest_generation_iteration"], 1) + self.assertEqual(payload["generation_context"]["generation_iterations_count"], 1) + self.assertEqual(payload["generation_context"]["latest_generation_mode"], "initial_generation") self.assertIn("async def run", payload["generated_source_code"]) self.assertEqual(len(payload["automated_validations"]), 4) - def test_tools_collaborator_can_run_generation_pipeline_after_manual_intake(self): + def test_tools_collaborator_cannot_run_generation_pipeline_before_director_authorization(self): client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) try: intake_response = client.post( @@ -615,21 +622,8 @@ class AdminToolsWebTests(unittest.TestCase): app.dependency_overrides.clear() self.assertEqual(intake_response.status_code, 200) - self.assertEqual(response.status_code, 200) - payload = response.json() - self.assertEqual(payload["status"], "generated") - self.assertEqual(payload["current_step"], "validation") - self.assertEqual(payload["queue_entry"]["gate"], "validation_required") - self.assertEqual(payload["queue_entry"]["automated_validation_status"], "passed") - self.assertEqual(payload["queue_entry"]["automated_validation_summary"], "4/4 validacoes automaticas passaram antes da revisao humana.") - automated_checks = {check["key"]: check for check in payload["automated_validations"]} - self.assertEqual(automated_checks["tool_contract"]["status"], "passed") - self.assertEqual(automated_checks["tool_signature_schema"]["status"], "passed") - self.assertEqual(automated_checks["tool_import_loading"]["status"], "passed") - self.assertEqual(automated_checks["tool_smoke_tests"]["status"], "passed") - steps_by_key = {step["key"]: step for step in payload["steps"]} - self.assertEqual(steps_by_key["generation"]["state"], "completed") - self.assertEqual(steps_by_key["validation"]["state"], "current") + self.assertEqual(response.status_code, 409) + self.assertIn("autorizacao de diretor", response.json()["detail"].lower()) def test_tools_publications_require_director_publication_permission(self): client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) diff --git a/tests/test_admin_view_bootstrap.py b/tests/test_admin_view_bootstrap.py index 301a84e..bb5b5ba 100644 --- a/tests/test_admin_view_bootstrap.py +++ b/tests/test_admin_view_bootstrap.py @@ -1,4 +1,4 @@ -import unittest +import unittest from fastapi.testclient import TestClient @@ -131,7 +131,8 @@ class AdminViewBootstrapTests(unittest.TestCase): self.assertIn('data-intake-endpoint="/panel/tools/drafts/intake"', response.text) self.assertIn('data-admin-tool-intake-form="true"', response.text) self.assertIn("Adicionar parametro", response.text) - self.assertIn("Validar pre-cadastro", response.text) + self.assertIn("Criar proposta", response.text) + self.assertIn("Nenhuma proposta criada ainda", response.text) def test_tool_review_page_redirects_to_login_without_session(self): app = create_app(AdminSettings(admin_app_name="Admin Interno", admin_version="1.4.0")) @@ -152,7 +153,8 @@ class AdminViewBootstrapTests(unittest.TestCase): app.dependency_overrides.clear() self.assertEqual(response.status_code, 200) - self.assertIn("Revisao, aprovacao e ativacao", response.text) + self.assertIn("Triagem, revisao e ativacao", response.text) + self.assertIn("Fluxo governado de proposta, codigo e ativacao", response.text) self.assertIn('data-admin-tool-review-board="true"', response.text) self.assertIn('data-overview-endpoint="/panel/tools/overview"', response.text) self.assertIn('data-contracts-endpoint="/panel/tools/contracts"', response.text) @@ -160,8 +162,14 @@ class AdminViewBootstrapTests(unittest.TestCase): self.assertIn('data-publications-endpoint="/panel/tools/publications"', response.text) self.assertIn('data-tool-review-code', response.text) self.assertIn('data-tool-review-decision-notes', response.text) + self.assertIn('data-tool-review-action="authorize-generation"', response.text) + self.assertIn('data-tool-review-action="run-pipeline"', response.text) + self.assertIn('data-tool-review-action="request-changes"', response.text) + self.assertIn('data-tool-review-action="close"', response.text) self.assertIn('data-tool-review-action="deactivate"', response.text) self.assertIn('data-tool-review-action="rollback"', response.text) + self.assertIn("Codigo completo da iteracao atual", response.text) + self.assertIn("Ativar no catalogo", response.text) self.assertNotIn("Abrir login administrativo", response.text) def test_collaborator_management_page_redirects_to_login_without_session(self):