${escapeHtml(item.display_name || item.tool_name || "Tool")}
${escapeHtml(item.summary || payload?.message || "Item aguardando analise do time.")}
${validationSummary}${queuedAt}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 -
- 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.
Fila de revisao
Items aguardando leitura tecnica ou aprovacao humana.
+Propostas aguardando triagem, leitura tecnica ou aprovacao humana.
Pipeline visual
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.
Fila atual
-A fila abaixo e lida da superficie web do painel e respeita o papel da sessao autenticada.
+A fila abaixo mostra em que gate cada proposta esta e qual decisao humana ou tecnica vem a seguir.
Checklist de aprovacao
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.
Detalhe da versao
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.
Preview do draft
-Assim que o formulario for validado, o resumo do draft aparece aqui com avisos e proximos passos.
+Assim que o formulario for enviado, o resumo do draft aparece aqui com os proximos passos de triagem antes da geracao.
${escapeHtml(item.summary || payload?.message || "Item aguardando analise do time.")}
${validationSummary}${queuedAt}${escapeHtml(item.summary || payload?.message || "Item aguardando analise do time.")}
${escapeHtml(payload?.message || "Nenhuma tool aguardando revisao agora.")}