From 739e6947eaf708dbc600fae2724c639d8e65c26b Mon Sep 17 00:00:00 2001 From: Yuuuu <1569752928@qq.com> Date: Sun, 15 Mar 2026 15:00:55 +0800 Subject: [PATCH] Summarize backend API updates --- CLAUDE.md | 36 + backend/app/api/ai_logs.py | 164 +- backend/app/api/ai_logs_utils.py | 137 + backend/app/api/ai_pmo.py | 1001 +-- backend/app/api/ai_pmo_api/__init__.py | 11 + .../api/ai_pmo_api/helpers_inbox_filters.py | 183 + .../api/ai_pmo_api/helpers_inbox_payload.py | 140 + backend/app/api/ai_pmo_api/helpers_resume.py | 191 + backend/app/api/ai_pmo_api/routes_cadence.py | 84 + backend/app/api/ai_pmo_api/routes_core.py | 181 + backend/app/api/ai_pmo_api/routes_resume.py | 151 + backend/app/api/ai_pmo_api/schemas.py | 25 + backend/app/api/ai_pmo_api/shared.py | 48 + backend/app/api/ai_steward.py | 1462 +--- backend/app/api/ai_steward_api/__init__.py | 25 + backend/app/api/ai_steward_api/helpers.py | 63 + .../ai_steward_api/routes_brief_blockers.py | 251 + .../app/api/ai_steward_api/routes_capture.py | 234 + .../api/ai_steward_api/routes_conflicts.py | 253 + .../api/ai_steward_api/routes_rag_sources.py | 151 + .../api/ai_steward_api/routes_stats_config.py | 197 + .../app/api/ai_steward_api/routes_workflow.py | 149 + backend/app/api/ai_steward_api/schemas.py | 96 + backend/app/api/ai_steward_api/shared.py | 37 + backend/app/api/endpoints/skill.py | 382 +- .../app/api/endpoints/skill_api/__init__.py | 18 + .../app/api/endpoints/skill_api/bindings.py | 115 + .../app/api/endpoints/skill_api/recommend.py | 51 + backend/app/api/endpoints/skill_api/shared.py | 31 + backend/app/api/endpoints/skill_api/tags.py | 116 + .../app/api/endpoints/skill_api/workload.py | 44 + backend/app/api/feishu.py | 377 +- backend/app/api/feishu_api/__init__.py | 5 + backend/app/api/feishu_api/config_routes.py | 129 + backend/app/api/feishu_api/helpers.py | 33 + .../api/feishu_api/long_connection_routes.py | 54 + backend/app/api/feishu_api/message_routes.py | 76 + backend/app/api/feishu_api/router.py | 16 + backend/app/api/feishu_api/schemas.py | 32 + backend/app/api/feishu_api/test_routes.py | 82 + backend/app/api/model_capability.py | 492 +- .../app/api/model_capability_api/__init__.py | 11 + .../app/api/model_capability_api/helpers.py | 7 + .../model_capability_api/routes_context.py | 131 + .../routes_experiment_sync.py | 118 + .../model_capability_api/routes_lifecycle.py | 80 + .../model_capability_api/routes_match_cost.py | 131 + .../app/api/model_capability_api/shared.py | 29 + backend/app/api/projects.py | 685 +- backend/app/api/projects_api/__init__.py | 11 + backend/app/api/projects_api/helpers.py | 266 + backend/app/api/projects_api/routes_list.py | 43 + .../app/api/projects_api/routes_mutation.py | 217 + .../app/api/projects_api/routes_signals.py | 93 + backend/app/api/projects_api/schemas.py | 26 + backend/app/api/projects_api/shared.py | 46 + backend/app/api/rag.py | 569 +- backend/app/api/rag_api/__init__.py | 11 + backend/app/api/rag_api/helpers.py | 52 + backend/app/api/rag_api/routes_knowledge.py | 141 + backend/app/api/rag_api/routes_qa.py | 198 + backend/app/api/rag_api/routes_query.py | 136 + backend/app/api/rag_api/shared.py | 33 + backend/app/api/signal_sources.py | 249 +- backend/app/api/signal_sources_models.py | 46 + backend/app/api/signal_sources_utils.py | 67 + backend/app/api/signals.py | 463 +- backend/app/api/signals_api/__init__.py | 16 + backend/app/api/signals_api/feedback.py | 88 + backend/app/api/signals_api/ingest_fetch.py | 297 + backend/app/api/signals_api/list_create.py | 106 + backend/app/api/skill_tags.py | 78 +- backend/app/api/skill_tags_contracts.py | 54 + backend/app/api/sstc.py | 3990 +--------- backend/app/api/sstc_api/__init__.py | 49 + backend/app/api/sstc_api/ai_routing.py | 130 + .../app/api/sstc_api/charters_ai_alignment.py | 256 + .../api/sstc_api/charters_detail_update.py | 176 + .../app/api/sstc_api/charters_list_create.py | 146 + backend/app/api/sstc_api/overview.py | 82 + .../app/api/sstc_api/releases_list_create.py | 123 + .../api/sstc_api/releases_status_generate.py | 107 + .../app/api/sstc_api/releases_verification.py | 103 + backend/app/api/sstc_api/reviews_archive.py | 60 + .../app/api/sstc_api/reviews_list_create.py | 104 + .../app/api/sstc_api/reviews_patch_convert.py | 215 + backend/app/api/sstc_api/shared.py | 56 + backend/app/api/sstc_api/signals_archive.py | 47 + .../app/api/sstc_api/signals_attachment.py | 191 + backend/app/api/sstc_api/signals_list.py | 145 + .../app/api/sstc_api/tasks_ai_breakdown.py | 121 + .../app/api/sstc_api/tasks_board_status.py | 126 + backend/app/api/sstc_api/tasks_create.py | 84 + .../api/sstc_api/tasks_recommend_optimize.py | 131 + .../app/api/sstc_api/tasks_update_delete.py | 164 + backend/app/api/users.py | 1109 +-- backend/app/api/users_api/__init__.py | 11 + backend/app/api/users_api/helpers_roles.py | 64 + backend/app/api/users_api/helpers_user.py | 267 + backend/app/api/users_api/routes_bulk.py | 242 + backend/app/api/users_api/routes_import.py | 96 + backend/app/api/users_api/routes_mutation.py | 220 + backend/app/api/users_api/routes_query.py | 166 + backend/app/api/users_api/schemas.py | 12 + backend/app/api/users_api/shared.py | 49 + backend/app/api/wecom.py | 328 +- backend/app/api/wecom_api/__init__.py | 16 + backend/app/api/wecom_api/config.py | 114 + backend/app/api/wecom_api/messaging.py | 77 + backend/app/api/wecom_api/schemas.py | 22 + backend/app/api/wecom_api/webhook.py | 79 + backend/app/config/ai_prompts.py | 1069 +-- .../app/config/ai_prompts_catalog/__init__.py | 45 + .../app/config/ai_prompts_catalog/part_01.py | 270 + .../app/config/ai_prompts_catalog/part_02.py | 255 + .../app/config/ai_prompts_catalog/part_03.py | 262 + .../app/config/ai_prompts_catalog/part_04.py | 203 + backend/app/config/operation_catalog.py | 464 +- .../config/operation_catalog_core/__init__.py | 19 + .../config/operation_catalog_core/helpers.py | 48 + .../config/operation_catalog_core/models.py | 40 + .../notification_file.py | 79 + .../personnel_knowledge.py | 133 + .../operation_catalog_core/project_task.py | 184 + backend/app/dispatcher.py | 292 +- backend/app/dispatcher_rules.py | 179 + backend/app/events.py | 233 +- backend/app/events_models.py | 76 + backend/app/main.py | 411 +- backend/app/main_db_guard.py | 91 + backend/app/main_feishu_outbox.py | 89 + backend/app/main_model_probe.py | 102 + backend/app/main_warmup.py | 119 + backend/app/seed_projects.py | 320 +- backend/app/seed_projects_helpers.py | 172 + backend/app/services/adaptive_rag_service.py | 6736 +---------------- .../services/adaptive_rag_service/__init__.py | 5 + .../mixin_answer_entry.py | 264 + .../mixin_answer_payload.py | 130 + .../mixin_answer_precheck.py | 181 + .../mixin_answer_retrieval.py | 287 + .../mixin_conversation_utils_a_p1.py | 237 + .../mixin_conversation_utils_a_p2.py | 38 + .../mixin_conversation_utils_b.py | 43 + .../adaptive_rag_service/mixin_core_intent.py | 147 + .../mixin_deep_guard_answer.py | 201 + .../mixin_deep_guard_rules.py | 229 + .../mixin_deterministic_query_core.py | 143 + .../mixin_deterministic_query_count.py | 220 + .../mixin_deterministic_query_list.py | 281 + .../mixin_fast_answer_project_main.py | 227 + .../mixin_fast_answer_project_utils.py | 91 + .../mixin_fast_answer_semantic_p1.py | 168 + .../mixin_fast_answer_semantic_p2.py | 103 + .../adaptive_rag_service/mixin_ingest.py | 230 + .../mixin_intent_correction.py | 166 + .../mixin_knowledge_docs.py | 197 + .../mixin_manifest_core.py | 195 + .../mixin_manifest_feature_p1.py | 226 + .../mixin_manifest_feature_p2.py | 51 + .../mixin_multistep_core.py | 239 + .../mixin_multistep_flow.py | 229 + .../mixin_qa_feedback_records.py | 155 + .../mixin_qa_session_core.py | 152 + .../mixin_qa_session_list_ops_p1.py | 240 + .../mixin_qa_session_list_ops_p2.py | 54 + .../mixin_qa_stats_export.py | 185 + .../mixin_requirement_ack_core.py | 197 + .../mixin_requirement_ack_flow.py | 173 + .../mixin_retrieval_graph.py | 217 + .../mixin_retrieval_router_db.py | 210 + .../mixin_retrieval_vector.py | 221 + .../mixin_session_state.py | 225 + .../mixin_symbol_snapshot.py | 124 + .../services/adaptive_rag_service/service.py | 83 + .../services/adaptive_rag_service/shared.py | 7 + .../adaptive_rag_service/shared_imports.py | 57 + .../adaptive_rag_service/shared_part_01.py | 205 + .../adaptive_rag_service/shared_part_02.py | 146 + .../adaptive_rag_service/shared_utils.py | 17 + .../app/services/ai_pmo_cadence/__init__.py | 5 + .../services/ai_pmo_cadence/mixin_builders.py | 106 + .../ai_pmo_cadence/mixin_program_view.py | 123 + .../ai_pmo_cadence/mixin_queries_a.py | 129 + .../ai_pmo_cadence/mixin_queries_b.py | 124 + .../ai_pmo_cadence/mixin_snapshots.py | 80 + .../app/services/ai_pmo_cadence/service.py | 31 + backend/app/services/ai_pmo_cadence/shared.py | 56 + .../app/services/ai_pmo_cadence_service.py | 485 +- .../app/services/ai_pmo_decision/__init__.py | 19 + .../app/services/ai_pmo_decision/engine.py | 40 + .../app/services/ai_pmo_decision/policy.py | 128 + .../app/services/ai_pmo_decision/shared.py | 58 + .../services/ai_pmo_decision/simulation.py | 87 + .../app/services/ai_pmo_decision/strategic.py | 56 + .../app/services/ai_pmo_decision/tactical.py | 68 + .../app/services/ai_pmo_decision_runtime.py | 370 +- .../app/services/ai_pmo_intent/__init__.py | 69 + .../services/ai_pmo_intent/intent_resolver.py | 215 + backend/app/services/ai_pmo_intent/models.py | 32 + .../services/ai_pmo_intent/route_resolver.py | 218 + backend/app/services/ai_pmo_intent/shared.py | 212 + backend/app/services/ai_pmo_intent_routing.py | 669 +- backend/app/services/ai_pmo_kernel_common.py | 307 +- .../ai_pmo_kernel_common_core/__init__.py | 5 + .../ai_pmo_kernel_common_core/constants.py | 62 + .../services/ai_pmo_kernel_common_core/env.py | 75 + .../ai_pmo_kernel_common_core/helpers.py | 110 + .../ai_pmo_kernel_common_core/imports.py | 133 + .../ai_pmo_kernel_ingress_flow/__init__.py | 5 + .../ai_pmo_kernel_ingress_flow/mixin_auto.py | 187 + .../ai_pmo_kernel_ingress_flow/mixin_entry.py | 276 + .../mixin_prepare_manual.py | 253 + .../ai_pmo_kernel_ingress_flow/service.py | 16 + .../ai_pmo_kernel_ingress_flow/shared.py | 11 + .../ai_pmo_kernel_mixins_ingress_flow.py | 556 +- ...mo_kernel_mixins_world_model_aggregates.py | 336 +- .../ai_pmo_kernel_mixins_world_model_core.py | 328 +- .../__init__.py | 5 + .../mixin_basics.py | 192 + .../mixin_project.py | 167 + .../service.py | 13 + .../shared.py | 9 + .../__init__.py | 5 + .../mixin_builder.py | 145 + .../mixin_cache.py | 121 + .../mixin_project.py | 78 + .../ai_pmo_kernel_world_model_core/service.py | 15 + .../ai_pmo_kernel_world_model_core/shared.py | 9 + .../__init__.py | 5 + .../mixin_analysis.py | 81 + .../mixin_context.py | 198 + .../mixin_discover.py | 153 + .../service.py | 17 + .../ai_pmo_typed_executor_discovery/shared.py | 9 + .../ai_pmo_typed_executor_execute/__init__.py | 5 + .../mixin_execute_query_project.py | 72 + .../mixin_execute_signal_create.py | 153 + .../mixin_execute_signal_source.py | 106 + .../mixin_helpers.py | 249 + .../ai_pmo_typed_executor_execute/service.py | 66 + .../ai_pmo_typed_executor_execute/shared.py | 9 + .../ai_pmo_typed_executor_mixins_discovery.py | 403 +- .../ai_pmo_typed_executor_mixins_execute.py | 484 +- backend/app/services/ai_service.py | 663 +- .../app/services/ai_service_core/__init__.py | 5 + .../mixin_business_analysis.py | 176 + .../ai_service_core/mixin_business_tasks.py | 65 + .../ai_service_core/mixin_runtime_core.py | 135 + .../mixin_runtime_prompt_retry.py | 212 + .../app/services/ai_service_core/service.py | 29 + .../app/services/ai_service_core/shared.py | 73 + backend/app/services/ai_steward.py | 968 +-- .../services/ai_steward_service/__init__.py | 5 + .../mixin_creation_normalize_persist.py | 273 + .../mixin_creation_pipeline.py | 97 + .../mixin_draft_generation.py | 200 + .../ai_steward_service/mixin_json_parsing.py | 247 + .../ai_steward_service/mixin_title_utils.py | 88 + .../services/ai_steward_service/service.py | 25 + .../app/services/ai_steward_service/shared.py | 82 + backend/app/services/auditor.py | 637 +- backend/app/services/auditor_core/__init__.py | 3 + .../services/auditor_core/mixin_ai_score.py | 216 + .../app/services/auditor_core/mixin_checks.py | 175 + .../services/auditor_core/mixin_pipeline.py | 119 + backend/app/services/auditor_core/service.py | 96 + backend/app/services/auditor_core/shared.py | 62 + backend/app/services/auto_merger.py | 543 +- .../app/services/auto_merger_core/__init__.py | 14 + .../services/auto_merger_core/mixin_entry.py | 111 + .../auto_merger_core/mixin_events_guard.py | 165 + .../auto_merger_core/mixin_execute.py | 122 + .../app/services/auto_merger_core/registry.py | 25 + .../app/services/auto_merger_core/schemas.py | 57 + .../app/services/auto_merger_core/service.py | 35 + .../app/services/auto_merger_core/shared.py | 35 + backend/app/services/blocker_detection.py | 426 +- .../blocker_detection_core/__init__.py | 6 + .../blocker_detection_core/mixin_ai.py | 156 + .../blocker_detection_core/mixin_scan.py | 188 + .../blocker_detection_core/registry.py | 11 + .../blocker_detection_core/service.py | 15 + .../services/blocker_detection_core/shared.py | 61 + .../services/budget_enforcement/__init__.py | 5 + .../budget_enforcement/mixin_alerts.py | 129 + .../budget_enforcement/mixin_checks.py | 141 + .../budget_enforcement/mixin_storage.py | 74 + .../services/budget_enforcement/service.py | 19 + .../app/services/budget_enforcement/shared.py | 26 + .../services/budget_enforcement_service.py | 347 +- backend/app/services/codegen.py | 573 +- backend/app/services/codegen_core/__init__.py | 14 + .../services/codegen_core/mixin_context.py | 90 + .../services/codegen_core/mixin_prompt_llm.py | 120 + .../codegen_core/mixin_review_summary.py | 99 + .../codegen_core/mixin_validation_diff.py | 104 + backend/app/services/codegen_core/registry.py | 15 + backend/app/services/codegen_core/schemas.py | 58 + backend/app/services/codegen_core/service.py | 73 + backend/app/services/codegen_core/shared.py | 35 + backend/app/services/cognitive_conflict.py | 475 +- .../cognitive_conflict_core/__init__.py | 6 + .../mixin_detection.py | 207 + .../mixin_resolution.py | 100 + .../cognitive_conflict_core/mixin_storage.py | 98 + .../cognitive_conflict_core/registry.py | 11 + .../cognitive_conflict_core/service.py | 20 + .../cognitive_conflict_core/shared.py | 37 + backend/app/services/context_cache.py | 1171 +-- .../context_cache_service/__init__.py | 5 + .../context_cache_service/invalidation.py | 172 + .../context_cache_service/mixin_cache_core.py | 133 + .../mixin_profile_basics.py | 135 + .../mixin_profile_route_parser.py | 254 + .../mixin_project_team.py | 207 + .../mixin_session_invalidate.py | 109 + .../mixin_user_snapshot.py | 136 + .../services/context_cache_service/service.py | 30 + .../services/context_cache_service/shared.py | 54 + backend/app/services/daily_briefing.py | 482 +- .../services/daily_briefing_core/__init__.py | 6 + .../mixin_helpers_summary.py | 116 + .../daily_briefing_core/mixin_personal.py | 251 + .../daily_briefing_core/mixin_team.py | 67 + .../services/daily_briefing_core/parsing.py | 34 + .../services/daily_briefing_core/registry.py | 11 + .../services/daily_briefing_core/service.py | 20 + .../services/daily_briefing_core/shared.py | 52 + backend/app/services/domain.py | 470 +- backend/app/services/domain_core/__init__.py | 21 + .../app/services/domain_core/mixin_crud.py | 188 + .../domain_core/mixin_runtime_stats.py | 147 + backend/app/services/domain_core/registry.py | 11 + backend/app/services/domain_core/schemas.py | 71 + backend/app/services/domain_core/service.py | 16 + backend/app/services/domain_core/shared.py | 31 + backend/app/services/event_workflow.py | 616 +- .../services/event_workflow_core/__init__.py | 3 + .../event_workflow_core/mixin_core.py | 69 + .../mixin_handlers_primary.py | 189 + .../mixin_handlers_secondary.py | 214 + .../event_workflow_core/mixin_helpers.py | 108 + .../services/event_workflow_core/service.py | 24 + .../services/event_workflow_core/shared.py | 45 + .../app/services/experiment_sync/__init__.py | 5 + .../services/experiment_sync/mixin_helpers.py | 77 + .../services/experiment_sync/mixin_review.py | 109 + .../services/experiment_sync/mixin_sync.py | 113 + .../app/services/experiment_sync/service.py | 19 + .../app/services/experiment_sync/shared.py | 29 + .../app/services/experiment_sync_service.py | 302 +- backend/app/services/feishu_integration.py | 2120 +----- .../feishu_integration_service/__init__.py | 5 + .../mixin_config.py | 157 + .../mixin_message_payload.py | 259 + .../mixin_receive.py | 213 + .../mixin_receive_routing.py | 159 + .../mixin_reply_guard.py | 198 + .../mixin_reply_llm_core.py | 259 + .../mixin_reply_outbox.py | 297 + .../mixin_reply_templates.py | 125 + .../mixin_sender_resolution.py | 126 + .../feishu_integration_service/mixin_trace.py | 125 + .../mixin_transport.py | 218 + .../feishu_integration_service/service.py | 51 + .../feishu_integration_service/shared.py | 53 + .../app/services/feishu_long_connection.py | 572 +- .../feishu_long_connection_core/__init__.py | 10 + .../feishu_long_connection_core/manager.py | 17 + .../mixin_lifecycle.py | 9 + .../mixin_lifecycle_connection.py | 179 + .../mixin_lifecycle_control.py | 186 + .../mixin_runtime.py | 199 + .../feishu_long_connection_core/registry.py | 21 + .../feishu_long_connection_core/shared.py | 47 + .../app/services/feishu_signal_pipeline.py | 6181 +-------------- .../feishu_signal_pipeline/__init__.py | 5 + .../mixin_command_commit_project.py | 98 + .../mixin_command_commit_task.py | 78 + .../mixin_command_exec_core.py | 147 + .../mixin_context_prefill_a.py | 279 + .../mixin_context_prefill_b.py | 233 + .../feishu_signal_pipeline/mixin_core_base.py | 172 + .../mixin_extraction.py | 265 + .../mixin_followup_a.py | 256 + .../mixin_followup_b.py | 299 + .../mixin_gate_dispatch.py | 270 + .../mixin_gate_project.py | 208 + .../feishu_signal_pipeline/mixin_gate_task.py | 206 + .../mixin_intent_gate_core.py | 283 + .../mixin_intent_support.py | 102 + .../mixin_item_finalize_routing.py | 225 + .../mixin_item_processing.py | 294 + .../mixin_lookup_and_block.py | 238 + .../mixin_message_utils_a.py | 140 + .../mixin_message_utils_b.py | 264 + .../mixin_persistence_lookup.py | 154 + .../mixin_process_message_core.py | 207 + .../mixin_process_message_default.py | 256 + .../mixin_process_message_helpers_a.py | 204 + .../mixin_process_message_helpers_b.py | 189 + .../mixin_queue_confirm_undo.py | 194 + .../mixin_queue_read_write.py | 183 + .../mixin_routing_notify.py | 266 + .../mixin_serialization.py | 204 + .../mixin_task_command_queue.py | 288 + .../feishu_signal_pipeline/service.py | 66 + .../services/feishu_signal_pipeline/shared.py | 5 + .../feishu_signal_pipeline/shared_core.py | 217 + .../shared_schema_utils.py | 171 + backend/app/services/integration.py | 568 +- .../app/services/integration_core/__init__.py | 27 + .../integration_core/mixin_clients.py | 38 + .../services/integration_core/mixin_health.py | 119 + .../integration_core/mixin_lifecycle.py | 67 + .../services/integration_core/mixin_sync.py | 197 + .../app/services/integration_core/registry.py | 20 + .../app/services/integration_core/schemas.py | 98 + .../app/services/integration_core/service.py | 22 + .../app/services/integration_core/shared.py | 32 + backend/app/services/llm.py | 3303 +------- backend/app/services/llm_service/__init__.py | 5 + .../llm_service/mixin_cache_circuit.py | 163 + .../services/llm_service/mixin_chat_openai.py | 193 + .../llm_service/mixin_chat_transport.py | 194 + .../llm_service/mixin_engine_admin.py | 258 + .../mixin_engine_resolution_core.py | 281 + .../llm_service/mixin_generate_db_chat.py | 135 + .../mixin_generate_db_structured.py | 152 + .../llm_service/mixin_generate_entry.py | 47 + .../llm_service/mixin_guardrails_prompt.py | 181 + .../llm_service/mixin_openclaw_routing.py | 212 + .../mixin_prompt_profile_runtime.py | 245 + .../mixin_structured_candidates.py | 96 + .../mixin_structured_extract_object.py | 125 + .../llm_service/mixin_structured_parse.py | 177 + .../llm_service/mixin_task_helpers.py | 105 + .../mixin_tier_engine_selection.py | 265 + .../llm_service/mixin_tier_escalation.py | 217 + backend/app/services/llm_service/service.py | 52 + backend/app/services/llm_service/shared.py | 4 + .../services/llm_service/shared_part_01.py | 294 + .../services/llm_service/shared_part_02.py | 81 + .../app/services/model_capability/__init__.py | 5 + .../model_capability/mixin_cost_tracking.py | 260 + .../model_capability/mixin_gap_queries.py | 131 + .../model_capability/mixin_validation.py | 279 + .../app/services/model_capability/service.py | 16 + .../app/services/model_capability/shared.py | 29 + .../app/services/model_capability_service.py | 703 +- .../services/model_context_bundle/__init__.py | 11 + .../model_context_bundle/mixin_build.py | 182 + .../model_context_bundle/mixin_core.py | 108 + .../model_context_bundle/mixin_verify.py | 170 + .../services/model_context_bundle/service.py | 17 + .../services/model_context_bundle/shared.py | 58 + .../services/model_context_bundle_service.py | 444 +- .../app/services/project_knowledge_graph.py | 2075 +---- .../__init__.py | 5 + .../mixin_codebase_overview.py | 136 + .../mixin_crud_ingest.py | 227 + .../mixin_cycle_run.py | 150 + .../mixin_event_queue.py | 163 + .../mixin_feature_catalog.py | 230 + .../mixin_guardrails.py | 283 + .../mixin_structured_delta.py | 292 + .../mixin_structured_snapshot.py | 192 + .../mixin_symbol_sync.py | 214 + .../mixin_sync_runs.py | 180 + .../service.py | 30 + .../project_knowledge_graph_service/shared.py | 58 + backend/app/services/scheduler_center.py | 459 +- .../scheduler_center_core/__init__.py | 13 + .../scheduler_center_core/ai_pmo_cadence.py | 160 + .../scheduler_center_core/knowledge_graph.py | 99 + .../scheduler_center_core/knowledge_url.py | 100 + .../services/scheduler_center_core/shared.py | 41 + .../scheduler_center_core/workload.py | 84 + backend/app/services/signal_capture.py | 2282 +----- .../signal_capture_service/__init__.py | 5 + .../mixin_analysis_normalize.py | 268 + .../mixin_capture_entry.py | 173 + .../mixin_capture_legacy.py | 199 + .../mixin_external_sources.py | 232 + .../mixin_new_project.py | 148 + .../mixin_pipeline_cache.py | 260 + .../mixin_source_analyzers.py | 207 + .../mixin_task_orchestration_main.py | 229 + .../mixin_task_orchestration_skill.py | 175 + .../mixin_task_orchestration_team.py | 280 + .../signal_capture_service/service.py | 36 + .../services/signal_capture_service/shared.py | 173 + .../app/services/signal_planner/__init__.py | 5 + .../services/signal_planner/mixin_analyze.py | 187 + .../services/signal_planner/mixin_loaders.py | 196 + .../services/signal_planner/mixin_prompt.py | 41 + .../services/signal_planner/mixin_utils.py | 124 + .../app/services/signal_planner/service.py | 19 + backend/app/services/signal_planner/shared.py | 60 + .../app/services/signal_planner_service.py | 482 +- .../signal_source_orchestration/__init__.py | 5 + .../mixin_entry_orchestrate.py | 205 + .../mixin_entry_terminal_a.py | 159 + .../mixin_entry_terminal_b.py | 164 + .../mixin_execute_isolation.py | 217 + .../mixin_lookup_parse.py | 134 + .../mixin_planning_locator_roles.py | 95 + .../mixin_planning_onboard.py | 271 + .../mixin_registry_execute.py | 297 + .../mixin_result_resolution.py | 187 + .../mixin_result_run_updates.py | 154 + .../mixin_worker_prompt_persist.py | 128 + .../signal_source_orchestration/service.py | 32 + .../signal_source_orchestration/shared.py | 57 + .../signal_source_orchestration_service.py | 1852 +---- .../services/signal_source_repair/__init__.py | 15 + .../services/signal_source_repair/errors.py | 33 + .../signal_source_repair/mixin_capture.py | 50 + .../signal_source_repair/mixin_data_access.py | 81 + .../signal_source_repair/mixin_persist.py | 105 + .../signal_source_repair/mixin_repair.py | 136 + .../signal_source_repair/mixin_utils.py | 101 + .../services/signal_source_repair/service.py | 23 + .../services/signal_source_repair/shared.py | 36 + .../services/signal_source_repair_service.py | 476 +- backend/app/services/skill/__init__.py | 22 + backend/app/services/skill/bindings.py | 210 + backend/app/services/skill/shared.py | 38 + backend/app/services/skill/tag_crud.py | 139 + .../app/services/skill/workload_recommend.py | 167 + backend/app/services/skill_service.py | 574 +- backend/app/services/sstc/__init__.py | 1 + backend/app/services/sstc/charter_data.py | 128 + .../services/sstc/charter_prefill_builder.py | 188 + .../services/sstc/charter_prefill_utils.py | 135 + .../app/services/sstc/charter_seed_utils.py | 138 + backend/app/services/sstc/entity_lookup.py | 108 + backend/app/services/sstc/feishu_routing.py | 109 + backend/app/services/sstc/governance.py | 229 + backend/app/services/sstc/review_utils.py | 71 + backend/app/services/sstc/shared.py | 145 + backend/app/services/sstc/signal_core.py | 218 + backend/app/services/sstc/signal_filters.py | 171 + backend/app/services/sstc_service.py | 641 +- .../services/sstc_service_core/__init__.py | 3 + .../sstc_service_core/mixin_build_views.py | 298 + .../sstc_service_core/mixin_fetch_core.py | 249 + .../sstc_service_core/mixin_members.py | 52 + .../app/services/sstc_service_core/service.py | 25 + .../app/services/sstc_service_core/shared.py | 58 + .../sstc_service_core/shared_helpers.py | 24 + backend/app/services/symbol_index/__init__.py | 5 + .../symbol_index/mixin_parse_python.py | 181 + .../symbol_index/mixin_parse_typescript.py | 131 + .../symbol_index/mixin_query_overview.py | 176 + .../services/symbol_index/mixin_serialize.py | 37 + .../services/symbol_index/mixin_sync_core.py | 220 + backend/app/services/symbol_index/service.py | 21 + backend/app/services/symbol_index/shared.py | 57 + backend/app/services/symbol_index_service.py | 776 +- backend/app/services/task_brief/__init__.py | 5 + .../services/task_brief/mixin_bundle_crud.py | 170 + .../task_brief/mixin_generate_status.py | 119 + .../services/task_brief/mixin_seed_history.py | 115 + .../services/task_brief/mixin_templates.py | 190 + backend/app/services/task_brief/service.py | 18 + backend/app/services/task_brief/shared.py | 155 + backend/app/services/task_brief_service.py | 728 +- backend/app/services/task_persistence.py | 356 +- .../task_persistence_core/__init__.py | 32 + .../services/task_persistence_core/models.py | 31 + .../task_persistence_core/normalize.py | 130 + .../services/task_persistence_core/persist.py | 73 + .../services/task_persistence_core/shared.py | 67 + .../task_persistence_core/skill_resolution.py | 79 + backend/app/services/workload/__init__.py | 23 + backend/app/services/workload/ai_optimize.py | 292 + .../app/services/workload/board_queries_a.py | 185 + .../app/services/workload/board_queries_b.py | 127 + backend/app/services/workload/core_ratio.py | 113 + backend/app/services/workload/shared.py | 24 + backend/app/services/workload_service.py | 756 +- 583 files changed, 62168 insertions(+), 57202 deletions(-) create mode 100644 backend/app/api/ai_logs_utils.py create mode 100644 backend/app/api/ai_pmo_api/__init__.py create mode 100644 backend/app/api/ai_pmo_api/helpers_inbox_filters.py create mode 100644 backend/app/api/ai_pmo_api/helpers_inbox_payload.py create mode 100644 backend/app/api/ai_pmo_api/helpers_resume.py create mode 100644 backend/app/api/ai_pmo_api/routes_cadence.py create mode 100644 backend/app/api/ai_pmo_api/routes_core.py create mode 100644 backend/app/api/ai_pmo_api/routes_resume.py create mode 100644 backend/app/api/ai_pmo_api/schemas.py create mode 100644 backend/app/api/ai_pmo_api/shared.py create mode 100644 backend/app/api/ai_steward_api/__init__.py create mode 100644 backend/app/api/ai_steward_api/helpers.py create mode 100644 backend/app/api/ai_steward_api/routes_brief_blockers.py create mode 100644 backend/app/api/ai_steward_api/routes_capture.py create mode 100644 backend/app/api/ai_steward_api/routes_conflicts.py create mode 100644 backend/app/api/ai_steward_api/routes_rag_sources.py create mode 100644 backend/app/api/ai_steward_api/routes_stats_config.py create mode 100644 backend/app/api/ai_steward_api/routes_workflow.py create mode 100644 backend/app/api/ai_steward_api/schemas.py create mode 100644 backend/app/api/ai_steward_api/shared.py create mode 100644 backend/app/api/endpoints/skill_api/__init__.py create mode 100644 backend/app/api/endpoints/skill_api/bindings.py create mode 100644 backend/app/api/endpoints/skill_api/recommend.py create mode 100644 backend/app/api/endpoints/skill_api/shared.py create mode 100644 backend/app/api/endpoints/skill_api/tags.py create mode 100644 backend/app/api/endpoints/skill_api/workload.py create mode 100644 backend/app/api/feishu_api/__init__.py create mode 100644 backend/app/api/feishu_api/config_routes.py create mode 100644 backend/app/api/feishu_api/helpers.py create mode 100644 backend/app/api/feishu_api/long_connection_routes.py create mode 100644 backend/app/api/feishu_api/message_routes.py create mode 100644 backend/app/api/feishu_api/router.py create mode 100644 backend/app/api/feishu_api/schemas.py create mode 100644 backend/app/api/feishu_api/test_routes.py create mode 100644 backend/app/api/model_capability_api/__init__.py create mode 100644 backend/app/api/model_capability_api/helpers.py create mode 100644 backend/app/api/model_capability_api/routes_context.py create mode 100644 backend/app/api/model_capability_api/routes_experiment_sync.py create mode 100644 backend/app/api/model_capability_api/routes_lifecycle.py create mode 100644 backend/app/api/model_capability_api/routes_match_cost.py create mode 100644 backend/app/api/model_capability_api/shared.py create mode 100644 backend/app/api/projects_api/__init__.py create mode 100644 backend/app/api/projects_api/helpers.py create mode 100644 backend/app/api/projects_api/routes_list.py create mode 100644 backend/app/api/projects_api/routes_mutation.py create mode 100644 backend/app/api/projects_api/routes_signals.py create mode 100644 backend/app/api/projects_api/schemas.py create mode 100644 backend/app/api/projects_api/shared.py create mode 100644 backend/app/api/rag_api/__init__.py create mode 100644 backend/app/api/rag_api/helpers.py create mode 100644 backend/app/api/rag_api/routes_knowledge.py create mode 100644 backend/app/api/rag_api/routes_qa.py create mode 100644 backend/app/api/rag_api/routes_query.py create mode 100644 backend/app/api/rag_api/shared.py create mode 100644 backend/app/api/signal_sources_models.py create mode 100644 backend/app/api/signal_sources_utils.py create mode 100644 backend/app/api/signals_api/__init__.py create mode 100644 backend/app/api/signals_api/feedback.py create mode 100644 backend/app/api/signals_api/ingest_fetch.py create mode 100644 backend/app/api/signals_api/list_create.py create mode 100644 backend/app/api/skill_tags_contracts.py create mode 100644 backend/app/api/sstc_api/__init__.py create mode 100644 backend/app/api/sstc_api/ai_routing.py create mode 100644 backend/app/api/sstc_api/charters_ai_alignment.py create mode 100644 backend/app/api/sstc_api/charters_detail_update.py create mode 100644 backend/app/api/sstc_api/charters_list_create.py create mode 100644 backend/app/api/sstc_api/overview.py create mode 100644 backend/app/api/sstc_api/releases_list_create.py create mode 100644 backend/app/api/sstc_api/releases_status_generate.py create mode 100644 backend/app/api/sstc_api/releases_verification.py create mode 100644 backend/app/api/sstc_api/reviews_archive.py create mode 100644 backend/app/api/sstc_api/reviews_list_create.py create mode 100644 backend/app/api/sstc_api/reviews_patch_convert.py create mode 100644 backend/app/api/sstc_api/shared.py create mode 100644 backend/app/api/sstc_api/signals_archive.py create mode 100644 backend/app/api/sstc_api/signals_attachment.py create mode 100644 backend/app/api/sstc_api/signals_list.py create mode 100644 backend/app/api/sstc_api/tasks_ai_breakdown.py create mode 100644 backend/app/api/sstc_api/tasks_board_status.py create mode 100644 backend/app/api/sstc_api/tasks_create.py create mode 100644 backend/app/api/sstc_api/tasks_recommend_optimize.py create mode 100644 backend/app/api/sstc_api/tasks_update_delete.py create mode 100644 backend/app/api/users_api/__init__.py create mode 100644 backend/app/api/users_api/helpers_roles.py create mode 100644 backend/app/api/users_api/helpers_user.py create mode 100644 backend/app/api/users_api/routes_bulk.py create mode 100644 backend/app/api/users_api/routes_import.py create mode 100644 backend/app/api/users_api/routes_mutation.py create mode 100644 backend/app/api/users_api/routes_query.py create mode 100644 backend/app/api/users_api/schemas.py create mode 100644 backend/app/api/users_api/shared.py create mode 100644 backend/app/api/wecom_api/__init__.py create mode 100644 backend/app/api/wecom_api/config.py create mode 100644 backend/app/api/wecom_api/messaging.py create mode 100644 backend/app/api/wecom_api/schemas.py create mode 100644 backend/app/api/wecom_api/webhook.py create mode 100644 backend/app/config/ai_prompts_catalog/__init__.py create mode 100644 backend/app/config/ai_prompts_catalog/part_01.py create mode 100644 backend/app/config/ai_prompts_catalog/part_02.py create mode 100644 backend/app/config/ai_prompts_catalog/part_03.py create mode 100644 backend/app/config/ai_prompts_catalog/part_04.py create mode 100644 backend/app/config/operation_catalog_core/__init__.py create mode 100644 backend/app/config/operation_catalog_core/helpers.py create mode 100644 backend/app/config/operation_catalog_core/models.py create mode 100644 backend/app/config/operation_catalog_core/notification_file.py create mode 100644 backend/app/config/operation_catalog_core/personnel_knowledge.py create mode 100644 backend/app/config/operation_catalog_core/project_task.py create mode 100644 backend/app/dispatcher_rules.py create mode 100644 backend/app/events_models.py create mode 100644 backend/app/main_db_guard.py create mode 100644 backend/app/main_feishu_outbox.py create mode 100644 backend/app/main_model_probe.py create mode 100644 backend/app/main_warmup.py create mode 100644 backend/app/seed_projects_helpers.py create mode 100644 backend/app/services/adaptive_rag_service/__init__.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_answer_entry.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_answer_payload.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_answer_precheck.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_conversation_utils_a_p1.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_conversation_utils_a_p2.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_conversation_utils_b.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_core_intent.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_deep_guard_rules.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_deterministic_query_core.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_deterministic_query_count.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_deterministic_query_list.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_fast_answer_project_main.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_fast_answer_project_utils.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p2.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_ingest.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_intent_correction.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_knowledge_docs.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_manifest_core.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_manifest_feature_p1.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_manifest_feature_p2.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_multistep_core.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_multistep_flow.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_qa_feedback_records.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_qa_session_core.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_qa_session_list_ops_p1.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_qa_session_list_ops_p2.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_qa_stats_export.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_requirement_ack_core.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_requirement_ack_flow.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_retrieval_graph.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_retrieval_router_db.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_retrieval_vector.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_session_state.py create mode 100644 backend/app/services/adaptive_rag_service/mixin_symbol_snapshot.py create mode 100644 backend/app/services/adaptive_rag_service/service.py create mode 100644 backend/app/services/adaptive_rag_service/shared.py create mode 100644 backend/app/services/adaptive_rag_service/shared_imports.py create mode 100644 backend/app/services/adaptive_rag_service/shared_part_01.py create mode 100644 backend/app/services/adaptive_rag_service/shared_part_02.py create mode 100644 backend/app/services/adaptive_rag_service/shared_utils.py create mode 100644 backend/app/services/ai_pmo_cadence/__init__.py create mode 100644 backend/app/services/ai_pmo_cadence/mixin_builders.py create mode 100644 backend/app/services/ai_pmo_cadence/mixin_program_view.py create mode 100644 backend/app/services/ai_pmo_cadence/mixin_queries_a.py create mode 100644 backend/app/services/ai_pmo_cadence/mixin_queries_b.py create mode 100644 backend/app/services/ai_pmo_cadence/mixin_snapshots.py create mode 100644 backend/app/services/ai_pmo_cadence/service.py create mode 100644 backend/app/services/ai_pmo_cadence/shared.py create mode 100644 backend/app/services/ai_pmo_decision/__init__.py create mode 100644 backend/app/services/ai_pmo_decision/engine.py create mode 100644 backend/app/services/ai_pmo_decision/policy.py create mode 100644 backend/app/services/ai_pmo_decision/shared.py create mode 100644 backend/app/services/ai_pmo_decision/simulation.py create mode 100644 backend/app/services/ai_pmo_decision/strategic.py create mode 100644 backend/app/services/ai_pmo_decision/tactical.py create mode 100644 backend/app/services/ai_pmo_intent/__init__.py create mode 100644 backend/app/services/ai_pmo_intent/intent_resolver.py create mode 100644 backend/app/services/ai_pmo_intent/models.py create mode 100644 backend/app/services/ai_pmo_intent/route_resolver.py create mode 100644 backend/app/services/ai_pmo_intent/shared.py create mode 100644 backend/app/services/ai_pmo_kernel_common_core/__init__.py create mode 100644 backend/app/services/ai_pmo_kernel_common_core/constants.py create mode 100644 backend/app/services/ai_pmo_kernel_common_core/env.py create mode 100644 backend/app/services/ai_pmo_kernel_common_core/helpers.py create mode 100644 backend/app/services/ai_pmo_kernel_common_core/imports.py create mode 100644 backend/app/services/ai_pmo_kernel_ingress_flow/__init__.py create mode 100644 backend/app/services/ai_pmo_kernel_ingress_flow/mixin_auto.py create mode 100644 backend/app/services/ai_pmo_kernel_ingress_flow/mixin_entry.py create mode 100644 backend/app/services/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py create mode 100644 backend/app/services/ai_pmo_kernel_ingress_flow/service.py create mode 100644 backend/app/services/ai_pmo_kernel_ingress_flow/shared.py create mode 100644 backend/app/services/ai_pmo_kernel_world_model_aggregates/__init__.py create mode 100644 backend/app/services/ai_pmo_kernel_world_model_aggregates/mixin_basics.py create mode 100644 backend/app/services/ai_pmo_kernel_world_model_aggregates/mixin_project.py create mode 100644 backend/app/services/ai_pmo_kernel_world_model_aggregates/service.py create mode 100644 backend/app/services/ai_pmo_kernel_world_model_aggregates/shared.py create mode 100644 backend/app/services/ai_pmo_kernel_world_model_core/__init__.py create mode 100644 backend/app/services/ai_pmo_kernel_world_model_core/mixin_builder.py create mode 100644 backend/app/services/ai_pmo_kernel_world_model_core/mixin_cache.py create mode 100644 backend/app/services/ai_pmo_kernel_world_model_core/mixin_project.py create mode 100644 backend/app/services/ai_pmo_kernel_world_model_core/service.py create mode 100644 backend/app/services/ai_pmo_kernel_world_model_core/shared.py create mode 100644 backend/app/services/ai_pmo_typed_executor_discovery/__init__.py create mode 100644 backend/app/services/ai_pmo_typed_executor_discovery/mixin_analysis.py create mode 100644 backend/app/services/ai_pmo_typed_executor_discovery/mixin_context.py create mode 100644 backend/app/services/ai_pmo_typed_executor_discovery/mixin_discover.py create mode 100644 backend/app/services/ai_pmo_typed_executor_discovery/service.py create mode 100644 backend/app/services/ai_pmo_typed_executor_discovery/shared.py create mode 100644 backend/app/services/ai_pmo_typed_executor_execute/__init__.py create mode 100644 backend/app/services/ai_pmo_typed_executor_execute/mixin_execute_query_project.py create mode 100644 backend/app/services/ai_pmo_typed_executor_execute/mixin_execute_signal_create.py create mode 100644 backend/app/services/ai_pmo_typed_executor_execute/mixin_execute_signal_source.py create mode 100644 backend/app/services/ai_pmo_typed_executor_execute/mixin_helpers.py create mode 100644 backend/app/services/ai_pmo_typed_executor_execute/service.py create mode 100644 backend/app/services/ai_pmo_typed_executor_execute/shared.py create mode 100644 backend/app/services/ai_service_core/__init__.py create mode 100644 backend/app/services/ai_service_core/mixin_business_analysis.py create mode 100644 backend/app/services/ai_service_core/mixin_business_tasks.py create mode 100644 backend/app/services/ai_service_core/mixin_runtime_core.py create mode 100644 backend/app/services/ai_service_core/mixin_runtime_prompt_retry.py create mode 100644 backend/app/services/ai_service_core/service.py create mode 100644 backend/app/services/ai_service_core/shared.py create mode 100644 backend/app/services/ai_steward_service/__init__.py create mode 100644 backend/app/services/ai_steward_service/mixin_creation_normalize_persist.py create mode 100644 backend/app/services/ai_steward_service/mixin_creation_pipeline.py create mode 100644 backend/app/services/ai_steward_service/mixin_draft_generation.py create mode 100644 backend/app/services/ai_steward_service/mixin_json_parsing.py create mode 100644 backend/app/services/ai_steward_service/mixin_title_utils.py create mode 100644 backend/app/services/ai_steward_service/service.py create mode 100644 backend/app/services/ai_steward_service/shared.py create mode 100644 backend/app/services/auditor_core/__init__.py create mode 100644 backend/app/services/auditor_core/mixin_ai_score.py create mode 100644 backend/app/services/auditor_core/mixin_checks.py create mode 100644 backend/app/services/auditor_core/mixin_pipeline.py create mode 100644 backend/app/services/auditor_core/service.py create mode 100644 backend/app/services/auditor_core/shared.py create mode 100644 backend/app/services/auto_merger_core/__init__.py create mode 100644 backend/app/services/auto_merger_core/mixin_entry.py create mode 100644 backend/app/services/auto_merger_core/mixin_events_guard.py create mode 100644 backend/app/services/auto_merger_core/mixin_execute.py create mode 100644 backend/app/services/auto_merger_core/registry.py create mode 100644 backend/app/services/auto_merger_core/schemas.py create mode 100644 backend/app/services/auto_merger_core/service.py create mode 100644 backend/app/services/auto_merger_core/shared.py create mode 100644 backend/app/services/blocker_detection_core/__init__.py create mode 100644 backend/app/services/blocker_detection_core/mixin_ai.py create mode 100644 backend/app/services/blocker_detection_core/mixin_scan.py create mode 100644 backend/app/services/blocker_detection_core/registry.py create mode 100644 backend/app/services/blocker_detection_core/service.py create mode 100644 backend/app/services/blocker_detection_core/shared.py create mode 100644 backend/app/services/budget_enforcement/__init__.py create mode 100644 backend/app/services/budget_enforcement/mixin_alerts.py create mode 100644 backend/app/services/budget_enforcement/mixin_checks.py create mode 100644 backend/app/services/budget_enforcement/mixin_storage.py create mode 100644 backend/app/services/budget_enforcement/service.py create mode 100644 backend/app/services/budget_enforcement/shared.py create mode 100644 backend/app/services/codegen_core/__init__.py create mode 100644 backend/app/services/codegen_core/mixin_context.py create mode 100644 backend/app/services/codegen_core/mixin_prompt_llm.py create mode 100644 backend/app/services/codegen_core/mixin_review_summary.py create mode 100644 backend/app/services/codegen_core/mixin_validation_diff.py create mode 100644 backend/app/services/codegen_core/registry.py create mode 100644 backend/app/services/codegen_core/schemas.py create mode 100644 backend/app/services/codegen_core/service.py create mode 100644 backend/app/services/codegen_core/shared.py create mode 100644 backend/app/services/cognitive_conflict_core/__init__.py create mode 100644 backend/app/services/cognitive_conflict_core/mixin_detection.py create mode 100644 backend/app/services/cognitive_conflict_core/mixin_resolution.py create mode 100644 backend/app/services/cognitive_conflict_core/mixin_storage.py create mode 100644 backend/app/services/cognitive_conflict_core/registry.py create mode 100644 backend/app/services/cognitive_conflict_core/service.py create mode 100644 backend/app/services/cognitive_conflict_core/shared.py create mode 100644 backend/app/services/context_cache_service/__init__.py create mode 100644 backend/app/services/context_cache_service/invalidation.py create mode 100644 backend/app/services/context_cache_service/mixin_cache_core.py create mode 100644 backend/app/services/context_cache_service/mixin_profile_basics.py create mode 100644 backend/app/services/context_cache_service/mixin_profile_route_parser.py create mode 100644 backend/app/services/context_cache_service/mixin_project_team.py create mode 100644 backend/app/services/context_cache_service/mixin_session_invalidate.py create mode 100644 backend/app/services/context_cache_service/mixin_user_snapshot.py create mode 100644 backend/app/services/context_cache_service/service.py create mode 100644 backend/app/services/context_cache_service/shared.py create mode 100644 backend/app/services/daily_briefing_core/__init__.py create mode 100644 backend/app/services/daily_briefing_core/mixin_helpers_summary.py create mode 100644 backend/app/services/daily_briefing_core/mixin_personal.py create mode 100644 backend/app/services/daily_briefing_core/mixin_team.py create mode 100644 backend/app/services/daily_briefing_core/parsing.py create mode 100644 backend/app/services/daily_briefing_core/registry.py create mode 100644 backend/app/services/daily_briefing_core/service.py create mode 100644 backend/app/services/daily_briefing_core/shared.py create mode 100644 backend/app/services/domain_core/__init__.py create mode 100644 backend/app/services/domain_core/mixin_crud.py create mode 100644 backend/app/services/domain_core/mixin_runtime_stats.py create mode 100644 backend/app/services/domain_core/registry.py create mode 100644 backend/app/services/domain_core/schemas.py create mode 100644 backend/app/services/domain_core/service.py create mode 100644 backend/app/services/domain_core/shared.py create mode 100644 backend/app/services/event_workflow_core/__init__.py create mode 100644 backend/app/services/event_workflow_core/mixin_core.py create mode 100644 backend/app/services/event_workflow_core/mixin_handlers_primary.py create mode 100644 backend/app/services/event_workflow_core/mixin_handlers_secondary.py create mode 100644 backend/app/services/event_workflow_core/mixin_helpers.py create mode 100644 backend/app/services/event_workflow_core/service.py create mode 100644 backend/app/services/event_workflow_core/shared.py create mode 100644 backend/app/services/experiment_sync/__init__.py create mode 100644 backend/app/services/experiment_sync/mixin_helpers.py create mode 100644 backend/app/services/experiment_sync/mixin_review.py create mode 100644 backend/app/services/experiment_sync/mixin_sync.py create mode 100644 backend/app/services/experiment_sync/service.py create mode 100644 backend/app/services/experiment_sync/shared.py create mode 100644 backend/app/services/feishu_integration_service/__init__.py create mode 100644 backend/app/services/feishu_integration_service/mixin_config.py create mode 100644 backend/app/services/feishu_integration_service/mixin_message_payload.py create mode 100644 backend/app/services/feishu_integration_service/mixin_receive.py create mode 100644 backend/app/services/feishu_integration_service/mixin_receive_routing.py create mode 100644 backend/app/services/feishu_integration_service/mixin_reply_guard.py create mode 100644 backend/app/services/feishu_integration_service/mixin_reply_llm_core.py create mode 100644 backend/app/services/feishu_integration_service/mixin_reply_outbox.py create mode 100644 backend/app/services/feishu_integration_service/mixin_reply_templates.py create mode 100644 backend/app/services/feishu_integration_service/mixin_sender_resolution.py create mode 100644 backend/app/services/feishu_integration_service/mixin_trace.py create mode 100644 backend/app/services/feishu_integration_service/mixin_transport.py create mode 100644 backend/app/services/feishu_integration_service/service.py create mode 100644 backend/app/services/feishu_integration_service/shared.py create mode 100644 backend/app/services/feishu_long_connection_core/__init__.py create mode 100644 backend/app/services/feishu_long_connection_core/manager.py create mode 100644 backend/app/services/feishu_long_connection_core/mixin_lifecycle.py create mode 100644 backend/app/services/feishu_long_connection_core/mixin_lifecycle_connection.py create mode 100644 backend/app/services/feishu_long_connection_core/mixin_lifecycle_control.py create mode 100644 backend/app/services/feishu_long_connection_core/mixin_runtime.py create mode 100644 backend/app/services/feishu_long_connection_core/registry.py create mode 100644 backend/app/services/feishu_long_connection_core/shared.py create mode 100644 backend/app/services/feishu_signal_pipeline/__init__.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_command_commit_project.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_command_commit_task.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_command_exec_core.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_context_prefill_a.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_context_prefill_b.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_core_base.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_extraction.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_followup_a.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_followup_b.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_gate_dispatch.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_gate_project.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_gate_task.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_intent_gate_core.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_intent_support.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_item_finalize_routing.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_item_processing.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_lookup_and_block.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_message_utils_a.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_message_utils_b.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_persistence_lookup.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_process_message_core.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_process_message_default.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_process_message_helpers_a.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_process_message_helpers_b.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_queue_confirm_undo.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_queue_read_write.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_routing_notify.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_serialization.py create mode 100644 backend/app/services/feishu_signal_pipeline/mixin_task_command_queue.py create mode 100644 backend/app/services/feishu_signal_pipeline/service.py create mode 100644 backend/app/services/feishu_signal_pipeline/shared.py create mode 100644 backend/app/services/feishu_signal_pipeline/shared_core.py create mode 100644 backend/app/services/feishu_signal_pipeline/shared_schema_utils.py create mode 100644 backend/app/services/integration_core/__init__.py create mode 100644 backend/app/services/integration_core/mixin_clients.py create mode 100644 backend/app/services/integration_core/mixin_health.py create mode 100644 backend/app/services/integration_core/mixin_lifecycle.py create mode 100644 backend/app/services/integration_core/mixin_sync.py create mode 100644 backend/app/services/integration_core/registry.py create mode 100644 backend/app/services/integration_core/schemas.py create mode 100644 backend/app/services/integration_core/service.py create mode 100644 backend/app/services/integration_core/shared.py create mode 100644 backend/app/services/llm_service/__init__.py create mode 100644 backend/app/services/llm_service/mixin_cache_circuit.py create mode 100644 backend/app/services/llm_service/mixin_chat_openai.py create mode 100644 backend/app/services/llm_service/mixin_chat_transport.py create mode 100644 backend/app/services/llm_service/mixin_engine_admin.py create mode 100644 backend/app/services/llm_service/mixin_engine_resolution_core.py create mode 100644 backend/app/services/llm_service/mixin_generate_db_chat.py create mode 100644 backend/app/services/llm_service/mixin_generate_db_structured.py create mode 100644 backend/app/services/llm_service/mixin_generate_entry.py create mode 100644 backend/app/services/llm_service/mixin_guardrails_prompt.py create mode 100644 backend/app/services/llm_service/mixin_openclaw_routing.py create mode 100644 backend/app/services/llm_service/mixin_prompt_profile_runtime.py create mode 100644 backend/app/services/llm_service/mixin_structured_candidates.py create mode 100644 backend/app/services/llm_service/mixin_structured_extract_object.py create mode 100644 backend/app/services/llm_service/mixin_structured_parse.py create mode 100644 backend/app/services/llm_service/mixin_task_helpers.py create mode 100644 backend/app/services/llm_service/mixin_tier_engine_selection.py create mode 100644 backend/app/services/llm_service/mixin_tier_escalation.py create mode 100644 backend/app/services/llm_service/service.py create mode 100644 backend/app/services/llm_service/shared.py create mode 100644 backend/app/services/llm_service/shared_part_01.py create mode 100644 backend/app/services/llm_service/shared_part_02.py create mode 100644 backend/app/services/model_capability/__init__.py create mode 100644 backend/app/services/model_capability/mixin_cost_tracking.py create mode 100644 backend/app/services/model_capability/mixin_gap_queries.py create mode 100644 backend/app/services/model_capability/mixin_validation.py create mode 100644 backend/app/services/model_capability/service.py create mode 100644 backend/app/services/model_capability/shared.py create mode 100644 backend/app/services/model_context_bundle/__init__.py create mode 100644 backend/app/services/model_context_bundle/mixin_build.py create mode 100644 backend/app/services/model_context_bundle/mixin_core.py create mode 100644 backend/app/services/model_context_bundle/mixin_verify.py create mode 100644 backend/app/services/model_context_bundle/service.py create mode 100644 backend/app/services/model_context_bundle/shared.py create mode 100644 backend/app/services/project_knowledge_graph_service/__init__.py create mode 100644 backend/app/services/project_knowledge_graph_service/mixin_codebase_overview.py create mode 100644 backend/app/services/project_knowledge_graph_service/mixin_crud_ingest.py create mode 100644 backend/app/services/project_knowledge_graph_service/mixin_cycle_run.py create mode 100644 backend/app/services/project_knowledge_graph_service/mixin_event_queue.py create mode 100644 backend/app/services/project_knowledge_graph_service/mixin_feature_catalog.py create mode 100644 backend/app/services/project_knowledge_graph_service/mixin_guardrails.py create mode 100644 backend/app/services/project_knowledge_graph_service/mixin_structured_delta.py create mode 100644 backend/app/services/project_knowledge_graph_service/mixin_structured_snapshot.py create mode 100644 backend/app/services/project_knowledge_graph_service/mixin_symbol_sync.py create mode 100644 backend/app/services/project_knowledge_graph_service/mixin_sync_runs.py create mode 100644 backend/app/services/project_knowledge_graph_service/service.py create mode 100644 backend/app/services/project_knowledge_graph_service/shared.py create mode 100644 backend/app/services/scheduler_center_core/__init__.py create mode 100644 backend/app/services/scheduler_center_core/ai_pmo_cadence.py create mode 100644 backend/app/services/scheduler_center_core/knowledge_graph.py create mode 100644 backend/app/services/scheduler_center_core/knowledge_url.py create mode 100644 backend/app/services/scheduler_center_core/shared.py create mode 100644 backend/app/services/scheduler_center_core/workload.py create mode 100644 backend/app/services/signal_capture_service/__init__.py create mode 100644 backend/app/services/signal_capture_service/mixin_analysis_normalize.py create mode 100644 backend/app/services/signal_capture_service/mixin_capture_entry.py create mode 100644 backend/app/services/signal_capture_service/mixin_capture_legacy.py create mode 100644 backend/app/services/signal_capture_service/mixin_external_sources.py create mode 100644 backend/app/services/signal_capture_service/mixin_new_project.py create mode 100644 backend/app/services/signal_capture_service/mixin_pipeline_cache.py create mode 100644 backend/app/services/signal_capture_service/mixin_source_analyzers.py create mode 100644 backend/app/services/signal_capture_service/mixin_task_orchestration_main.py create mode 100644 backend/app/services/signal_capture_service/mixin_task_orchestration_skill.py create mode 100644 backend/app/services/signal_capture_service/mixin_task_orchestration_team.py create mode 100644 backend/app/services/signal_capture_service/service.py create mode 100644 backend/app/services/signal_capture_service/shared.py create mode 100644 backend/app/services/signal_planner/__init__.py create mode 100644 backend/app/services/signal_planner/mixin_analyze.py create mode 100644 backend/app/services/signal_planner/mixin_loaders.py create mode 100644 backend/app/services/signal_planner/mixin_prompt.py create mode 100644 backend/app/services/signal_planner/mixin_utils.py create mode 100644 backend/app/services/signal_planner/service.py create mode 100644 backend/app/services/signal_planner/shared.py create mode 100644 backend/app/services/signal_source_orchestration/__init__.py create mode 100644 backend/app/services/signal_source_orchestration/mixin_entry_orchestrate.py create mode 100644 backend/app/services/signal_source_orchestration/mixin_entry_terminal_a.py create mode 100644 backend/app/services/signal_source_orchestration/mixin_entry_terminal_b.py create mode 100644 backend/app/services/signal_source_orchestration/mixin_execute_isolation.py create mode 100644 backend/app/services/signal_source_orchestration/mixin_lookup_parse.py create mode 100644 backend/app/services/signal_source_orchestration/mixin_planning_locator_roles.py create mode 100644 backend/app/services/signal_source_orchestration/mixin_planning_onboard.py create mode 100644 backend/app/services/signal_source_orchestration/mixin_registry_execute.py create mode 100644 backend/app/services/signal_source_orchestration/mixin_result_resolution.py create mode 100644 backend/app/services/signal_source_orchestration/mixin_result_run_updates.py create mode 100644 backend/app/services/signal_source_orchestration/mixin_worker_prompt_persist.py create mode 100644 backend/app/services/signal_source_orchestration/service.py create mode 100644 backend/app/services/signal_source_orchestration/shared.py create mode 100644 backend/app/services/signal_source_repair/__init__.py create mode 100644 backend/app/services/signal_source_repair/errors.py create mode 100644 backend/app/services/signal_source_repair/mixin_capture.py create mode 100644 backend/app/services/signal_source_repair/mixin_data_access.py create mode 100644 backend/app/services/signal_source_repair/mixin_persist.py create mode 100644 backend/app/services/signal_source_repair/mixin_repair.py create mode 100644 backend/app/services/signal_source_repair/mixin_utils.py create mode 100644 backend/app/services/signal_source_repair/service.py create mode 100644 backend/app/services/signal_source_repair/shared.py create mode 100644 backend/app/services/skill/__init__.py create mode 100644 backend/app/services/skill/bindings.py create mode 100644 backend/app/services/skill/shared.py create mode 100644 backend/app/services/skill/tag_crud.py create mode 100644 backend/app/services/skill/workload_recommend.py create mode 100644 backend/app/services/sstc/__init__.py create mode 100644 backend/app/services/sstc/charter_data.py create mode 100644 backend/app/services/sstc/charter_prefill_builder.py create mode 100644 backend/app/services/sstc/charter_prefill_utils.py create mode 100644 backend/app/services/sstc/charter_seed_utils.py create mode 100644 backend/app/services/sstc/entity_lookup.py create mode 100644 backend/app/services/sstc/feishu_routing.py create mode 100644 backend/app/services/sstc/governance.py create mode 100644 backend/app/services/sstc/review_utils.py create mode 100644 backend/app/services/sstc/shared.py create mode 100644 backend/app/services/sstc/signal_core.py create mode 100644 backend/app/services/sstc/signal_filters.py create mode 100644 backend/app/services/sstc_service_core/__init__.py create mode 100644 backend/app/services/sstc_service_core/mixin_build_views.py create mode 100644 backend/app/services/sstc_service_core/mixin_fetch_core.py create mode 100644 backend/app/services/sstc_service_core/mixin_members.py create mode 100644 backend/app/services/sstc_service_core/service.py create mode 100644 backend/app/services/sstc_service_core/shared.py create mode 100644 backend/app/services/sstc_service_core/shared_helpers.py create mode 100644 backend/app/services/symbol_index/__init__.py create mode 100644 backend/app/services/symbol_index/mixin_parse_python.py create mode 100644 backend/app/services/symbol_index/mixin_parse_typescript.py create mode 100644 backend/app/services/symbol_index/mixin_query_overview.py create mode 100644 backend/app/services/symbol_index/mixin_serialize.py create mode 100644 backend/app/services/symbol_index/mixin_sync_core.py create mode 100644 backend/app/services/symbol_index/service.py create mode 100644 backend/app/services/symbol_index/shared.py create mode 100644 backend/app/services/task_brief/__init__.py create mode 100644 backend/app/services/task_brief/mixin_bundle_crud.py create mode 100644 backend/app/services/task_brief/mixin_generate_status.py create mode 100644 backend/app/services/task_brief/mixin_seed_history.py create mode 100644 backend/app/services/task_brief/mixin_templates.py create mode 100644 backend/app/services/task_brief/service.py create mode 100644 backend/app/services/task_brief/shared.py create mode 100644 backend/app/services/task_persistence_core/__init__.py create mode 100644 backend/app/services/task_persistence_core/models.py create mode 100644 backend/app/services/task_persistence_core/normalize.py create mode 100644 backend/app/services/task_persistence_core/persist.py create mode 100644 backend/app/services/task_persistence_core/shared.py create mode 100644 backend/app/services/task_persistence_core/skill_resolution.py create mode 100644 backend/app/services/workload/__init__.py create mode 100644 backend/app/services/workload/ai_optimize.py create mode 100644 backend/app/services/workload/board_queries_a.py create mode 100644 backend/app/services/workload/board_queries_b.py create mode 100644 backend/app/services/workload/core_ratio.py create mode 100644 backend/app/services/workload/shared.py diff --git a/CLAUDE.md b/CLAUDE.md index f474ac6c..84c7eea5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,6 +120,42 @@ backend/ - 新增:`backend/app/services/ai_pmo_kernel_mixins_closure_data.py`(Kernel 闭环历史上下文回放) - 新增:`backend/app/services/ai_pmo_kernel_mixins_closure_payload.py`(Kernel 闭环语义与来源上下文组装) - 更新:`backend/app/services/ai_pmo_kernel_service.py`(兼容入口层,仅导出 `AIPMOKernelService`) +- 新增:`backend/app/services/feishu_signal_pipeline/`(飞书信号管道按职责拆分的服务包,所有子文件 <= 300 行) +- 新增:`backend/app/services/feishu_signal_pipeline/service.py`(组合入口,按 mixin 聚合能力) +- 新增:`backend/app/services/feishu_signal_pipeline/shared_core.py`、`shared_schema_utils.py`(共享常量/正则/Schema/工具函数拆分) +- 新增:`backend/app/services/feishu_signal_pipeline/mixin_*.py`(按职责拆分的处理流、gate、followup、queue、序列化、提取分类等实现) +- 更新:`backend/app/services/feishu_signal_pipeline.py`(降级为兼容入口,仅导出包内 `FeishuSignalPipelineService`) +- 新增:`backend/app/api/feishu_api/`(飞书接口按配置/长连接/消息发送/连通性测试拆分的子模块目录) +- 新增:`backend/app/api/feishu_api/router.py`(飞书 API 聚合路由) +- 新增:`backend/app/api/feishu_api/config_routes.py`(飞书配置相关接口) +- 新增:`backend/app/api/feishu_api/long_connection_routes.py`(飞书长连接状态与重启接口) +- 新增:`backend/app/api/feishu_api/message_routes.py`(飞书消息与卡片发送接口) +- 新增:`backend/app/api/feishu_api/test_routes.py`(飞书连通性测试接口) +- 新增:`backend/app/api/feishu_api/schemas.py`(飞书 API 请求模型) +- 新增:`backend/app/api/feishu_api/helpers.py`(飞书 API 公共辅助函数) +- 更新:`backend/app/api/feishu.py`(降级为兼容入口,仅导出聚合 `router`) +- 新增:`backend/app/api/sstc_api/`(SSTC 路由按领域拆分:signals/charters/tasks/releases/reviews/overview/ai-routing) +- 新增:`backend/app/api/sstc_api/shared.py`(SSTC 路由层公共依赖导入) +- 更新:`backend/app/api/sstc.py`(降级为兼容入口,仅导出聚合 `router`) +- 新增:`backend/app/services/sstc/`(SSTC 业务辅助能力拆分:signal/charter/governance/entity_lookup/review) +- 新增:`backend/app/services/llm_service/`(LLM 服务按职责拆分的 mixin 包,所有子文件 <= 300 行) +- 更新:`backend/app/services/llm.py`(降级为兼容入口,导出 `LLMService` 与 `llm_service`) +- 新增:`backend/app/services/signal_capture_service/`(主动信号捕获服务按入口/编排/分析/新项目拆分) +- 更新:`backend/app/services/signal_capture.py`(降级为兼容入口) +- 新增:`backend/app/services/project_knowledge_graph_service/`(知识图谱服务按 CRUD/增量同步/守卫/快照拆分) +- 更新:`backend/app/services/project_knowledge_graph.py`(降级为兼容入口) +- 新增:`backend/app/services/context_cache_service/`(上下文缓存服务按缓存核心/画像/快照/失效钩子拆分) +- 更新:`backend/app/services/context_cache.py`(降级为兼容入口) +- 新增:`backend/app/config/ai_prompts_catalog/`(Prompt 常量分片目录,按 part_01..part_04 管理) +- 更新:`backend/app/config/ai_prompts.py`(兼容聚合入口,保留 `build_default_role_prompt_bundle`) +- 新增:`backend/app/api/signals_api/`(signals 路由拆分为 list/create、ingest/fetch、feedback 子模块) +- 新增:`backend/app/api/wecom_api/`(wecom 路由拆分为 config/webhook/messaging 子模块) +- 新增:`backend/app/api/endpoints/skill_api/`(人员能力图谱路由拆分为 tags/bindings/workload/recommend 子模块) +- 新增:`backend/app/api/ai_logs_utils.py`、`backend/app/api/signal_sources_models.py`、`backend/app/api/signal_sources_utils.py`(API 共享序列化/Schema/项目解析工具) +- 新增:`backend/app/config/operation_catalog_core/`(operation catalog 按模型/构造器/领域条目拆分) +- 新增:`backend/app/dispatcher_rules.py`、`backend/app/events_models.py`、`backend/app/seed_projects_helpers.py`(规则/事件模型/seed helper 拆分) +- 新增:`backend/app/main_db_guard.py`、`backend/app/main_feishu_outbox.py`、`backend/app/main_warmup.py`、`backend/app/main_model_probe.py`(main 启动职责拆分) +- 新增:`backend/app/services/ai_pmo_kernel_world_model_aggregates/`、`backend/app/services/ai_pmo_kernel_world_model_core/`、`backend/app/services/ai_pmo_kernel_common_core/`(kernel world-model/common 拆分) ## 5. 前端结构(`frontend/`) ```text diff --git a/backend/app/api/ai_logs.py b/backend/app/api/ai_logs.py index bfe7f8bc..eeb69e2d 100644 --- a/backend/app/api/ai_logs.py +++ b/backend/app/api/ai_logs.py @@ -2,158 +2,22 @@ from __future__ import annotations -import json from datetime import date, datetime -from typing import Any, Optional +from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import and_, case, func, or_, select, union_all +from sqlalchemy import and_, case, func, select from sqlalchemy.ext.asyncio import AsyncSession from ..core.response import success_response from ..database import get_db from ..dependencies.auth import get_current_user -from ..models import ( - AIProcessingLog, - FeishuItem, - ProjectSignalLink, - ProjectSuggestion, - Signal, - User, - WorkItem, -) - +from ..models import AIProcessingLog, User +from .ai_logs_utils import apply_project_filter, serialize_log_detail, serialize_log_row router = APIRouter(prefix="/ai-logs", tags=["AI Logs"]) -def _normalize_bullets(value: Any) -> list[str]: - if isinstance(value, list): - return [str(x).strip() for x in value if str(x).strip()][:3] - if isinstance(value, str): - raw = value.strip() - if not raw: - return [] - try: - parsed = json.loads(raw) - if isinstance(parsed, list): - return [str(x).strip() for x in parsed if str(x).strip()][:3] - except Exception: - pass - return [line.strip(" -•\t") for line in raw.splitlines() if line.strip()][:3] - return [] - - -def _safe_json(value: Optional[str]) -> Any: - text = (value or "").strip() - if not text: - return None - try: - return json.loads(text) - except Exception: - return None - - -def _serialize_log_row(row: AIProcessingLog) -> dict[str, Any]: - return { - "id": str(row.id), - "feishu_message_id": row.feishu_message_id, - "feishu_item_id": str(row.feishu_item_id) if row.feishu_item_id is not None else None, - "raw_message": row.raw_message, - "project_score": row.project_score, - "signal_confidence": row.signal_confidence, - "level": row.level, - "routing_decision": row.routing_decision, - "routing_reason": row.routing_reason, - "reasoning_bullets": _normalize_bullets(row.reasoning_bullets), - "processing_status": row.processing_status, - "error_message": row.error_message, - "duration_ms": row.duration_ms, - "created_at": row.created_at.isoformat() if row.created_at else None, - } - - -def _serialize_log_detail(row: AIProcessingLog) -> dict[str, Any]: - payload = _serialize_log_row(row) - payload["ai_raw_output"] = row.ai_raw_output - payload["ai_raw_output_json"] = _safe_json(row.ai_raw_output) - return payload - - -def _apply_project_filter(stmt, *, tenant_id: int, project_id: Optional[int]): - if project_id is None: - return stmt - - work_item_message_ids = select(func.distinct(WorkItem.source_message_id)).where( - and_( - WorkItem.tenant_id == tenant_id, - WorkItem.project_id == int(project_id), - WorkItem.source_system == "feishu", - WorkItem.source_message_id.is_not(None), - ) - ) - direct_items = select(FeishuItem.id.label("item_id")).where( - and_( - FeishuItem.tenant_id == tenant_id, - FeishuItem.matched_project_id == int(project_id), - ) - ) - signal_matched_items = select(Signal.feishu_item_id.label("item_id")).where( - and_( - Signal.tenant_id == tenant_id, - Signal.matched_project_id == int(project_id), - Signal.feishu_item_id.is_not(None), - ) - ) - linked_items = ( - select(Signal.feishu_item_id.label("item_id")) - .select_from(Signal) - .join( - ProjectSignalLink, - and_( - ProjectSignalLink.tenant_id == tenant_id, - ProjectSignalLink.project_id == int(project_id), - ProjectSignalLink.signal_id == Signal.id, - ), - ) - .where( - and_( - Signal.tenant_id == tenant_id, - Signal.feishu_item_id.is_not(None), - ) - ) - ) - auto_created_items = select(ProjectSuggestion.feishu_item_id.label("item_id")).where( - and_( - ProjectSuggestion.tenant_id == tenant_id, - ProjectSuggestion.created_project_id == int(project_id), - ProjectSuggestion.feishu_item_id.is_not(None), - ) - ) - - all_item_ids = union_all( - direct_items, - signal_matched_items, - linked_items, - auto_created_items, - ).subquery("project_item_ids") - item_ids_stmt = select(func.distinct(all_item_ids.c.item_id)).where(all_item_ids.c.item_id.is_not(None)) - message_ids_stmt = select(func.distinct(FeishuItem.feishu_message_id)).where( - and_( - FeishuItem.tenant_id == tenant_id, - FeishuItem.id.in_(item_ids_stmt), - FeishuItem.feishu_message_id.is_not(None), - ) - ) - return stmt.where( - or_( - AIProcessingLog.feishu_item_id.in_(item_ids_stmt), - AIProcessingLog.feishu_message_id.in_(message_ids_stmt), - AIProcessingLog.feishu_message_id.in_(work_item_message_ids), - ) - ) - - @router.get("") async def list_ai_logs( project_id: Optional[int] = Query(default=None, ge=1), @@ -171,12 +35,12 @@ async def list_ai_logs( .offset(offset) .limit(limit) ) - total_stmt = _apply_project_filter(total_stmt, tenant_id=tenant_id, project_id=project_id) - data_stmt = _apply_project_filter(data_stmt, tenant_id=tenant_id, project_id=project_id) + total_stmt = apply_project_filter(total_stmt, tenant_id=tenant_id, project_id=project_id) + data_stmt = apply_project_filter(data_stmt, tenant_id=tenant_id, project_id=project_id) total = int((await db.execute(total_stmt)).scalar() or 0) rows = (await db.execute(data_stmt)).scalars().all() - items = [_serialize_log_row(row) for row in rows] + items = [serialize_log_row(row) for row in rows] return success_response( { "items": items, @@ -218,9 +82,7 @@ async def get_ai_log_stats( func.sum( case( ( - AIProcessingLog.routing_decision.in_( - ["auto_create_project", "auto_enter_signal"] - ), + AIProcessingLog.routing_decision.in_(["auto_create_project", "auto_enter_signal"]), 1, ), else_=0, @@ -254,7 +116,7 @@ async def get_ai_log_stats( ) ) ) - stats_stmt = _apply_project_filter(stats_stmt, tenant_id=tenant_id, project_id=project_id) + stats_stmt = apply_project_filter(stats_stmt, tenant_id=tenant_id, project_id=project_id) row = (await db.execute(stats_stmt)).one() return success_response( { @@ -296,7 +158,7 @@ async def list_execute_reply_guard_alerts( return success_response( { "count": len(rows), - "items": [_serialize_log_row(row) for row in rows], + "items": [serialize_log_row(row) for row in rows], } ) @@ -314,9 +176,9 @@ async def get_latest_ai_log( .order_by(AIProcessingLog.created_at.desc(), AIProcessingLog.id.desc()) .limit(1) ) - stmt = _apply_project_filter(stmt, tenant_id=tenant_id, project_id=project_id) + stmt = apply_project_filter(stmt, tenant_id=tenant_id, project_id=project_id) row = (await db.execute(stmt)).scalar_one_or_none() - return success_response(_serialize_log_detail(row) if row else None) + return success_response(serialize_log_detail(row) if row else None) @router.get("/{log_id}") @@ -338,4 +200,4 @@ async def get_ai_log_detail( ).scalar_one_or_none() if row is None: raise HTTPException(status_code=404, detail="ai_log_not_found") - return success_response(_serialize_log_detail(row)) + return success_response(serialize_log_detail(row)) diff --git a/backend/app/api/ai_logs_utils.py b/backend/app/api/ai_logs_utils.py new file mode 100644 index 00000000..73a33a4f --- /dev/null +++ b/backend/app/api/ai_logs_utils.py @@ -0,0 +1,137 @@ +"""Shared serializers and query helpers for AI logs APIs.""" + +from __future__ import annotations + +import json +from typing import Any, Optional + +from sqlalchemy import and_, func, or_, select, union_all + +from ..models import AIProcessingLog, FeishuItem, ProjectSignalLink, ProjectSuggestion, Signal, WorkItem + + +def normalize_bullets(value: Any) -> list[str]: + if isinstance(value, list): + return [str(x).strip() for x in value if str(x).strip()][:3] + if isinstance(value, str): + raw = value.strip() + if not raw: + return [] + try: + parsed = json.loads(raw) + if isinstance(parsed, list): + return [str(x).strip() for x in parsed if str(x).strip()][:3] + except Exception: + pass + return [line.strip(" -•\t") for line in raw.splitlines() if line.strip()][:3] + return [] + + +def safe_json(value: Optional[str]) -> Any: + text = (value or "").strip() + if not text: + return None + try: + return json.loads(text) + except Exception: + return None + + +def serialize_log_row(row: AIProcessingLog) -> dict[str, Any]: + return { + "id": str(row.id), + "feishu_message_id": row.feishu_message_id, + "feishu_item_id": str(row.feishu_item_id) if row.feishu_item_id is not None else None, + "raw_message": row.raw_message, + "project_score": row.project_score, + "signal_confidence": row.signal_confidence, + "level": row.level, + "routing_decision": row.routing_decision, + "routing_reason": row.routing_reason, + "reasoning_bullets": normalize_bullets(row.reasoning_bullets), + "processing_status": row.processing_status, + "error_message": row.error_message, + "duration_ms": row.duration_ms, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + + +def serialize_log_detail(row: AIProcessingLog) -> dict[str, Any]: + payload = serialize_log_row(row) + payload["ai_raw_output"] = row.ai_raw_output + payload["ai_raw_output_json"] = safe_json(row.ai_raw_output) + return payload + + +def apply_project_filter(stmt, *, tenant_id: int, project_id: Optional[int]): + if project_id is None: + return stmt + + work_item_message_ids = select(func.distinct(WorkItem.source_message_id)).where( + and_( + WorkItem.tenant_id == tenant_id, + WorkItem.project_id == int(project_id), + WorkItem.source_system == "feishu", + WorkItem.source_message_id.is_not(None), + ) + ) + direct_items = select(FeishuItem.id.label("item_id")).where( + and_( + FeishuItem.tenant_id == tenant_id, + FeishuItem.matched_project_id == int(project_id), + ) + ) + signal_matched_items = select(Signal.feishu_item_id.label("item_id")).where( + and_( + Signal.tenant_id == tenant_id, + Signal.matched_project_id == int(project_id), + Signal.feishu_item_id.is_not(None), + ) + ) + linked_items = ( + select(Signal.feishu_item_id.label("item_id")) + .select_from(Signal) + .join( + ProjectSignalLink, + and_( + ProjectSignalLink.tenant_id == tenant_id, + ProjectSignalLink.project_id == int(project_id), + ProjectSignalLink.signal_id == Signal.id, + ), + ) + .where( + and_( + Signal.tenant_id == tenant_id, + Signal.feishu_item_id.is_not(None), + ) + ) + ) + auto_created_items = select(ProjectSuggestion.feishu_item_id.label("item_id")).where( + and_( + ProjectSuggestion.tenant_id == tenant_id, + ProjectSuggestion.created_project_id == int(project_id), + ProjectSuggestion.feishu_item_id.is_not(None), + ) + ) + + all_item_ids = union_all( + direct_items, + signal_matched_items, + linked_items, + auto_created_items, + ).subquery("project_item_ids") + item_ids_stmt = select(func.distinct(all_item_ids.c.item_id)).where(all_item_ids.c.item_id.is_not(None)) + message_ids_stmt = select(func.distinct(FeishuItem.feishu_message_id)).where( + and_( + FeishuItem.tenant_id == tenant_id, + FeishuItem.id.in_(item_ids_stmt), + FeishuItem.feishu_message_id.is_not(None), + ) + ) + return stmt.where( + or_( + AIProcessingLog.feishu_item_id.in_(item_ids_stmt), + AIProcessingLog.feishu_message_id.in_(message_ids_stmt), + AIProcessingLog.feishu_message_id.in_(work_item_message_ids), + ) + ) diff --git a/backend/app/api/ai_pmo.py b/backend/app/api/ai_pmo.py index e4d3f13c..450d33e2 100644 --- a/backend/app/api/ai_pmo.py +++ b/backend/app/api/ai_pmo.py @@ -1,1002 +1,5 @@ -"""AI PMO 决策中枢 API。""" - from __future__ import annotations -import asyncio -from datetime import datetime -import re -from typing import Any, Literal, Optional +from .ai_pmo_api import router -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, Field -from sqlalchemy import and_, select -from sqlalchemy.ext.asyncio import AsyncSession - -from ..core.logging import get_logger -from ..core.response import success_response -from ..database import AsyncSessionLocal, get_db -from ..dependencies.auth import get_current_user -from ..dependencies.tenant import get_tenant_id -from ..models import DecisionRecord, IngestEvent, PlanBundle, PortfolioSnapshot, Project, ReplyReceipt, User, WorkItem -from ..services.ai_pmo_cadence_service import AIPMOCadenceService -from ..services.ai_pmo_kernel_service import AIPMOKernelService - -router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"]) -logger = get_logger(__name__) -FRONT_NOISE_INTENTS = {"query.read_only", "query.answer"} -FRONT_NOISE_EXACT = { - "收到", - "收到啦", - "好的", - "ok", - "okk", - "yes", - "no", - "谢谢", - "thanks", - "thx", - "在吗", - "在不在", - "怎么样了", - "咋样了", - "辛苦了", -} -FRONT_NOISE_HINTS = ("怎么样了", "咋样了", "在吗", "辛苦了", "ok", "谢谢", "收到") -FRONT_NOISE_STRUCTURED_HINTS = ("项目", "信号", "任务", "风险", "进度", "状态", "work_item", "工作项", "#") -GENERIC_SOURCE_TEXT_RE = re.compile( - r"^(?:请)?(?:按|按照)?(?:这个|上述|这条)?(?:分步|步骤)?(?:骤)?(?:把|将)?(?:它|该需求|这条需求)?(?:加入|接入|录入).{0,24}信号源(?:中)?$", - re.IGNORECASE, -) -LOW_VALUE_CLOSURE_HINTS = ("缺少项目归属", "必须先确认项目", "无法创建", "不能创建") - - -class PMOProcessTextRequest(BaseModel): - message_text: str = Field(..., min_length=1, max_length=4000) - sender: Optional[str] = Field(default=None, max_length=120) - source_message_id: Optional[str] = Field(default=None, max_length=128) - project_id: Optional[int] = Field(default=None) - - -class PMOCadenceRunRequest(BaseModel): - cadence_type: str = Field(..., min_length=1, max_length=32) - project_id: Optional[int] = Field(default=None) - - -class PMOResumeWorkItemRequest(BaseModel): - action: Literal["continue", "commit", "refresh", "cancel"] = Field(default="continue") - project_id: Optional[int] = Field(default=None) - source_message_id: Optional[str] = Field(default=None, max_length=128) - - -class PMOWorkItemDecisionRequest(BaseModel): - action: Literal["approve", "reject"] = Field(..., description="审批动作:approve=继续执行,reject=标记失效") - project_id: Optional[int] = Field(default=None) - source_message_id: Optional[str] = Field(default=None, max_length=128) - reason: Optional[str] = Field(default=None, max_length=500) - - -@router.post("/process-text") -async def process_text( - request: PMOProcessTextRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - service = AIPMOKernelService(db, tenant_id=tenant_id) - result = await service.process_ingress( - source_system="pmflow_manual", - source_message_id=request.source_message_id, - message_text=request.message_text, - sender=str(request.sender or current_user.name or current_user.username or "PMFlow用户"), - channel_ref="pmflow_manual", - source_sender_open_id=None, - captured_at=None, - chat_type="manual", - project_hint_id=request.project_id, - ) - if not result.get("handled"): - raise HTTPException(status_code=400, detail=str(result.get("reason") or "AI PMO kernel 未处理该输入")) - return success_response(result) - - -@router.get("/work-items") -async def list_work_items( - limit: int = 20, - state: Optional[str] = None, - project_id: Optional[int] = None, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - _ = current_user - stmt = ( - select(WorkItem, IngestEvent, Project) - .join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id) - .outerjoin(Project, and_(Project.tenant_id == tenant_id, Project.id == WorkItem.project_id)) - .where(WorkItem.tenant_id == tenant_id) - .order_by(WorkItem.updated_at.desc(), WorkItem.id.desc()) - ) - if state: - stmt = stmt.where(WorkItem.state == state) - if project_id is not None: - stmt = stmt.where(WorkItem.project_id == int(project_id)) - rows = (await db.execute(stmt.limit(max(1, min(limit, 100))))).all() - work_item_ids = [int(row.id) for row, _, _ in rows] - latest_decision_projection = await _build_latest_decision_projection( - db=db, - tenant_id=tenant_id, - work_item_ids=work_item_ids, - ) - closure_payload_overrides = await _build_closure_payload_overrides( - db=db, - tenant_id=tenant_id, - rows=rows, - ) - items = [ - _serialize_work_item_summary( - row=row, - ingest=ingest, - project=project, - latest_decision_projection=latest_decision_projection.get(int(row.id)), - payload_override=closure_payload_overrides.get(int(row.id)), - ) - for row, ingest, project in rows - if not _should_hide_work_item_from_inbox(row=row, ingest=ingest) - ] - return success_response({"items": items}) - - -@router.get("/work-items/{work_item_id}") -async def get_work_item( - work_item_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - _ = current_user - row = ( - await db.execute( - select(WorkItem).where( - and_( - WorkItem.tenant_id == tenant_id, - WorkItem.id == int(work_item_id), - ) - ) - ) - ).scalar_one_or_none() - if row is None: - raise HTTPException(status_code=404, detail="work_item_not_found") - decision_rows = ( - await db.execute( - select(DecisionRecord).where( - and_( - DecisionRecord.tenant_id == tenant_id, - DecisionRecord.work_item_id == int(work_item_id), - ) - ).order_by(DecisionRecord.id.asc()) - ) - ).scalars().all() - plan_rows = ( - await db.execute( - select(PlanBundle).where( - and_( - PlanBundle.tenant_id == tenant_id, - PlanBundle.work_item_id == int(work_item_id), - ) - ).order_by(PlanBundle.id.asc()) - ) - ).scalars().all() - reply_rows = ( - await db.execute( - select(ReplyReceipt).where( - and_( - ReplyReceipt.tenant_id == tenant_id, - ReplyReceipt.work_item_id == int(work_item_id), - ) - ).order_by(ReplyReceipt.id.asc()) - ) - ).scalars().all() - payload: dict[str, Any] = { - "id": int(row.id), - "source_system": row.source_system, - "source_message_id": row.source_message_id, - "state": row.state, - "intent": row.intent, - "decision_level": row.decision_level, - "project_id": _safe_int(row.project_id), - "signal_id": _safe_int(row.signal_id), - "context_snapshot": row.context_snapshot, - "action_bundle": row.action_bundle, - "execution_result": row.execution_result, - "review_summary": row.review_summary, - "reply_template_key": row.reply_template_key, - "decision_records": [ - { - "id": int(item.id), - "decision_level": item.decision_level, - "decision_object_type": item.decision_object_type, - "decision_object_id": _safe_int(item.decision_object_id), - "rationale": item.rationale, - "risk_score": item.risk_score, - "budget_impact": item.budget_impact, - "resource_impact": item.resource_impact, - "simulation_result": item.simulation_result, - "policy_gate_hit": bool(item.policy_gate_hit), - "policy_gate_code": item.policy_gate_code, - "approval_requirement": item.approval_requirement, - "execution_result": item.execution_result, - "final_outcome": item.final_outcome, - } - for item in decision_rows - ], - "plan_bundles": [ - { - "id": int(item.id), - "plan_type": item.plan_type, - "summary": item.summary, - "payload": item.payload, - } - for item in plan_rows - ], - "reply_receipts": [ - { - "id": int(item.id), - "receipt_phase": item.receipt_phase, - "template_key": item.template_key, - "payload": item.payload, - "status": item.status, - } - for item in reply_rows - ], - } - return success_response(payload) - - -@router.post("/work-items/{work_item_id}/resume") -async def resume_work_item( - work_item_id: int, - request: PMOResumeWorkItemRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - target = ( - await db.execute( - select(WorkItem).where( - and_( - WorkItem.tenant_id == tenant_id, - WorkItem.id == int(work_item_id), - ) - ).limit(1) - ) - ).scalar_one_or_none() - if target is None: - raise HTTPException(status_code=404, detail="work_item_not_found") - action_text_map = { - "continue": "继续处理", - "commit": "确认并提交候选", - "refresh": "换一批候选", - "cancel": "取消本次候选", - } - action_message = action_text_map.get(str(request.action or "").strip().lower(), "继续处理") - service = AIPMOKernelService(db, tenant_id=tenant_id) - raw_result = await service.process_ingress( - source_system="ai_pmo_ui", - source_message_id=request.source_message_id or f"resume-{int(work_item_id)}-{int(datetime.now().timestamp() * 1000)}", - message_text=f"{action_message} work_item #{int(work_item_id)}", - sender=str(current_user.name or current_user.username or "PMFlow用户"), - channel_ref="ai_pmo_ui", - source_sender_open_id=None, - captured_at=None, - chat_type="manual", - project_hint_id=request.project_id or _safe_int(target.project_id), - ) - result = _normalize_resume_result(raw=raw_result, target_work_item=target) - return success_response(result) - - -@router.post("/work-items/{work_item_id}/decision") -async def decide_work_item( - work_item_id: int, - request: PMOWorkItemDecisionRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - target = ( - await db.execute( - select(WorkItem).where( - and_( - WorkItem.tenant_id == tenant_id, - WorkItem.id == int(work_item_id), - ) - ).limit(1) - ) - ).scalar_one_or_none() - if target is None: - raise HTTPException(status_code=404, detail="work_item_not_found") - - if request.action == "reject": - result = await _reject_work_item( - db=db, - tenant_id=tenant_id, - target=target, - current_user=current_user, - reason=request.reason, - ) - return success_response(result) - - if str(target.state or "").strip() == "waiting_manual" and request.project_id is not None: - target.project_id = int(request.project_id) - target.state = "executing" - target.reply_template_key = "action_processing" - current_execution = target.execution_result if isinstance(target.execution_result, dict) else {} - target.execution_result = { - **current_execution, - "status": "executing", - "manual_decision": "approve", - "resume_trigger": "queued_background", - "project_id": int(request.project_id), - "queued_at": datetime.now().isoformat(), - } - decision_row = ( - await db.execute( - select(DecisionRecord) - .where( - and_( - DecisionRecord.tenant_id == tenant_id, - DecisionRecord.work_item_id == int(target.id), - ) - ) - .order_by(DecisionRecord.id.desc()) - .limit(1) - ) - ).scalar_one_or_none() - if decision_row is not None and str(decision_row.policy_gate_code or "").strip() == "missing_project_scope": - decision_row.policy_gate_hit = False - decision_row.policy_gate_code = None - decision_row.approval_requirement = { - "required": False, - "reason": "project_scope_confirmed", - "message": "项目归属已确认,正在后台继续执行。", - } - decision_row.final_outcome = "executing" - await db.commit() - _queue_resume_work_item( - tenant_id=tenant_id, - work_item_id=int(work_item_id), - source_message_id=request.source_message_id, - sender=str(current_user.name or current_user.username or "PMFlow用户"), - project_id=int(request.project_id), - ) - return success_response( - { - "handled": True, - "status": "pending_manual", - "followup_reply": "已确认关联项目,正在后台继续执行。", - "target_work_item_id": int(target.id), - "target_state": str(target.state or "").strip() or "executing", - "target_intent": str(target.intent or "").strip() or None, - "project_id": int(request.project_id), - "signal_id": _safe_int(target.signal_id), - "auto_tasks": [], - } - ) - - service = AIPMOKernelService(db, tenant_id=tenant_id) - raw_result = await service.process_ingress( - source_system="ai_pmo_ui", - source_message_id=request.source_message_id or f"decision-approve-{int(work_item_id)}-{int(datetime.now().timestamp() * 1000)}", - message_text=f"继续处理 work_item #{int(work_item_id)}", - sender=str(current_user.name or current_user.username or "PMFlow用户"), - channel_ref="ai_pmo_ui", - source_sender_open_id=None, - captured_at=None, - chat_type="manual", - project_hint_id=request.project_id or _safe_int(target.project_id), - ) - result = _normalize_resume_result(raw=raw_result, target_work_item=target) - return success_response(result) - - -def _queue_resume_work_item( - *, - tenant_id: int, - work_item_id: int, - source_message_id: Optional[str], - sender: str, - project_id: int, -) -> None: - message_id = ( - str(source_message_id or "").strip() - or f"decision-approve-bg-{int(work_item_id)}-{int(datetime.now().timestamp() * 1000)}" - )[:128] - task = asyncio.create_task( - _run_resume_work_item_in_background( - tenant_id=int(tenant_id), - work_item_id=int(work_item_id), - source_message_id=message_id, - sender=str(sender or "PMFlow用户"), - project_id=int(project_id), - ), - name=f"ai-pmo-resume-{int(work_item_id)}", - ) - - def _on_done(done_task: asyncio.Task[None]) -> None: - if done_task.cancelled(): - return - exc = done_task.exception() - if exc is not None: - logger.warning( - "ai_pmo_background_resume_failed", - tenant_id=int(tenant_id), - work_item_id=int(work_item_id), - error=str(exc)[:400], - ) - - task.add_done_callback(_on_done) - - -async def _run_resume_work_item_in_background( - *, - tenant_id: int, - work_item_id: int, - source_message_id: str, - sender: str, - project_id: int, -) -> None: - async with AsyncSessionLocal() as bg_db: - service = AIPMOKernelService(bg_db, tenant_id=int(tenant_id)) - await service.process_ingress( - source_system="ai_pmo_ui", - source_message_id=source_message_id, - message_text=f"继续处理 work_item #{int(work_item_id)}", - sender=str(sender or "PMFlow用户"), - channel_ref="ai_pmo_ui", - source_sender_open_id=None, - captured_at=None, - chat_type="manual", - project_hint_id=int(project_id), - ) - - -def _normalize_noise_text(text: str) -> str: - return re.sub(r"[\\s\\??!!,,。~~、]+", "", str(text or "")).strip().lower() - - -def _should_hide_work_item_from_inbox(*, row: WorkItem, ingest: IngestEvent) -> bool: - intent_name = str(row.intent or "").strip().lower() - if intent_name not in FRONT_NOISE_INTENTS: - return False - payload = ingest.payload if isinstance(ingest.payload, dict) else {} - message_text = str(payload.get("message_text") or "").strip() - normalized = _normalize_noise_text(message_text) - if not normalized: - return True - if any(keyword in normalized for keyword in FRONT_NOISE_STRUCTURED_HINTS): - return False - if normalized in FRONT_NOISE_EXACT: - return True - if len(normalized) <= 8 and any(keyword in normalized for keyword in FRONT_NOISE_HINTS): - return True - return False - - -@router.post("/cadence/run") -async def run_cadence( - request: PMOCadenceRunRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - _ = current_user - service = AIPMOCadenceService(db, tenant_id=tenant_id) - try: - snapshot = await service.generate_snapshot( - cadence_type=request.cadence_type, - project_id=request.project_id, - ) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - await db.commit() - return success_response(snapshot) - - -@router.get("/cadence/latest") -async def get_latest_cadence_snapshot( - cadence_type: str, - project_id: Optional[int] = None, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - _ = current_user - service = AIPMOCadenceService(db, tenant_id=tenant_id) - snapshot = await service.get_latest_snapshot( - cadence_type=cadence_type, - project_id=project_id, - ) - return success_response(snapshot) - - -@router.get("/portfolio/program-view") -async def get_program_view( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - _ = current_user - service = AIPMOCadenceService(db, tenant_id=tenant_id) - return success_response(await service.build_program_view()) - - -@router.get("/portfolio/latest") -async def get_latest_portfolio_snapshot( - scope_type: str = "tenant", - project_id: Optional[int] = None, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - _ = current_user - scope_ref = f"project:{int(project_id)}" if project_id is not None else f"tenant:{tenant_id}" - row = ( - await db.execute( - select(PortfolioSnapshot).where( - and_( - PortfolioSnapshot.tenant_id == tenant_id, - PortfolioSnapshot.scope_type == scope_type, - PortfolioSnapshot.scope_ref == scope_ref, - ) - ).order_by(PortfolioSnapshot.id.desc()).limit(1) - ) - ).scalar_one_or_none() - if row is None: - return success_response(None) - return success_response( - { - "id": int(row.id), - "scope_type": row.scope_type, - "scope_ref": row.scope_ref, - "summary": row.summary, - "payload": row.payload, - "created_at": row.created_at.isoformat() if row.created_at else None, - } - ) - - -def _safe_int(value: Any) -> Optional[int]: - try: - if value is None or value == "": - return None - return int(value) - except (TypeError, ValueError): - return None - - -def _compact_text(value: Any, limit: int) -> str: - text = " ".join(str(value or "").split()).strip() - if len(text) <= limit: - return text - return f"{text[: max(limit - 1, 1)].rstrip()}…" - - -def _normalize_module_list(value: Any) -> list[str]: - if not isinstance(value, list): - return [] - modules: list[str] = [] - for row in value: - item = str(row or "").strip() - if not item or item in modules: - continue - modules.append(item[:50]) - return modules[:8] - - -def _is_low_value_closure_payload(payload: dict[str, Any]) -> bool: - summary = str(payload.get("closure_summary") or "").strip() - dialogue = str(payload.get("closure_dialogue") or "").strip() - modules = _normalize_module_list(payload.get("closure_modules")) - turns = payload.get("closure_turns") if isinstance(payload.get("closure_turns"), list) else [] - has_closure = bool(summary or dialogue or modules) - if not has_closure: - return True - compact_summary = re.sub(r"\s+", "", summary).lower() if summary else "" - summary_is_generic = bool(compact_summary and GENERIC_SOURCE_TEXT_RE.match(compact_summary)) - weak_summary = bool(summary and any(token in summary for token in LOW_VALUE_CLOSURE_HINTS)) - has_assistant_turn = any( - str(item.get("role") or "").strip().lower() == "assistant" and str(item.get("text") or "").strip() - for item in turns - if isinstance(item, dict) - ) - if weak_summary and not has_assistant_turn: - return True - if summary_is_generic and not modules and not has_assistant_turn: - return True - if dialogue and "机器人:" not in dialogue and len(turns) <= 1 and weak_summary: - return True - return False - - -def _priority_from_review(review_summary: Any) -> tuple[str, int]: - payload = review_summary if isinstance(review_summary, dict) else {} - risk_score = float(payload.get("risk_score_after") or 0.0) - if risk_score >= 0.75: - return "high", 3 - if risk_score >= 0.45: - return "medium", 2 - return "low", 1 - - -def _blocking_reason(row: WorkItem) -> Optional[str]: - execution_result = row.execution_result if isinstance(row.execution_result, dict) else {} - review_summary = row.review_summary if isinstance(row.review_summary, dict) else {} - reason = str(execution_result.get("reason") or execution_result.get("error_message") or "").strip() - if reason: - return reason - divergences = review_summary.get("divergences") if isinstance(review_summary.get("divergences"), list) else [] - if divergences and isinstance(divergences[0], dict): - return str(divergences[0].get("detail") or "").strip() or None - return None - - -def _next_step_text(row: WorkItem) -> Optional[str]: - review_summary = row.review_summary if isinstance(row.review_summary, dict) else {} - next_actions = review_summary.get("next_actions") if isinstance(review_summary.get("next_actions"), list) else [] - if next_actions: - return str(next_actions[0]).strip() - action_bundle = row.action_bundle if isinstance(row.action_bundle, dict) else {} - summary = str(action_bundle.get("summary") or "").strip() - return summary or None - - -def _stage_text(state: str) -> str: - mapping = { - "received": "已接收", - "modeled": "已建模", - "decided": "已决策", - "planned": "已规划", - "executing": "执行中", - "waiting_manual": "待人工确认", - "succeeded": "执行完成", - "failed": "执行失败", - "ignored": "已忽略", - } - return mapping.get(str(state or "").strip(), str(state or "").strip() or "-") - - -def _normalize_approval_requirement(value: Any) -> Optional[dict[str, Any]]: - if not isinstance(value, dict): - return None - reason = str(value.get("reason") or value.get("policy_gate_code") or "").strip() or None - message = str(value.get("message") or value.get("hint") or value.get("detail") or "").strip() or None - return { - **value, - "required": bool(value.get("required")), - "reason": reason, - "message": message, - } - - -def _extract_gate_projection_from_work_item(row: WorkItem) -> dict[str, Any]: - execution_result = row.execution_result if isinstance(row.execution_result, dict) else {} - review_summary = row.review_summary if isinstance(row.review_summary, dict) else {} - policy_gate_code = ( - str( - execution_result.get("policy_gate_code") - or execution_result.get("gate_code") - or review_summary.get("policy_gate_code") - or review_summary.get("gate_code") - or "" - ).strip() - or None - ) - approval_requirement = _normalize_approval_requirement( - execution_result.get("approval_requirement") - if isinstance(execution_result.get("approval_requirement"), dict) - else review_summary.get("approval_requirement") - ) - return { - "latest_policy_gate_code": policy_gate_code, - "approval_requirement": approval_requirement, - } - - -async def _build_latest_decision_projection( - *, - db: AsyncSession, - tenant_id: int, - work_item_ids: list[int], -) -> dict[int, dict[str, Any]]: - if not work_item_ids: - return {} - rows = ( - await db.execute( - select(DecisionRecord) - .where( - and_( - DecisionRecord.tenant_id == tenant_id, - DecisionRecord.work_item_id.in_(work_item_ids), - ) - ) - .order_by(DecisionRecord.work_item_id.asc(), DecisionRecord.id.desc()) - ) - ).scalars().all() - - projection: dict[int, dict[str, Any]] = {} - for row in rows: - work_item_id = _safe_int(row.work_item_id) - if work_item_id is None or work_item_id in projection: - continue - projection[work_item_id] = { - "latest_policy_gate_code": str(row.policy_gate_code or "").strip() or None, - "approval_requirement": _normalize_approval_requirement(row.approval_requirement), - } - return projection - - -async def _build_closure_payload_overrides( - *, - db: AsyncSession, - tenant_id: int, - rows: list[tuple[WorkItem, IngestEvent, Optional[Project]]], -) -> dict[int, dict[str, Any]]: - if not rows: - return {} - service = AIPMOKernelService(db, tenant_id=tenant_id) - overrides: dict[int, dict[str, Any]] = {} - for row, ingest, _project in rows: - payload = ingest.payload if isinstance(ingest.payload, dict) else {} - if not isinstance(payload, dict): - continue - has_closure = bool( - str(payload.get("closure_summary") or "").strip() - or str(payload.get("closure_dialogue") or "").strip() - or (isinstance(payload.get("closure_modules"), list) and payload.get("closure_modules")) - ) - if has_closure and not _is_low_value_closure_payload(payload): - continue - message_text = str(payload.get("message_text") or "").strip() - if not message_text: - continue - source_system = str(row.source_system or "").strip() or "pmflow_manual" - captured_at_seed: Optional[str] = str(payload.get("captured_at") or "").strip() or None - if not captured_at_seed and isinstance(row.created_at, datetime): - captured_at_seed = row.created_at.isoformat() - closure_payload = await service._build_message_closure_payload( - source_system=source_system, - channel_ref=str(payload.get("channel_ref") or "").strip() or None, - sender=str(payload.get("sender") or "").strip(), - source_sender_open_id=str(payload.get("source_sender_open_id") or "").strip() or None, - message_text=message_text, - source_message_id=str(row.source_message_id or "").strip() or None, - owner_user_id=_safe_int(row.owner_user_id), - project_id=_safe_int(row.project_id), - captured_at=captured_at_seed, - ) - if not any( - ( - str(closure_payload.get("closure_summary") or "").strip(), - str(closure_payload.get("closure_dialogue") or "").strip(), - isinstance(closure_payload.get("closure_modules"), list) and closure_payload.get("closure_modules"), - ) - ): - continue - overrides[int(row.id)] = { - **payload, - "closure_summary": closure_payload.get("closure_summary"), - "closure_dialogue": closure_payload.get("closure_dialogue"), - "closure_modules": closure_payload.get("closure_modules"), - "closure_turns": closure_payload.get("closure_turns"), - } - return overrides - - -def _serialize_work_item_summary( - *, - row: WorkItem, - ingest: IngestEvent, - project: Optional[Project], - latest_decision_projection: Optional[dict[str, Any]] = None, - payload_override: Optional[dict[str, Any]] = None, -) -> dict[str, Any]: - payload = payload_override if isinstance(payload_override, dict) else ( - ingest.payload if isinstance(ingest.payload, dict) else {} - ) - message_text = str(payload.get("message_text") or "").strip() - closure_dialogue = str(payload.get("closure_dialogue") or "").strip() or None - closure_summary = str(payload.get("closure_summary") or "").strip() or None - closure_modules = _normalize_module_list(payload.get("closure_modules")) - closure_turns = payload.get("closure_turns") if isinstance(payload.get("closure_turns"), list) else [] - source_captured_at = str(payload.get("captured_at") or "").strip() or None - source_sender = str(payload.get("sender") or "").strip() or None - source_channel_ref = str(payload.get("channel_ref") or "").strip() or None - summary_seed = closure_summary or message_text or _next_step_text(row) or row.intent or "AI PMO 工作项" - summary = _compact_text(summary_seed, 180) - compact_message = re.sub(r"\s+", "", message_text).lower() if message_text else "" - prefer_closure_title = bool(compact_message and GENERIC_SOURCE_TEXT_RE.match(compact_message)) - title_seed = ( - (closure_summary or message_text) - if prefer_closure_title - else (message_text or closure_summary or f"AI PMO · {row.intent or 'work_item'}") - ) - title = _compact_text(title_seed, 64) - priority, priority_rank = _priority_from_review(row.review_summary) - project_name = None - if project is not None: - project_name = str(project.title or project.name or "").strip() or None - project_id = _safe_int(row.project_id) - decision_projection = latest_decision_projection if isinstance(latest_decision_projection, dict) else {} - latest_policy_gate_code = str(decision_projection.get("latest_policy_gate_code") or "").strip() or None - approval_requirement = _normalize_approval_requirement(decision_projection.get("approval_requirement")) - if latest_policy_gate_code is None or approval_requirement is None: - fallback_projection = _extract_gate_projection_from_work_item(row) - if latest_policy_gate_code is None: - latest_policy_gate_code = str(fallback_projection.get("latest_policy_gate_code") or "").strip() or None - if approval_requirement is None: - approval_requirement = _normalize_approval_requirement(fallback_projection.get("approval_requirement")) - return { - "id": int(row.id), - "source_system": row.source_system, - "source_message_id": row.source_message_id, - "source_message_text": _compact_text(message_text, 600) or None, - "source_conversation_closure": _compact_text(closure_dialogue, 2000) or None, - "source_captured_at": source_captured_at, - "source_sender": source_sender, - "source_channel_ref": source_channel_ref, - "closure_summary": closure_summary, - "implementation_modules": closure_modules, - "source_closure_turns": closure_turns[:4], - "state": row.state, - "intent": row.intent, - "decision_level": row.decision_level, - "project_id": project_id, - "project_name": project_name, - "signal_id": _safe_int(row.signal_id), - "reply_template_key": row.reply_template_key, - "title": title or f"工作项#{int(row.id)}", - "summary": summary, - "ai_plan": _compact_text(((row.action_bundle or {}) if isinstance(row.action_bundle, dict) else {}).get("summary") or "", 180) or None, - "blocking_reason": _blocking_reason(row), - "next_step_text": _next_step_text(row), - "stage_text": _stage_text(str(row.state or "")), - "priority": priority, - "priority_rank": priority_rank, - "latest_policy_gate_code": latest_policy_gate_code, - "approval_requirement": approval_requirement, - "target_url": f"/projects/{project_id}/board" if project_id is not None else None, - "review_summary": row.review_summary, - "execution_result": row.execution_result, - "created_at": row.created_at.isoformat() if row.created_at else None, - "updated_at": row.updated_at.isoformat() if row.updated_at else None, - } - - -def _normalize_resume_result(*, raw: dict[str, Any], target_work_item: WorkItem) -> dict[str, Any]: - if not bool(raw.get("handled")): - reason = str(raw.get("error") or raw.get("reason") or "继续执行失败").strip() or "继续执行失败" - return { - "handled": False, - "status": "failed_safe", - "followup_reply": reason, - "target_work_item_id": int(target_work_item.id), - "target_state": str(target_work_item.state or "").strip() or None, - "target_intent": str(target_work_item.intent or "").strip() or None, - "project_id": _safe_int(target_work_item.project_id), - "signal_id": _safe_int(target_work_item.signal_id), - "auto_tasks": [], - } - - state = None - rows = raw.get("results") if isinstance(raw.get("results"), list) else [] - if rows and isinstance(rows[0], dict): - state = str(rows[0].get("state") or "").strip() or None - if not state: - state = str(target_work_item.state or "").strip() or None - - if state == "succeeded": - status = "succeeded" - elif state == "waiting_manual": - status = "pending_manual" - elif state == "ignored": - status = "invalidated" - else: - status = "failed_safe" - - followup_reply = str(raw.get("direct_reply_text") or "").strip() - if not followup_reply: - if status == "succeeded": - followup_reply = "已继续执行并完成。" - elif status == "pending_manual": - followup_reply = "继续执行后仍需补充信息或人工确认。" - elif status == "invalidated": - followup_reply = "该工作项已失效,不再继续执行。" - else: - followup_reply = "继续执行失败,请查看详情。" - - linked_project_ids = raw.get("linked_project_ids") if isinstance(raw.get("linked_project_ids"), list) else [] - project_id = _safe_int(linked_project_ids[0]) if linked_project_ids else _safe_int(target_work_item.project_id) - return { - "handled": True, - "status": status, - "followup_reply": followup_reply, - "target_work_item_id": int(target_work_item.id), - "target_state": state, - "target_intent": str(target_work_item.intent or "").strip() or None, - "project_id": project_id, - "signal_id": _safe_int(raw.get("signal_id")) or _safe_int(target_work_item.signal_id), - "auto_tasks": raw.get("auto_tasks") if isinstance(raw.get("auto_tasks"), list) else [], - } - - -async def _reject_work_item( - *, - db: AsyncSession, - tenant_id: int, - target: WorkItem, - current_user: User, - reason: Optional[str], -) -> dict[str, Any]: - current_state = str(target.state or "").strip() - if current_state == "ignored": - return { - "handled": True, - "status": "invalidated", - "followup_reply": "该工作项已处于失效状态。", - "target_work_item_id": int(target.id), - "target_state": "ignored", - "target_intent": str(target.intent or "").strip() or None, - "project_id": _safe_int(target.project_id), - "signal_id": _safe_int(target.signal_id), - "auto_tasks": [], - } - - reject_reason = str(reason or "").strip() or "人工审批拒绝,工作项已失效。" - existing_execution = target.execution_result if isinstance(target.execution_result, dict) else {} - target.state = "ignored" - target.execution_result = { - **existing_execution, - "status": "invalidated", - "manual_decision": "reject", - "manual_reason": reject_reason, - "operator_user_id": _safe_int(current_user.id), - "processed_at": datetime.now().isoformat(), - } - target.review_summary = { - "verified": False, - "issues": ["rejected_by_user"], - "next_step": "该工作项已进入失效状态", - "operator": str(current_user.name or current_user.username or "").strip() or None, - "reason": reject_reason, - } - target.reply_template_key = "action_rejected" - - decision_row = ( - await db.execute( - select(DecisionRecord) - .where( - and_( - DecisionRecord.tenant_id == tenant_id, - DecisionRecord.work_item_id == int(target.id), - ) - ) - .order_by(DecisionRecord.id.desc()) - .limit(1) - ) - ).scalar_one_or_none() - if decision_row is not None: - decision_row.execution_result = target.execution_result - decision_row.final_outcome = "ignored" - - await db.commit() - return { - "handled": True, - "status": "invalidated", - "followup_reply": reject_reason, - "target_work_item_id": int(target.id), - "target_state": "ignored", - "target_intent": str(target.intent or "").strip() or None, - "project_id": _safe_int(target.project_id), - "signal_id": _safe_int(target.signal_id), - "auto_tasks": [], - } +__all__ = ["router"] diff --git a/backend/app/api/ai_pmo_api/__init__.py b/backend/app/api/ai_pmo_api/__init__.py new file mode 100644 index 00000000..67ff2ba3 --- /dev/null +++ b/backend/app/api/ai_pmo_api/__init__.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from fastapi import APIRouter + +from . import routes_cadence, routes_core, routes_resume + +router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"]) +for _module in [routes_core, routes_resume, routes_cadence]: + router.include_router(_module.router) + +__all__ = ["router"] diff --git a/backend/app/api/ai_pmo_api/helpers_inbox_filters.py b/backend/app/api/ai_pmo_api/helpers_inbox_filters.py new file mode 100644 index 00000000..651e3c75 --- /dev/null +++ b/backend/app/api/ai_pmo_api/helpers_inbox_filters.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +from .shared import * + +def _normalize_noise_text(text: str) -> str: + return re.sub(r"[\\s\\??!!,,。~~、]+", "", str(text or "")).strip().lower() + +def _should_hide_work_item_from_inbox(*, row: WorkItem, ingest: IngestEvent) -> bool: + intent_name = str(row.intent or "").strip().lower() + if intent_name not in FRONT_NOISE_INTENTS: + return False + payload = ingest.payload if isinstance(ingest.payload, dict) else {} + message_text = str(payload.get("message_text") or "").strip() + normalized = _normalize_noise_text(message_text) + if not normalized: + return True + if any(keyword in normalized for keyword in FRONT_NOISE_STRUCTURED_HINTS): + return False + if normalized in FRONT_NOISE_EXACT: + return True + if len(normalized) <= 8 and any(keyword in normalized for keyword in FRONT_NOISE_HINTS): + return True + return False + +def _safe_int(value: Any) -> Optional[int]: + try: + if value is None or value == "": + return None + return int(value) + except (TypeError, ValueError): + return None + +def _compact_text(value: Any, limit: int) -> str: + text = " ".join(str(value or "").split()).strip() + if len(text) <= limit: + return text + return f"{text[: max(limit - 1, 1)].rstrip()}…" + +def _normalize_module_list(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + modules: list[str] = [] + for row in value: + item = str(row or "").strip() + if not item or item in modules: + continue + modules.append(item[:50]) + return modules[:8] + +def _is_low_value_closure_payload(payload: dict[str, Any]) -> bool: + summary = str(payload.get("closure_summary") or "").strip() + dialogue = str(payload.get("closure_dialogue") or "").strip() + modules = _normalize_module_list(payload.get("closure_modules")) + turns = payload.get("closure_turns") if isinstance(payload.get("closure_turns"), list) else [] + has_closure = bool(summary or dialogue or modules) + if not has_closure: + return True + compact_summary = re.sub(r"\s+", "", summary).lower() if summary else "" + summary_is_generic = bool(compact_summary and GENERIC_SOURCE_TEXT_RE.match(compact_summary)) + weak_summary = bool(summary and any(token in summary for token in LOW_VALUE_CLOSURE_HINTS)) + has_assistant_turn = any( + str(item.get("role") or "").strip().lower() == "assistant" and str(item.get("text") or "").strip() + for item in turns + if isinstance(item, dict) + ) + if weak_summary and not has_assistant_turn: + return True + if summary_is_generic and not modules and not has_assistant_turn: + return True + if dialogue and "机器人:" not in dialogue and len(turns) <= 1 and weak_summary: + return True + return False + +def _priority_from_review(review_summary: Any) -> tuple[str, int]: + payload = review_summary if isinstance(review_summary, dict) else {} + risk_score = float(payload.get("risk_score_after") or 0.0) + if risk_score >= 0.75: + return "high", 3 + if risk_score >= 0.45: + return "medium", 2 + return "low", 1 + +def _blocking_reason(row: WorkItem) -> Optional[str]: + execution_result = row.execution_result if isinstance(row.execution_result, dict) else {} + review_summary = row.review_summary if isinstance(row.review_summary, dict) else {} + reason = str(execution_result.get("reason") or execution_result.get("error_message") or "").strip() + if reason: + return reason + divergences = review_summary.get("divergences") if isinstance(review_summary.get("divergences"), list) else [] + if divergences and isinstance(divergences[0], dict): + return str(divergences[0].get("detail") or "").strip() or None + return None + +def _next_step_text(row: WorkItem) -> Optional[str]: + review_summary = row.review_summary if isinstance(row.review_summary, dict) else {} + next_actions = review_summary.get("next_actions") if isinstance(review_summary.get("next_actions"), list) else [] + if next_actions: + return str(next_actions[0]).strip() + action_bundle = row.action_bundle if isinstance(row.action_bundle, dict) else {} + summary = str(action_bundle.get("summary") or "").strip() + return summary or None + +def _stage_text(state: str) -> str: + mapping = { + "received": "已接收", + "modeled": "已建模", + "decided": "已决策", + "planned": "已规划", + "executing": "执行中", + "waiting_manual": "待人工确认", + "succeeded": "执行完成", + "failed": "执行失败", + "ignored": "已忽略", + } + return mapping.get(str(state or "").strip(), str(state or "").strip() or "-") + +def _normalize_approval_requirement(value: Any) -> Optional[dict[str, Any]]: + if not isinstance(value, dict): + return None + reason = str(value.get("reason") or value.get("policy_gate_code") or "").strip() or None + message = str(value.get("message") or value.get("hint") or value.get("detail") or "").strip() or None + return { + **value, + "required": bool(value.get("required")), + "reason": reason, + "message": message, + } + +def _extract_gate_projection_from_work_item(row: WorkItem) -> dict[str, Any]: + execution_result = row.execution_result if isinstance(row.execution_result, dict) else {} + review_summary = row.review_summary if isinstance(row.review_summary, dict) else {} + policy_gate_code = ( + str( + execution_result.get("policy_gate_code") + or execution_result.get("gate_code") + or review_summary.get("policy_gate_code") + or review_summary.get("gate_code") + or "" + ).strip() + or None + ) + approval_requirement = _normalize_approval_requirement( + execution_result.get("approval_requirement") + if isinstance(execution_result.get("approval_requirement"), dict) + else review_summary.get("approval_requirement") + ) + return { + "latest_policy_gate_code": policy_gate_code, + "approval_requirement": approval_requirement, + } + +async def _build_latest_decision_projection( + *, + db: AsyncSession, + tenant_id: int, + work_item_ids: list[int], +) -> dict[int, dict[str, Any]]: + if not work_item_ids: + return {} + rows = ( + await db.execute( + select(DecisionRecord) + .where( + and_( + DecisionRecord.tenant_id == tenant_id, + DecisionRecord.work_item_id.in_(work_item_ids), + ) + ) + .order_by(DecisionRecord.work_item_id.asc(), DecisionRecord.id.desc()) + ) + ).scalars().all() + + projection: dict[int, dict[str, Any]] = {} + for row in rows: + work_item_id = _safe_int(row.work_item_id) + if work_item_id is None or work_item_id in projection: + continue + projection[work_item_id] = { + "latest_policy_gate_code": str(row.policy_gate_code or "").strip() or None, + "approval_requirement": _normalize_approval_requirement(row.approval_requirement), + } + return projection + diff --git a/backend/app/api/ai_pmo_api/helpers_inbox_payload.py b/backend/app/api/ai_pmo_api/helpers_inbox_payload.py new file mode 100644 index 00000000..8d2589e2 --- /dev/null +++ b/backend/app/api/ai_pmo_api/helpers_inbox_payload.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from .shared import * +from .helpers_inbox_filters import _compact_text, _normalize_module_list, _safe_int + +async def _build_closure_payload_overrides( + *, + db: AsyncSession, + tenant_id: int, + rows: list[tuple[WorkItem, IngestEvent, Optional[Project]]], +) -> dict[int, dict[str, Any]]: + if not rows: + return {} + service = AIPMOKernelService(db, tenant_id=tenant_id) + overrides: dict[int, dict[str, Any]] = {} + for row, ingest, _project in rows: + payload = ingest.payload if isinstance(ingest.payload, dict) else {} + if not isinstance(payload, dict): + continue + has_closure = bool( + str(payload.get("closure_summary") or "").strip() + or str(payload.get("closure_dialogue") or "").strip() + or (isinstance(payload.get("closure_modules"), list) and payload.get("closure_modules")) + ) + if has_closure and not _is_low_value_closure_payload(payload): + continue + message_text = str(payload.get("message_text") or "").strip() + if not message_text: + continue + source_system = str(row.source_system or "").strip() or "pmflow_manual" + captured_at_seed: Optional[str] = str(payload.get("captured_at") or "").strip() or None + if not captured_at_seed and isinstance(row.created_at, datetime): + captured_at_seed = row.created_at.isoformat() + closure_payload = await service._build_message_closure_payload( + source_system=source_system, + channel_ref=str(payload.get("channel_ref") or "").strip() or None, + sender=str(payload.get("sender") or "").strip(), + source_sender_open_id=str(payload.get("source_sender_open_id") or "").strip() or None, + message_text=message_text, + source_message_id=str(row.source_message_id or "").strip() or None, + owner_user_id=_safe_int(row.owner_user_id), + project_id=_safe_int(row.project_id), + captured_at=captured_at_seed, + ) + if not any( + ( + str(closure_payload.get("closure_summary") or "").strip(), + str(closure_payload.get("closure_dialogue") or "").strip(), + isinstance(closure_payload.get("closure_modules"), list) and closure_payload.get("closure_modules"), + ) + ): + continue + overrides[int(row.id)] = { + **payload, + "closure_summary": closure_payload.get("closure_summary"), + "closure_dialogue": closure_payload.get("closure_dialogue"), + "closure_modules": closure_payload.get("closure_modules"), + "closure_turns": closure_payload.get("closure_turns"), + } + return overrides + +def _serialize_work_item_summary( + *, + row: WorkItem, + ingest: IngestEvent, + project: Optional[Project], + latest_decision_projection: Optional[dict[str, Any]] = None, + payload_override: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + payload = payload_override if isinstance(payload_override, dict) else ( + ingest.payload if isinstance(ingest.payload, dict) else {} + ) + message_text = str(payload.get("message_text") or "").strip() + closure_dialogue = str(payload.get("closure_dialogue") or "").strip() or None + closure_summary = str(payload.get("closure_summary") or "").strip() or None + closure_modules = _normalize_module_list(payload.get("closure_modules")) + closure_turns = payload.get("closure_turns") if isinstance(payload.get("closure_turns"), list) else [] + source_captured_at = str(payload.get("captured_at") or "").strip() or None + source_sender = str(payload.get("sender") or "").strip() or None + source_channel_ref = str(payload.get("channel_ref") or "").strip() or None + summary_seed = closure_summary or message_text or _next_step_text(row) or row.intent or "AI PMO 工作项" + summary = _compact_text(summary_seed, 180) + compact_message = re.sub(r"\s+", "", message_text).lower() if message_text else "" + prefer_closure_title = bool(compact_message and GENERIC_SOURCE_TEXT_RE.match(compact_message)) + title_seed = ( + (closure_summary or message_text) + if prefer_closure_title + else (message_text or closure_summary or f"AI PMO · {row.intent or 'work_item'}") + ) + title = _compact_text(title_seed, 64) + priority, priority_rank = _priority_from_review(row.review_summary) + project_name = None + if project is not None: + project_name = str(project.title or project.name or "").strip() or None + project_id = _safe_int(row.project_id) + decision_projection = latest_decision_projection if isinstance(latest_decision_projection, dict) else {} + latest_policy_gate_code = str(decision_projection.get("latest_policy_gate_code") or "").strip() or None + approval_requirement = _normalize_approval_requirement(decision_projection.get("approval_requirement")) + if latest_policy_gate_code is None or approval_requirement is None: + fallback_projection = _extract_gate_projection_from_work_item(row) + if latest_policy_gate_code is None: + latest_policy_gate_code = str(fallback_projection.get("latest_policy_gate_code") or "").strip() or None + if approval_requirement is None: + approval_requirement = _normalize_approval_requirement(fallback_projection.get("approval_requirement")) + return { + "id": int(row.id), + "source_system": row.source_system, + "source_message_id": row.source_message_id, + "source_message_text": _compact_text(message_text, 600) or None, + "source_conversation_closure": _compact_text(closure_dialogue, 2000) or None, + "source_captured_at": source_captured_at, + "source_sender": source_sender, + "source_channel_ref": source_channel_ref, + "closure_summary": closure_summary, + "implementation_modules": closure_modules, + "source_closure_turns": closure_turns[:4], + "state": row.state, + "intent": row.intent, + "decision_level": row.decision_level, + "project_id": project_id, + "project_name": project_name, + "signal_id": _safe_int(row.signal_id), + "reply_template_key": row.reply_template_key, + "title": title or f"工作项#{int(row.id)}", + "summary": summary, + "ai_plan": _compact_text(((row.action_bundle or {}) if isinstance(row.action_bundle, dict) else {}).get("summary") or "", 180) or None, + "blocking_reason": _blocking_reason(row), + "next_step_text": _next_step_text(row), + "stage_text": _stage_text(str(row.state or "")), + "priority": priority, + "priority_rank": priority_rank, + "latest_policy_gate_code": latest_policy_gate_code, + "approval_requirement": approval_requirement, + "target_url": f"/projects/{project_id}/board" if project_id is not None else None, + "review_summary": row.review_summary, + "execution_result": row.execution_result, + "created_at": row.created_at.isoformat() if row.created_at else None, + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + } + diff --git a/backend/app/api/ai_pmo_api/helpers_resume.py b/backend/app/api/ai_pmo_api/helpers_resume.py new file mode 100644 index 00000000..5daec9a0 --- /dev/null +++ b/backend/app/api/ai_pmo_api/helpers_resume.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +from .shared import * + +def _queue_resume_work_item( + *, + tenant_id: int, + work_item_id: int, + source_message_id: Optional[str], + sender: str, + project_id: int, +) -> None: + message_id = ( + str(source_message_id or "").strip() + or f"decision-approve-bg-{int(work_item_id)}-{int(datetime.now().timestamp() * 1000)}" + )[:128] + task = asyncio.create_task( + _run_resume_work_item_in_background( + tenant_id=int(tenant_id), + work_item_id=int(work_item_id), + source_message_id=message_id, + sender=str(sender or "PMFlow用户"), + project_id=int(project_id), + ), + name=f"ai-pmo-resume-{int(work_item_id)}", + ) + + def _on_done(done_task: asyncio.Task[None]) -> None: + if done_task.cancelled(): + return + exc = done_task.exception() + if exc is not None: + logger.warning( + "ai_pmo_background_resume_failed", + tenant_id=int(tenant_id), + work_item_id=int(work_item_id), + error=str(exc)[:400], + ) + + task.add_done_callback(_on_done) + +async def _run_resume_work_item_in_background( + *, + tenant_id: int, + work_item_id: int, + source_message_id: str, + sender: str, + project_id: int, +) -> None: + async with AsyncSessionLocal() as bg_db: + service = AIPMOKernelService(bg_db, tenant_id=int(tenant_id)) + await service.process_ingress( + source_system="ai_pmo_ui", + source_message_id=source_message_id, + message_text=f"继续处理 work_item #{int(work_item_id)}", + sender=str(sender or "PMFlow用户"), + channel_ref="ai_pmo_ui", + source_sender_open_id=None, + captured_at=None, + chat_type="manual", + project_hint_id=int(project_id), + ) + +def _normalize_resume_result(*, raw: dict[str, Any], target_work_item: WorkItem) -> dict[str, Any]: + if not bool(raw.get("handled")): + reason = str(raw.get("error") or raw.get("reason") or "继续执行失败").strip() or "继续执行失败" + return { + "handled": False, + "status": "failed_safe", + "followup_reply": reason, + "target_work_item_id": int(target_work_item.id), + "target_state": str(target_work_item.state or "").strip() or None, + "target_intent": str(target_work_item.intent or "").strip() or None, + "project_id": _safe_int(target_work_item.project_id), + "signal_id": _safe_int(target_work_item.signal_id), + "auto_tasks": [], + } + + state = None + rows = raw.get("results") if isinstance(raw.get("results"), list) else [] + if rows and isinstance(rows[0], dict): + state = str(rows[0].get("state") or "").strip() or None + if not state: + state = str(target_work_item.state or "").strip() or None + + if state == "succeeded": + status = "succeeded" + elif state == "waiting_manual": + status = "pending_manual" + elif state == "ignored": + status = "invalidated" + else: + status = "failed_safe" + + followup_reply = str(raw.get("direct_reply_text") or "").strip() + if not followup_reply: + if status == "succeeded": + followup_reply = "已继续执行并完成。" + elif status == "pending_manual": + followup_reply = "继续执行后仍需补充信息或人工确认。" + elif status == "invalidated": + followup_reply = "该工作项已失效,不再继续执行。" + else: + followup_reply = "继续执行失败,请查看详情。" + + linked_project_ids = raw.get("linked_project_ids") if isinstance(raw.get("linked_project_ids"), list) else [] + project_id = _safe_int(linked_project_ids[0]) if linked_project_ids else _safe_int(target_work_item.project_id) + return { + "handled": True, + "status": status, + "followup_reply": followup_reply, + "target_work_item_id": int(target_work_item.id), + "target_state": state, + "target_intent": str(target_work_item.intent or "").strip() or None, + "project_id": project_id, + "signal_id": _safe_int(raw.get("signal_id")) or _safe_int(target_work_item.signal_id), + "auto_tasks": raw.get("auto_tasks") if isinstance(raw.get("auto_tasks"), list) else [], + } + +async def _reject_work_item( + *, + db: AsyncSession, + tenant_id: int, + target: WorkItem, + current_user: User, + reason: Optional[str], +) -> dict[str, Any]: + current_state = str(target.state or "").strip() + if current_state == "ignored": + return { + "handled": True, + "status": "invalidated", + "followup_reply": "该工作项已处于失效状态。", + "target_work_item_id": int(target.id), + "target_state": "ignored", + "target_intent": str(target.intent or "").strip() or None, + "project_id": _safe_int(target.project_id), + "signal_id": _safe_int(target.signal_id), + "auto_tasks": [], + } + + reject_reason = str(reason or "").strip() or "人工审批拒绝,工作项已失效。" + existing_execution = target.execution_result if isinstance(target.execution_result, dict) else {} + target.state = "ignored" + target.execution_result = { + **existing_execution, + "status": "invalidated", + "manual_decision": "reject", + "manual_reason": reject_reason, + "operator_user_id": _safe_int(current_user.id), + "processed_at": datetime.now().isoformat(), + } + target.review_summary = { + "verified": False, + "issues": ["rejected_by_user"], + "next_step": "该工作项已进入失效状态", + "operator": str(current_user.name or current_user.username or "").strip() or None, + "reason": reject_reason, + } + target.reply_template_key = "action_rejected" + + decision_row = ( + await db.execute( + select(DecisionRecord) + .where( + and_( + DecisionRecord.tenant_id == tenant_id, + DecisionRecord.work_item_id == int(target.id), + ) + ) + .order_by(DecisionRecord.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if decision_row is not None: + decision_row.execution_result = target.execution_result + decision_row.final_outcome = "ignored" + + await db.commit() + return { + "handled": True, + "status": "invalidated", + "followup_reply": reject_reason, + "target_work_item_id": int(target.id), + "target_state": "ignored", + "target_intent": str(target.intent or "").strip() or None, + "project_id": _safe_int(target.project_id), + "signal_id": _safe_int(target.signal_id), + "auto_tasks": [], + } + diff --git a/backend/app/api/ai_pmo_api/routes_cadence.py b/backend/app/api/ai_pmo_api/routes_cadence.py new file mode 100644 index 00000000..2beaf6c8 --- /dev/null +++ b/backend/app/api/ai_pmo_api/routes_cadence.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from .shared import * +from .schemas import PMOCadenceRunRequest + +@router.post("/cadence/run") +async def run_cadence( + request: PMOCadenceRunRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _ = current_user + service = AIPMOCadenceService(db, tenant_id=tenant_id) + try: + snapshot = await service.generate_snapshot( + cadence_type=request.cadence_type, + project_id=request.project_id, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + await db.commit() + return success_response(snapshot) + +@router.get("/cadence/latest") +async def get_latest_cadence_snapshot( + cadence_type: str, + project_id: Optional[int] = None, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _ = current_user + service = AIPMOCadenceService(db, tenant_id=tenant_id) + snapshot = await service.get_latest_snapshot( + cadence_type=cadence_type, + project_id=project_id, + ) + return success_response(snapshot) + +@router.get("/portfolio/program-view") +async def get_program_view( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _ = current_user + service = AIPMOCadenceService(db, tenant_id=tenant_id) + return success_response(await service.build_program_view()) + +@router.get("/portfolio/latest") +async def get_latest_portfolio_snapshot( + scope_type: str = "tenant", + project_id: Optional[int] = None, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _ = current_user + scope_ref = f"project:{int(project_id)}" if project_id is not None else f"tenant:{tenant_id}" + row = ( + await db.execute( + select(PortfolioSnapshot).where( + and_( + PortfolioSnapshot.tenant_id == tenant_id, + PortfolioSnapshot.scope_type == scope_type, + PortfolioSnapshot.scope_ref == scope_ref, + ) + ).order_by(PortfolioSnapshot.id.desc()).limit(1) + ) + ).scalar_one_or_none() + if row is None: + return success_response(None) + return success_response( + { + "id": int(row.id), + "scope_type": row.scope_type, + "scope_ref": row.scope_ref, + "summary": row.summary, + "payload": row.payload, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + ) + diff --git a/backend/app/api/ai_pmo_api/routes_core.py b/backend/app/api/ai_pmo_api/routes_core.py new file mode 100644 index 00000000..ba364d3d --- /dev/null +++ b/backend/app/api/ai_pmo_api/routes_core.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from .shared import * +from .helpers_inbox_filters import _should_hide_work_item_from_inbox, _safe_int +from .helpers_inbox_payload import _build_closure_payload_overrides, _serialize_work_item_summary +from .schemas import PMOProcessTextRequest + +@router.post("/process-text") +async def process_text( + request: PMOProcessTextRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = AIPMOKernelService(db, tenant_id=tenant_id) + result = await service.process_ingress( + source_system="pmflow_manual", + source_message_id=request.source_message_id, + message_text=request.message_text, + sender=str(request.sender or current_user.name or current_user.username or "PMFlow用户"), + channel_ref="pmflow_manual", + source_sender_open_id=None, + captured_at=None, + chat_type="manual", + project_hint_id=request.project_id, + ) + if not result.get("handled"): + raise HTTPException(status_code=400, detail=str(result.get("reason") or "AI PMO kernel 未处理该输入")) + return success_response(result) + +@router.get("/work-items") +async def list_work_items( + limit: int = 20, + state: Optional[str] = None, + project_id: Optional[int] = None, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _ = current_user + stmt = ( + select(WorkItem, IngestEvent, Project) + .join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id) + .outerjoin(Project, and_(Project.tenant_id == tenant_id, Project.id == WorkItem.project_id)) + .where(WorkItem.tenant_id == tenant_id) + .order_by(WorkItem.updated_at.desc(), WorkItem.id.desc()) + ) + if state: + stmt = stmt.where(WorkItem.state == state) + if project_id is not None: + stmt = stmt.where(WorkItem.project_id == int(project_id)) + rows = (await db.execute(stmt.limit(max(1, min(limit, 100))))).all() + work_item_ids = [int(row.id) for row, _, _ in rows] + latest_decision_projection = await _build_latest_decision_projection( + db=db, + tenant_id=tenant_id, + work_item_ids=work_item_ids, + ) + closure_payload_overrides = await _build_closure_payload_overrides( + db=db, + tenant_id=tenant_id, + rows=rows, + ) + items = [ + _serialize_work_item_summary( + row=row, + ingest=ingest, + project=project, + latest_decision_projection=latest_decision_projection.get(int(row.id)), + payload_override=closure_payload_overrides.get(int(row.id)), + ) + for row, ingest, project in rows + if not _should_hide_work_item_from_inbox(row=row, ingest=ingest) + ] + return success_response({"items": items}) + +@router.get("/work-items/{work_item_id}") +async def get_work_item( + work_item_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _ = current_user + row = ( + await db.execute( + select(WorkItem).where( + and_( + WorkItem.tenant_id == tenant_id, + WorkItem.id == int(work_item_id), + ) + ) + ) + ).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="work_item_not_found") + decision_rows = ( + await db.execute( + select(DecisionRecord).where( + and_( + DecisionRecord.tenant_id == tenant_id, + DecisionRecord.work_item_id == int(work_item_id), + ) + ).order_by(DecisionRecord.id.asc()) + ) + ).scalars().all() + plan_rows = ( + await db.execute( + select(PlanBundle).where( + and_( + PlanBundle.tenant_id == tenant_id, + PlanBundle.work_item_id == int(work_item_id), + ) + ).order_by(PlanBundle.id.asc()) + ) + ).scalars().all() + reply_rows = ( + await db.execute( + select(ReplyReceipt).where( + and_( + ReplyReceipt.tenant_id == tenant_id, + ReplyReceipt.work_item_id == int(work_item_id), + ) + ).order_by(ReplyReceipt.id.asc()) + ) + ).scalars().all() + payload: dict[str, Any] = { + "id": int(row.id), + "source_system": row.source_system, + "source_message_id": row.source_message_id, + "state": row.state, + "intent": row.intent, + "decision_level": row.decision_level, + "project_id": _safe_int(row.project_id), + "signal_id": _safe_int(row.signal_id), + "context_snapshot": row.context_snapshot, + "action_bundle": row.action_bundle, + "execution_result": row.execution_result, + "review_summary": row.review_summary, + "reply_template_key": row.reply_template_key, + "decision_records": [ + { + "id": int(item.id), + "decision_level": item.decision_level, + "decision_object_type": item.decision_object_type, + "decision_object_id": _safe_int(item.decision_object_id), + "rationale": item.rationale, + "risk_score": item.risk_score, + "budget_impact": item.budget_impact, + "resource_impact": item.resource_impact, + "simulation_result": item.simulation_result, + "policy_gate_hit": bool(item.policy_gate_hit), + "policy_gate_code": item.policy_gate_code, + "approval_requirement": item.approval_requirement, + "execution_result": item.execution_result, + "final_outcome": item.final_outcome, + } + for item in decision_rows + ], + "plan_bundles": [ + { + "id": int(item.id), + "plan_type": item.plan_type, + "summary": item.summary, + "payload": item.payload, + } + for item in plan_rows + ], + "reply_receipts": [ + { + "id": int(item.id), + "receipt_phase": item.receipt_phase, + "template_key": item.template_key, + "payload": item.payload, + "status": item.status, + } + for item in reply_rows + ], + } + return success_response(payload) + diff --git a/backend/app/api/ai_pmo_api/routes_resume.py b/backend/app/api/ai_pmo_api/routes_resume.py new file mode 100644 index 00000000..1e45db6e --- /dev/null +++ b/backend/app/api/ai_pmo_api/routes_resume.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from .shared import * +from .helpers_resume import _queue_resume_work_item, _reject_work_item +from .schemas import PMOResumeWorkItemRequest, PMOWorkItemDecisionRequest + +@router.post("/work-items/{work_item_id}/resume") +async def resume_work_item( + work_item_id: int, + request: PMOResumeWorkItemRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + target = ( + await db.execute( + select(WorkItem).where( + and_( + WorkItem.tenant_id == tenant_id, + WorkItem.id == int(work_item_id), + ) + ).limit(1) + ) + ).scalar_one_or_none() + if target is None: + raise HTTPException(status_code=404, detail="work_item_not_found") + action_text_map = { + "continue": "继续处理", + "commit": "确认并提交候选", + "refresh": "换一批候选", + "cancel": "取消本次候选", + } + action_message = action_text_map.get(str(request.action or "").strip().lower(), "继续处理") + service = AIPMOKernelService(db, tenant_id=tenant_id) + raw_result = await service.process_ingress( + source_system="ai_pmo_ui", + source_message_id=request.source_message_id or f"resume-{int(work_item_id)}-{int(datetime.now().timestamp() * 1000)}", + message_text=f"{action_message} work_item #{int(work_item_id)}", + sender=str(current_user.name or current_user.username or "PMFlow用户"), + channel_ref="ai_pmo_ui", + source_sender_open_id=None, + captured_at=None, + chat_type="manual", + project_hint_id=request.project_id or _safe_int(target.project_id), + ) + result = _normalize_resume_result(raw=raw_result, target_work_item=target) + return success_response(result) + +@router.post("/work-items/{work_item_id}/decision") +async def decide_work_item( + work_item_id: int, + request: PMOWorkItemDecisionRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + target = ( + await db.execute( + select(WorkItem).where( + and_( + WorkItem.tenant_id == tenant_id, + WorkItem.id == int(work_item_id), + ) + ).limit(1) + ) + ).scalar_one_or_none() + if target is None: + raise HTTPException(status_code=404, detail="work_item_not_found") + + if request.action == "reject": + result = await _reject_work_item( + db=db, + tenant_id=tenant_id, + target=target, + current_user=current_user, + reason=request.reason, + ) + return success_response(result) + + if str(target.state or "").strip() == "waiting_manual" and request.project_id is not None: + target.project_id = int(request.project_id) + target.state = "executing" + target.reply_template_key = "action_processing" + current_execution = target.execution_result if isinstance(target.execution_result, dict) else {} + target.execution_result = { + **current_execution, + "status": "executing", + "manual_decision": "approve", + "resume_trigger": "queued_background", + "project_id": int(request.project_id), + "queued_at": datetime.now().isoformat(), + } + decision_row = ( + await db.execute( + select(DecisionRecord) + .where( + and_( + DecisionRecord.tenant_id == tenant_id, + DecisionRecord.work_item_id == int(target.id), + ) + ) + .order_by(DecisionRecord.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if decision_row is not None and str(decision_row.policy_gate_code or "").strip() == "missing_project_scope": + decision_row.policy_gate_hit = False + decision_row.policy_gate_code = None + decision_row.approval_requirement = { + "required": False, + "reason": "project_scope_confirmed", + "message": "项目归属已确认,正在后台继续执行。", + } + decision_row.final_outcome = "executing" + await db.commit() + _queue_resume_work_item( + tenant_id=tenant_id, + work_item_id=int(work_item_id), + source_message_id=request.source_message_id, + sender=str(current_user.name or current_user.username or "PMFlow用户"), + project_id=int(request.project_id), + ) + return success_response( + { + "handled": True, + "status": "pending_manual", + "followup_reply": "已确认关联项目,正在后台继续执行。", + "target_work_item_id": int(target.id), + "target_state": str(target.state or "").strip() or "executing", + "target_intent": str(target.intent or "").strip() or None, + "project_id": int(request.project_id), + "signal_id": _safe_int(target.signal_id), + "auto_tasks": [], + } + ) + + service = AIPMOKernelService(db, tenant_id=tenant_id) + raw_result = await service.process_ingress( + source_system="ai_pmo_ui", + source_message_id=request.source_message_id or f"decision-approve-{int(work_item_id)}-{int(datetime.now().timestamp() * 1000)}", + message_text=f"继续处理 work_item #{int(work_item_id)}", + sender=str(current_user.name or current_user.username or "PMFlow用户"), + channel_ref="ai_pmo_ui", + source_sender_open_id=None, + captured_at=None, + chat_type="manual", + project_hint_id=request.project_id or _safe_int(target.project_id), + ) + result = _normalize_resume_result(raw=raw_result, target_work_item=target) + return success_response(result) + diff --git a/backend/app/api/ai_pmo_api/schemas.py b/backend/app/api/ai_pmo_api/schemas.py new file mode 100644 index 00000000..f271a8d0 --- /dev/null +++ b/backend/app/api/ai_pmo_api/schemas.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from .shared import BaseModel, Field, Literal, Optional + +class PMOProcessTextRequest(BaseModel): + message_text: str = Field(..., min_length=1, max_length=4000) + sender: Optional[str] = Field(default=None, max_length=120) + source_message_id: Optional[str] = Field(default=None, max_length=128) + project_id: Optional[int] = Field(default=None) + +class PMOCadenceRunRequest(BaseModel): + cadence_type: str = Field(..., min_length=1, max_length=32) + project_id: Optional[int] = Field(default=None) + +class PMOResumeWorkItemRequest(BaseModel): + action: Literal["continue", "commit", "refresh", "cancel"] = Field(default="continue") + project_id: Optional[int] = Field(default=None) + source_message_id: Optional[str] = Field(default=None, max_length=128) + +class PMOWorkItemDecisionRequest(BaseModel): + action: Literal["approve", "reject"] = Field(..., description="审批动作:approve=继续执行,reject=标记失效") + project_id: Optional[int] = Field(default=None) + source_message_id: Optional[str] = Field(default=None, max_length=128) + reason: Optional[str] = Field(default=None, max_length=500) + diff --git a/backend/app/api/ai_pmo_api/shared.py b/backend/app/api/ai_pmo_api/shared.py new file mode 100644 index 00000000..732b3c5f --- /dev/null +++ b/backend/app/api/ai_pmo_api/shared.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import asyncio +from datetime import datetime +import re +from typing import Any, Literal, Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.logging import get_logger +from ...core.response import success_response +from ...database import AsyncSessionLocal, get_db +from ...dependencies.auth import get_current_user +from ...dependencies.tenant import get_tenant_id +from ...models import DecisionRecord, IngestEvent, PlanBundle, PortfolioSnapshot, Project, ReplyReceipt, User, WorkItem +from ...services.ai_pmo_cadence_service import AIPMOCadenceService +from ...services.ai_pmo_kernel_service import AIPMOKernelService + +router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"]) +logger = get_logger(__name__) +FRONT_NOISE_INTENTS = {"query.read_only", "query.answer"} +FRONT_NOISE_EXACT = { + "收到", + "收到啦", + "好的", + "ok", + "okk", + "yes", + "no", + "谢谢", + "thanks", + "thx", + "在吗", + "在不在", + "怎么样了", + "咋样了", + "辛苦了", +} +FRONT_NOISE_HINTS = ("怎么样了", "咋样了", "在吗", "辛苦了", "ok", "谢谢", "收到") +FRONT_NOISE_STRUCTURED_HINTS = ("项目", "信号", "任务", "风险", "进度", "状态", "work_item", "工作项", "#") +GENERIC_SOURCE_TEXT_RE = re.compile( + r"^(?:请)?(?:按|按照)?(?:这个|上述|这条)?(?:分步|步骤)?(?:骤)?(?:把|将)?(?:它|该需求|这条需求)?(?:加入|接入|录入).{0,24}信号源(?:中)?$", + re.IGNORECASE, +) +LOW_VALUE_CLOSURE_HINTS = ("缺少项目归属", "必须先确认项目", "无法创建", "不能创建") diff --git a/backend/app/api/ai_steward.py b/backend/app/api/ai_steward.py index f7e97649..99f444df 100644 --- a/backend/app/api/ai_steward.py +++ b/backend/app/api/ai_steward.py @@ -1,1461 +1,5 @@ -""" -AI Steward API - 主动式 AI 管家接口 +from __future__ import annotations -核心功能: -1. 零配置项目创建 -2. 主动信号捕获 -3. 认知冲突检测 -4. 阻塞自动解决 -5. 个性化进度简报 -""" -from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks -from sqlalchemy import and_, select -from sqlalchemy.ext.asyncio import AsyncSession -from pydantic import BaseModel, Field -from typing import List, Optional, Dict, Any, Literal -from datetime import datetime, timezone, timedelta -import json +from .ai_steward_api import router -from ..core.response import success_response -from ..database import get_db -from ..dependencies.auth import get_current_user -from ..dependencies.permissions import require_manager_user -from ..dependencies.tenant import get_tenant_id -from ..models import ProjectSignalLink, Signal, User -from ..services.ai_steward import get_ai_steward_service, AIStewardService -from ..services.signal_capture import ProactiveSignalCaptureService -from ..services.cognitive_conflict import get_cognitive_conflict_service -from ..services.blocker_detection import get_blocker_detection_service -from ..services.daily_briefing import get_daily_briefing_service -from ..services.event_workflow import get_event_workflow_engine, WorkflowEventType -from ..services.adaptive_rag_service import AdaptiveRAGService -from ..services.ai_orchestration_run_service import AIOrchestrationRunService -from ..services.feishu_outbox_service import FeishuOutboxService -from ..services.signal_source_orchestration_service import SignalSourceOrchestrationService -from ..services.signal_source_repair_service import ( - SignalSourceRepairMissingSignalError, - SignalSourceRepairService, - SignalSourceRepairTimeoutError, -) -from ..schemas.rag import RAGAskRequest -from ..utils.audited_write import audited_write - - -from ..utils.timezone import now_cn_aware, now_cn_naive -router = APIRouter(prefix="/ai-steward", tags=["AI Steward"]) - - -def _utc_now_naive() -> datetime: - return now_cn_naive() - - -async def _ensure_project_signal_link( - db: AsyncSession, - *, - tenant_id: int, - project_id: int, - signal_id: int, - link_type: str = "related", -) -> None: - existing = ( - await db.execute( - select(ProjectSignalLink.id).where( - and_( - ProjectSignalLink.tenant_id == int(tenant_id), - ProjectSignalLink.project_id == int(project_id), - ProjectSignalLink.signal_id == int(signal_id), - ) - ).limit(1) - ) - ).scalar_one_or_none() - if existing is not None: - return - db.add( - ProjectSignalLink( - tenant_id=int(tenant_id), - project_id=int(project_id), - signal_id=int(signal_id), - link_type=link_type, - relevance_score=1.0, - ) - ) - - -# ==================== Request/Response Models ==================== - -class ProjectFromTextRequest(BaseModel): - """零配置项目创建请求""" - input_text: str = Field( - ..., - description="自然语言项目描述,例如:我们要做一个 AI 客服工具,基于 GPT-4o mini,对标 XX 竞品,3 周上线", - min_length=10, - max_length=500 - ) - - -class ProjectFromTextResponse(BaseModel): - """零配置项目创建响应""" - project: Dict[str, Any] - signal: Dict[str, Any] - charter: Dict[str, Any] - tasks: List[Dict[str, Any]] - risks: List[Dict[str, Any]] - analysis: Dict[str, Any] - - -class CognitiveConflictResponse(BaseModel): - """认知冲突响应""" - id: str - type: str - description: str - severity: str - resolution_suggestion: Optional[str] = None - related_users: List[str] - - -class DailyBriefingResponse(BaseModel): - """每日简报响应""" - date: str - total_updates: int - your_updates: int - relevant_changes: List[Dict[str, Any]] - risk_alerts: List[Dict[str, Any]] - alignment_needed: List[Dict[str, Any]] - - -class FeishuCaptureRequest(BaseModel): - """飞书信号捕获请求""" - group_chat_id: str - message_content: str - sender: str - feishu_message_id: Optional[str] = None - - -class BrowserCaptureRequest(BaseModel): - """浏览器信号捕获请求""" - user_id: int - url: str - title: str - content_preview: str - - -class GitHubCaptureRequest(BaseModel): - """GitHub信号捕获请求""" - repo_url: str - event_type: str - event_data: Dict[str, Any] - - -class ConflictDetectRequest(BaseModel): - """认知冲突检测请求""" - project_id: int - detect_type: str = "all" # all, charter, definition - - -class SignalSourceOnboardRequest(BaseModel): - project_id: int = Field(..., ge=1) - instruction: str = Field(..., min_length=6, max_length=2000) - dry_run: bool = False - response_mode: Literal["fast", "complete"] = "fast" - - -class SignalSourceListResponse(BaseModel): - items: List[Dict[str, Any]] - - -class SignalSourceRepairRequest(BaseModel): - project_id: int = Field(..., ge=1) - registry_ids: List[int] = Field(default_factory=list, min_length=1, max_length=50) - sender: str = Field(default="PMFlow修复器", min_length=1, max_length=64) - - -def _parse_related_users(raw_value: Any) -> List[str]: - """兼容 involved_user_ids 的 list / json-string / 单值字符串。""" - if raw_value is None: - return [] - if isinstance(raw_value, list): - return [str(item) for item in raw_value if item is not None] - if isinstance(raw_value, (bytes, bytearray)): - raw_value = raw_value.decode("utf-8", errors="ignore") - if isinstance(raw_value, str): - text = raw_value.strip() - if not text: - return [] - try: - parsed = json.loads(text) - except (TypeError, ValueError, json.JSONDecodeError): - if "," in text: - return [v.strip() for v in text.split(",") if v.strip()] - return [text] - if isinstance(parsed, list): - return [str(item) for item in parsed if item is not None] - if parsed is None: - return [] - return [str(parsed)] - return [str(raw_value)] - - -# ==================== Zero-Config Project Creation ==================== - -@router.post("/rag/ask") -async def ask_ai_steward_rag( - request: RAGAskRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) - service = AdaptiveRAGService(db, tenant_id=effective_tenant_id) - result = await service.answer_question( - question=request.question, - asked_by=int(current_user.id), - asked_via="pmflow", - project_id=request.project_id, - source_chat_id=request.source_chat_id, - session_id=request.session_id, - force_new_session=bool(request.new_session), - thinking_mode=request.thinking_mode, - action_mode=request.action_mode, - sync_requirement=bool(request.sync_requirement), - ) - return success_response(result) - - -@router.post("/signal-sources/onboard") -@audited_write(caller="user") -async def onboard_signal_source( - request: SignalSourceOnboardRequest, - current_user: User = Depends(require_manager_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) - signal = Signal( - tenant_id=effective_tenant_id, - source="PMFlow", - content=f"[PMFlow用户#{int(current_user.id)}]: {request.instruction[:1800]}", - impact_score=0.7, - priority="medium", - processed=False, - strategic_analysis=json.dumps( - { - "source": "pmflow_manual_signal_source_onboard", - "instruction": request.instruction[:2000], - "created_at": now_cn_aware().isoformat(), - }, - ensure_ascii=False, - ), - tasks_generated=json.dumps([], ensure_ascii=False), - matched_project_id=int(request.project_id), - title=(request.instruction[:80] or "新增信号源"), - signal_type="新需求", - summary=request.instruction[:300], - reasoning=json.dumps(["来自 AI Steward 手动信号源接入请求。"], ensure_ascii=False), - confidence=0.82, - recommended_action="enter_only", - status="待处理", - ) - if not request.dry_run: - db.add(signal) - await db.flush() - await _ensure_project_signal_link( - db, - tenant_id=effective_tenant_id, - project_id=int(request.project_id), - signal_id=int(signal.id), - ) - await db.commit() - await db.refresh(signal) - signal_id_value = int(signal.id) if not request.dry_run and getattr(signal, "id", None) is not None else None - - service = SignalSourceOrchestrationService(db) - result = await service.orchestrate_from_signal( - tenant_id=effective_tenant_id, - project_id=int(request.project_id), - signal_id=signal_id_value, - initiating_text=request.instruction, - requested_via="pmflow", - operator_user_id=int(current_user.id), - dry_run=bool(request.dry_run), - response_mode=request.response_mode, - ) - return success_response( - { - "signal_id": signal_id_value, - **result, - } - ) - - -@router.get("/orchestration-runs/{trace_id}") -async def get_orchestration_run( - trace_id: str, - current_user: User = Depends(require_manager_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) - row = await AIOrchestrationRunService(db).get_run( - tenant_id=effective_tenant_id, - trace_id=str(trace_id or "").strip(), - ) - if row is None: - raise HTTPException(status_code=404, detail="orchestration run not found") - return success_response(AIOrchestrationRunService.serialize_run(row)) - - -@router.get("/projects/{project_id}/signal-sources") -async def list_project_signal_sources( - project_id: int, - current_user: User = Depends(require_manager_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) - items = await SignalSourceOrchestrationService(db).list_project_sources( - tenant_id=effective_tenant_id, - project_id=int(project_id), - ) - return success_response(SignalSourceListResponse(items=items).model_dump()) - - -@router.post("/signal-sources/repair-dirty") -@audited_write(caller="system") -async def repair_dirty_signal_sources( - request: SignalSourceRepairRequest, - current_user: User = Depends(require_manager_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) - try: - rows = await SignalSourceRepairService(db).repair_registries( - tenant_id=effective_tenant_id, - project_id=int(request.project_id), - registry_ids=[int(item) for item in request.registry_ids], - sender=str(request.sender or "PMFlow修复器").strip()[:64] or "PMFlow修复器", - ) - except SignalSourceRepairTimeoutError as exc: - raise HTTPException(status_code=504, detail=str(exc)) from exc - except SignalSourceRepairMissingSignalError as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc - return success_response( - { - "project_id": int(request.project_id), - "items": rows, - } - ) - -@router.post("/project-from-text", response_model=ProjectFromTextResponse) -async def create_project_from_text( - request: ProjectFromTextRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 零配置项目创建 - 从一句话生成完整项目 - - 示例输入: - ``` - 我们要做一个 AI 客服工具,基于 GPT-4o mini,对标 XX 竞品,3 周上线 - ``` - - AI 将自动生成: - - 完整的项目配置 - - 竞品分析信号 - - 项目宪章 - - 任务链(含依赖关系) - - 风险预警规则 - """ - try: - service = get_ai_steward_service(db) - - result = await service.create_project_from_natural_language( - input_text=request.input_text, - creator_id=int(current_user.id), - tenant_id=tenant_id - ) - - return ProjectFromTextResponse(**result) - - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"项目创建失败: {str(e)}" - ) - - -# ==================== Signal Capture Endpoints ==================== - -@router.post("/signal/capture-feishu") -@audited_write(caller="ai") -async def capture_feishu_signal( - request: FeishuCaptureRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 从飞书群聊捕获信号 - - AI 会自动判断消息是否包含有价值的项目或技术信息, - 如果是,则创建信号并自动关联到相关项目 - """ - try: - service = ProactiveSignalCaptureService(db) - result = await service.capture_from_feishu( - group_chat_id=request.group_chat_id, - message_content=request.message_content, - sender=request.sender, - tenant_id=tenant_id, - feishu_message_id=request.feishu_message_id, - ) - response_payload = dict(result) if isinstance(result, dict) else {"result": result} - - should_enqueue_reply = bool(response_payload.get("direct_reply_required")) - reply_text = str(response_payload.get("direct_reply_text") or "").strip() or "已收到,正在处理。" - source_message_id = (str(request.feishu_message_id or "").strip()[:128] or None) - normalized_chat_id = str(request.group_chat_id or "").strip() - if should_enqueue_reply and normalized_chat_id: - try: - outbox_id = await FeishuOutboxService.enqueue( - db, - tenant_id=int(tenant_id), - chat_id=normalized_chat_id, - text=reply_text, - source_message_id=source_message_id, - dedupe_seed=f"capture-feishu:{normalized_chat_id}:{str(request.sender or '').strip()[:64]}", - trace_id=str(response_payload.get("ai_trace_id") or "").strip()[:64] or None, - ) - await db.commit() - response_payload["reply_outbox_enqueued"] = bool(outbox_id) - response_payload["reply_outbox_id"] = int(outbox_id) if outbox_id else None - except Exception as outbox_exc: - await db.rollback() - response_payload["reply_outbox_enqueued"] = False - response_payload["reply_outbox_error"] = str(outbox_exc)[:200] - return response_payload - except Exception as e: - raise HTTPException(status_code=500, detail=f"飞书信号捕获失败: {str(e)}") - - -@router.post("/signal/capture-browser") -async def capture_browser_signal( - request: BrowserCaptureRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 从浏览器标签页捕获信号 - - 监控用户访问的技术文档、竞品页面、GitHub issues等 - """ - try: - service = ProactiveSignalCaptureService(db) - result = await service.capture_from_browser( - user_id=request.user_id, - url=request.url, - title=request.title, - content_preview=request.content_preview, - tenant_id=tenant_id - ) - return result - except Exception as e: - raise HTTPException(status_code=500, detail=f"浏览器信号捕获失败: {str(e)}") - - -@router.post("/signal/capture-github") -async def capture_github_signal( - request: GitHubCaptureRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 从GitHub事件捕获信号 - - 监控竞品仓库更新、上游依赖更新等 - """ - try: - service = ProactiveSignalCaptureService(db) - result = await service.capture_from_github( - repo_url=request.repo_url, - event_type=request.event_type, - event_data=request.event_data, - tenant_id=tenant_id - ) - return result - except Exception as e: - raise HTTPException(status_code=500, detail=f"GitHub信号捕获失败: {str(e)}") - - -@router.post("/signal/start-hn-monitor") -async def start_hn_monitor( - background_tasks: BackgroundTasks, - interval_minutes: int = 30, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 启动 HackerNews 定时监控(后台任务) - - 定期抓取 HackerNews 首页,分析技术趋势 - """ - async def run_monitor(): - service = ProactiveSignalCaptureService(db) - await service.start_hackernews_monitor(interval_minutes=interval_minutes, tenant_id=tenant_id) - - background_tasks.add_task(run_monitor) - return { - "status": "started", - "message": f"HackerNews 监控已启动,间隔: {interval_minutes} 分钟" - } - - -# ==================== Cognitive Conflict Endpoints ==================== - -@router.get("/cognitive-conflicts", response_model=List[CognitiveConflictResponse]) -async def get_cognitive_conflicts( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), - project_id: Optional[int] = None -): - """ - 获取认知冲突列表 - - AI 会自动识别团队中的隐性认知差异,例如: - - 产品说"用户留存提升10%",开发实现的是"DAU提升10%" - - 双方没有争论,但目标不一致,AI 会主动识别并推送对齐提醒 - """ - from ..models import CognitiveConflict, TruthNode - from sqlalchemy.future import select - - query = select(CognitiveConflict).where( - CognitiveConflict.tenant_id == tenant_id, - CognitiveConflict.status == 'active' - ) - - if project_id: - # 通过 truth_node 关联到项目 - query = query.join( - TruthNode, CognitiveConflict.truth_node_id == TruthNode.id - ).where( - TruthNode.project_id == project_id - ) - - result = await db.execute(query) - conflicts = result.scalars().all() - - return [ - CognitiveConflictResponse( - id=str(c.id), - type=c.conflict_type, - description=c.description, - severity="high" if "critical" in (c.description or "").lower() else "medium", - related_users=_parse_related_users(c.involved_user_ids), - ) - for c in conflicts - ] - - -@router.post("/conflicts/detect") -async def detect_conflicts( - request: ConflictDetectRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 检测项目中的认知冲突 - - 支持检测类型: - - all: 全部检测(宪章冲突 + 定义冲突) - - charter: 仅检测宪章中的潜在冲突 - - definition: 仅检测用户定义冲突 - """ - try: - service = get_cognitive_conflict_service(db) - - detected_conflicts = [] - - if request.detect_type in ["all", "charter"]: - charter_conflicts = await service.detect_charter_conflicts( - project_id=request.project_id, - tenant_id=tenant_id - ) - detected_conflicts.extend(charter_conflicts) - - if request.detect_type in ["all", "definition"]: - definition_conflicts = await service.detect_user_definition_conflicts( - project_id=request.project_id, - tenant_id=tenant_id - ) - detected_conflicts.extend(definition_conflicts) - - return { - "project_id": request.project_id, - "detected_count": len(detected_conflicts), - "conflicts": detected_conflicts - } - except Exception as e: - raise HTTPException(status_code=500, detail=f"冲突检测失败: {str(e)}") - - -@router.post("/conflicts/{conflict_id}/propose-resolution") -async def propose_conflict_resolution( - conflict_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - AI 生成认知冲突解决方案 - - 为冲突双方生成对齐方案: - 1. 分析冲突根因 - 2. 提出妥协方案 - 3. 生成可执行的对齐计划 - """ - try: - service = get_cognitive_conflict_service(db) - resolution = await service.propose_alignment_resolution( - conflict_id=conflict_id, - tenant_id=tenant_id - ) - return resolution - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - except Exception as e: - raise HTTPException(status_code=500, detail=f"方案生成失败: {str(e)}") - - -@router.post("/conflicts/{conflict_id}/align") -async def trigger_async_alignment( - conflict_id: int, - user1_id: int, - user2_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 触发异步 1v1 认知对齐 - - 为冲突双方创建独立的对齐任务: - 1. 发送对齐邀请 - 2. 提供背景信息 - 3. 收集双方观点 - 4. AI 生成共识方案 - """ - try: - service = get_cognitive_conflict_service(db) - alignment = await service.trigger_async_alignment( - conflict_id=conflict_id, - user1_id=user1_id, - user2_id=user2_id, - tenant_id=tenant_id - ) - return alignment - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - except Exception as e: - raise HTTPException(status_code=500, detail=f"对齐触发失败: {str(e)}") - - -@router.post("/cognitive-conflicts/{conflict_id}/resolve") -async def resolve_cognitive_conflict( - conflict_id: int, - resolution_note: str, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 解决认知冲突 - - AI 会生成解决方案建议,双方确认后标记为已解决 - """ - from ..models import CognitiveConflict - from sqlalchemy.future import select - - result = await db.execute( - select(CognitiveConflict).where( - CognitiveConflict.id == conflict_id, - CognitiveConflict.tenant_id == tenant_id - ) - ) - conflict = result.scalar_one_or_none() - - if not conflict: - raise HTTPException(status_code=404, detail="冲突记录不存在") - - conflict.status = 'resolved' - conflict.resolved_at = _utc_now_naive() - conflict.description += f"\n\n解决方案({current_user.username}): {resolution_note}" - - await db.commit() - - return {"message": "冲突已标记为已解决"} - - -@router.get("/conflicts/task-mismatch/{task_id}") -async def check_task_charter_mismatch( - task_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 检测任务与宪章的一致性 - - 检查: - 1. 任务描述是否与宪章目标一致 - 2. 技术选型是否符合约束 - 3. 是否偏离项目方向 - """ - from ..models import Task, TruthNode - from sqlalchemy.future import select - - try: - # 获取任务 - result = await db.execute( - select(Task).where( - Task.id == task_id, - Task.tenant_id == tenant_id - ) - ) - task = result.scalar_one_or_none() - - if not task: - raise HTTPException(status_code=404, detail="任务不存在") - - # 获取宪章 - charter_result = await db.execute( - select(TruthNode).where( - TruthNode.id == task.truth_node_id, - TruthNode.tenant_id == tenant_id - ) - ) - charter = charter_result.scalar_one_or_none() - - if not charter: - raise HTTPException(status_code=404, detail="宪章不存在") - - # 检测一致性 - service = get_cognitive_conflict_service(db) - mismatch = await service.detect_task_charter_mismatch( - task=task, - charter_content=charter.content, - tenant_id=tenant_id - ) - - if mismatch: - return { - "is_aligned": False, - "mismatch": mismatch - } - - return { - "is_aligned": True, - "message": "任务与宪章一致" - } - - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=f"一致性检测失败: {str(e)}") - - -# ==================== Other AI Steward Endpoints ==================== - -@router.get("/daily-briefing") -async def get_daily_briefing( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 获取个性化每日简报 - - AI 为每个角色生成定制化简报,只包含: - - 与你相关的任务变更 - - 风险提醒 - - 需要对齐的事项 - - 高影响信号 - - 团队更新 - - 彻底过滤无关信息,替代传统日报/周报 - """ - try: - service = get_daily_briefing_service(db) - briefing = await service.generate_personalized_briefing( - user_id=int(current_user.id), - tenant_id=tenant_id - ) - return briefing - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"生成简报失败: {str(e)}" - ) - - -@router.get("/daily-briefing/team/{project_id}") -async def get_team_briefing( - project_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 获取团队简报(给管理者) - - 包含: - - 整体进度 - - 每个成员的工作量 - - 风险和阻塞 - - 需要决策的事项 - """ - try: - service = get_daily_briefing_service(db) - briefing = await service.generate_team_briefing( - project_id=project_id, - tenant_id=tenant_id - ) - return briefing - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"生成团队简报失败: {str(e)}" - ) - - -@router.post("/analyze-blocker") -async def analyze_blocker( - code_context: str, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 分析代码阻塞 - - 当开发在代码注释中提到「这个接口达不到效果」时, - AI 自动触发分析,推荐替代方案或生成代码片段 - """ - from ..services.llm import LLMService - - service = get_ai_steward_service(db) - - prompt = f"""分析以下代码/注释中提到的问题,提供解决方案: - -代码上下文: -{code_context} - -请提供: -1. 问题分析 -2. 推荐的替代方案 -3. 示例代码(如适用) - -以 JSON 格式返回。""" - - try: - llm = LLMService() - response = await llm.generate( - prompt, - db=db, - tenant_id=tenant_id, - ) - - return { - 'analysis': response, - 'suggestions': [], - 'auto_generated': True - } - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"分析失败: {str(e)}" - ) - - -# ==================== Blocker Detection Endpoints ==================== - -class CodeCommentScanRequest(BaseModel): - """代码注释扫描请求""" - code_content: str - file_path: str - line_number: int - project_id: int - - -class BlockerResolveRequest(BaseModel): - """阻塞解决请求""" - blocker_id: int - resolution_note: str - - -@router.post("/blockers/scan-comment") -async def scan_code_comment_for_blockers( - request: CodeCommentScanRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 扫描代码注释,检测阻塞 - - 自动识别阻塞模式: - - "等待 X 接口完成" - - "这个 API 达不到效果" - - "由于 Y 的限制,暂时跳过" - - 如果检测到阻塞,AI 会自动: - 1. 分析严重程度 - 2. 生成解决方案建议 - 3. 创建追踪任务(如需要) - """ - try: - service = get_blocker_detection_service(db) - result = await service.scan_code_comment( - code_content=request.code_content, - file_path=request.file_path, - line_number=request.line_number, - project_id=request.project_id, - user_id=int(current_user.id), - tenant_id=tenant_id - ) - - if result: - return { - 'status': 'blocker_detected', - 'blocker': result - } - - return { - 'status': 'no_blocker', - 'message': '未检测到阻塞' - } - - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"扫描失败: {str(e)}" - ) - - -@router.post("/blockers/detect-tasks") -async def detect_task_blockers( - background_tasks: BackgroundTasks, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 检测所有任务中的阻塞 - - 扫描所有超过3天未更新的进行中任务, - 使用 AI 判断是否存在阻塞 - """ - try: - service = get_blocker_detection_service(db) - blockers = await service.detect_task_blockers(tenant_id=tenant_id) - - return { - 'total_detected': len(blockers), - 'blockers': blockers - } - - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"检测失败: {str(e)}" - ) - - -@router.get("/blockers/active") -async def get_active_blockers( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 获取所有活跃的阻塞 - - 返回所有未解决的阻塞,按严重程度排序 - """ - try: - service = get_blocker_detection_service(db) - blockers = await service.get_active_blockers(tenant_id=tenant_id) - - # 按严重程度排序 - severity_order = {'high': 0, 'medium': 1, 'low': 2} - blockers.sort(key=lambda b: severity_order.get(b['severity'], 1)) - - return { - 'total': len(blockers), - 'blockers': blockers - } - - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"获取失败: {str(e)}" - ) - - -@router.post("/blockers/resolve") -async def resolve_blocker( - request: BlockerResolveRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 标记阻塞为已解决 - - 记录解决方案并关闭阻塞记录 - """ - try: - service = get_blocker_detection_service(db) - result = await service.resolve_blocker( - blocker_id=request.blocker_id, - resolution_note=request.resolution_note, - tenant_id=tenant_id - ) - - return result - - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"解决失败: {str(e)}" - ) - - -@router.get("/stats") -async def get_ai_steward_stats( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 获取 AI Steward 统计数据 - - 展示 AI 为团队节省的工作量: - - 自动捕获的信号数 - - 避免的认知冲突数 - - 自动解决的阻塞数 - - 节省的会议时间(小时) - """ - from ..models import Signal, CognitiveConflict - from sqlalchemy import func, text - - # 统计信号数量 - week_ago = _utc_now_naive() - timedelta(days=7) - month_ago = _utc_now_naive() - timedelta(days=30) - - signal_result = await db.execute( - select(func.count(Signal.id)).where( - Signal.tenant_id == tenant_id, - Signal.created_at >= week_ago - ) - ) - signals_captured = signal_result.scalar() or 0 - - # 统计高影响信号 - high_impact_result = await db.execute( - select(func.count(Signal.id)).where( - Signal.tenant_id == tenant_id, - Signal.impact_score >= 0.8, - Signal.created_at >= week_ago - ) - ) - high_impact_count = high_impact_result.scalar() or 0 - - # 统计认知冲突 - conflict_result = await db.execute( - select(func.count(CognitiveConflict.id)).where( - CognitiveConflict.tenant_id == tenant_id, - CognitiveConflict.status.in_(['detected', 'active']) - ) - ) - active_conflicts = conflict_result.scalar() or 0 - - # 统计已解决的冲突 - resolved_conflict_result = await db.execute( - select(func.count(CognitiveConflict.id)).where( - CognitiveConflict.tenant_id == tenant_id, - CognitiveConflict.status == 'resolved', - CognitiveConflict.resolved_at >= month_ago - ) - ) - conflicts_resolved = resolved_conflict_result.scalar() or 0 - - # 统计阻塞 - blocker_result = await db.execute( - text(""" - SELECT COUNT(*) FROM cognitive_conflicts - WHERE tenant_id = :tenant_id - AND conflict_type IN ('code_blocker', 'task_blocker') - AND status = 'detected' - """), - {"tenant_id": tenant_id} - ) - active_blockers = blocker_result.scalar() or 0 - - # 活跃项目数 - from ..models import Project - project_result = await db.execute( - select(func.count(Project.id)).where( - Project.tenant_id == tenant_id, - Project.status == 'active' - ) - ) - active_projects = project_result.scalar() or 0 - - # 估算节省时间(每个冲突/阻塞避免约2小时会议) - hours_saved = conflicts_resolved * 2 - - stats = { - 'signals_captured': signals_captured, - 'high_impact_signals': high_impact_count, - 'active_conflicts': active_conflicts, - 'conflicts_resolved': conflicts_resolved, - 'active_blockers': active_blockers, - 'active_projects': active_projects, - 'hours_saved': hours_saved, - 'last_updated': now_cn_aware().isoformat() - } - - return stats - - -@router.post("/sync-signal") -async def sync_external_signal( - source: str, - content: str, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 手动同步外部信号 - - 支持从以下来源同步: - - 飞书群聊 - - GitHub commits/PRs - - 浏览器标签页 - """ - from ..models import Signal - from ..services.ai_steward import get_ai_steward_service - - service = get_ai_steward_service(db) - - # AI 分析信号重要性 - prompt = f"""分析以下信号的重要性: - -来源:{source} -内容:{content} - -请评估: -1. impact_score (0-1) -2. priority (low/medium/high/critical) -3. 是否需要立即行动 - -以 JSON 格式返回。""" - - try: - from ..services.llm import LLMService - llm = LLMService() - response = await llm.generate( - prompt, - db=db, - tenant_id=tenant_id, - ) - - # 创建信号 - signal = Signal( - source=source, - content=content, - impact_score=0.7, - priority='medium', - processed=False, - tenant_id=tenant_id - ) - db.add(signal) - await db.commit() - - return { - 'signal_id': str(signal.id), - 'analysis': response, - 'status': 'captured' - } - - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"信号同步失败: {str(e)}" - ) - - -# ==================== Event-Driven Workflow Endpoints ==================== - -class WorkflowTriggerRequest(BaseModel): - """工作流触发请求""" - event_type: str - event_data: Dict[str, Any] - - -@router.post("/workflow/trigger") -async def trigger_workflow( - request: WorkflowTriggerRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 触发事件驱动工作流 - - 支持的事件类型: - - high_impact_signal: 高影响信号到达 - - blocker_detected: 阻塞检测 - - cognitive_conflict: 认知冲突 - - tech_stack_change: 技术栈变更 - - competitor_update: 竞品动态 - - task_completed: 任务完成 - - task_blocked: 任务阻塞 - - project_risk: 项目风险 - - 工作流会自动: - 1. 调整项目优先级 - 2. 创建响应任务 - 3. 通知相关角色 - 4. 更新依赖关系 - """ - try: - engine = get_event_workflow_engine(db) - result = await engine.trigger_workflow( - event_type=request.event_type, - event_data=request.event_data, - tenant_id=tenant_id - ) - return result - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"工作流触发失败: {str(e)}" - ) - - -@router.post("/workflow/signal/{signal_id}") -async def trigger_signal_workflow( - signal_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 当接收到高影响信号时自动触发工作流 - - 检查信号的 impact_score,如果超过阈值(0.85), - 自动触发工作流来调整相关项目的优先级 - """ - from ..models import Signal - from sqlalchemy.future import select - - signal_result = await db.execute( - select(Signal).where( - Signal.id == signal_id, - Signal.tenant_id == tenant_id - ) - ) - signal = signal_result.scalar_one_or_none() - - if not signal: - raise HTTPException(status_code=404, detail="信号不存在") - - # 检查是否达到阈值 - if signal.impact_score < 0.85: - return { - 'status': 'ignored', - 'reason': f'信号影响分数 {signal.impact_score} 低于阈值 0.85' - } - - # 触发高影响信号工作流 - engine = get_event_workflow_engine(db) - result = await engine.trigger_workflow( - event_type=WorkflowEventType.HIGH_IMPACT_SIGNAL, - event_data={'signal_id': signal_id}, - tenant_id=tenant_id - ) - - return result - - -@router.get("/workflow/status") -async def get_workflow_status( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 获取工作流状态 - - 显示最近触发的工作流及其结果 - """ - from ..models import AICallLog - from sqlalchemy.future import select - - # 获取最近的工作流记录 - # 这里简化处理,实际应该有专门的工作流记录表 - return { - 'active_workflows': [], - 'recent_triggers': [], - 'message': '工作流状态追踪' - } - - -@router.post("/workflow/auto-enable") -async def enable_auto_workflow( - project_id: int, - enable: bool = True, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 启用/禁用项目的自动工作流 - - 启用后,当以下情况发生时会自动触发工作流: - - 高影响信号到达(>0.85) - - 任务阻塞检测 - - 认知冲突检测 - """ - from ..models import Project - from sqlalchemy.future import select - - project_result = await db.execute( - select(Project).where( - Project.id == project_id, - Project.tenant_id == tenant_id - ) - ) - project = project_result.scalar_one_or_none() - - if not project: - raise HTTPException(status_code=404, detail="项目不存在") - - # 这里应该设置项目的 auto_workflow 标志 - # 由于 Project 模型可能没有这个字段,暂时返回成功 - return { - 'project_id': project_id, - 'auto_workflow': enable, - 'message': f'自动工作流已{"启用" if enable else "禁用"}' - } - - -# ==================== AI Config Suggestion ==================== - -@router.get("/config/suggest") -async def get_ai_config_suggestion( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - AI 建议配置方案 - - 根据团队现状,智能推荐应该优先配置哪些集成源 - """ - from ..models import Integration, Project, Signal - from sqlalchemy.future import select - from sqlalchemy import func - - # 检查已配置的集成 - configured_sources = [] - - # 检查飞书配置 - feishu_result = await db.execute( - select(Integration).where( - Integration.tenant_id == tenant_id, - Integration.type == 'feishu', - Integration.is_active == True - ) - ) - if feishu_result.scalar_one_or_none(): - configured_sources.append('feishu') - - # 检查活跃项目数 - project_result = await db.execute( - select(func.count(Project.id)).where( - Project.tenant_id == tenant_id, - Project.status == 'active' - ) - ) - active_projects = project_result.scalar() or 0 - - # 检查近期信号数量 - signal_result = await db.execute( - select(func.count(Signal.id)).where( - Signal.tenant_id == tenant_id, - Signal.created_at >= (_utc_now_naive() - timedelta(days=7)) - ) - ) - recent_signals = signal_result.scalar() or 0 - - # AI 分析并推荐配置 - recommended = [] - reasoning_parts = [] - - # 根据项目数量推荐 - if active_projects > 0: - if 'feishu' not in configured_sources: - recommended.append('feishu') - reasoning_parts.append(f"您有 {active_projects} 个活跃项目,建议配置飞书群聊集成以自动捕获需求讨论") - - # 根据信号量推荐 - if recent_signals < 10: - if 'hn' not in [s.split('_')[0] for s in configured_sources]: - recommended.append('hn') - reasoning_parts.append("近期信号较少,建议启用 HackerNews 监控获取更多技术趋势") - - # 默认推荐 - if not recommended: - if 'feishu' not in configured_sources: - recommended.append('feishu') - if 'hn' not in [s.split('_')[0] for s in configured_sources]: - recommended.append('hn') - reasoning_parts.append("建议配置基础集成源以启动 AI 主动捕获能力") - - reasoning = ";".join(reasoning_parts) if reasoning_parts else "所有基础集成已配置,AI 主动捕获能力已全面启用" - - # 估算影响 - impact_levels = { - 0: "暂无明显影响", - 1: "预计每周节省 2 小时手动录入时间", - 2: "预计每周节省 4 小时手动录入时间,捕获 10+ 个需求信号", - 3: "预计每周节省 6 小时会议时间,自动对齐 5+ 个认知冲突", - 4: "预计每周节省 8 小时会议时间,主动捕获 20+ 个需求信号" - } - impact = impact_levels.get(len(recommended), impact_levels[4]) - - return { - 'recommended_sources': recommended, - 'reasoning': reasoning, - 'estimated_impact': impact, - 'current_config': { - 'feishu_configured': 'feishu' in configured_sources, - 'hn_configured': any('hn' in s for s in configured_sources), - 'github_configured': any('github' in s for s in configured_sources), - 'browser_installed': False # 需要前端检测 - } - } +__all__ = ["router"] diff --git a/backend/app/api/ai_steward_api/__init__.py b/backend/app/api/ai_steward_api/__init__.py new file mode 100644 index 00000000..c966b11a --- /dev/null +++ b/backend/app/api/ai_steward_api/__init__.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from fastapi import APIRouter + +from . import ( + routes_brief_blockers, + routes_capture, + routes_conflicts, + routes_rag_sources, + routes_stats_config, + routes_workflow, +) + +router = APIRouter(prefix="/ai-steward", tags=["AI Steward"]) +for _module in [ + routes_rag_sources, + routes_capture, + routes_conflicts, + routes_brief_blockers, + routes_stats_config, + routes_workflow, +]: + router.include_router(_module.router) + +__all__ = ["router"] diff --git a/backend/app/api/ai_steward_api/helpers.py b/backend/app/api/ai_steward_api/helpers.py new file mode 100644 index 00000000..652c4a6f --- /dev/null +++ b/backend/app/api/ai_steward_api/helpers.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from .shared import * + +def _utc_now_naive() -> datetime: + return now_cn_naive() + +async def _ensure_project_signal_link( + db: AsyncSession, + *, + tenant_id: int, + project_id: int, + signal_id: int, + link_type: str = "related", +) -> None: + existing = ( + await db.execute( + select(ProjectSignalLink.id).where( + and_( + ProjectSignalLink.tenant_id == int(tenant_id), + ProjectSignalLink.project_id == int(project_id), + ProjectSignalLink.signal_id == int(signal_id), + ) + ).limit(1) + ) + ).scalar_one_or_none() + if existing is not None: + return + db.add( + ProjectSignalLink( + tenant_id=int(tenant_id), + project_id=int(project_id), + signal_id=int(signal_id), + link_type=link_type, + relevance_score=1.0, + ) + ) + +def _parse_related_users(raw_value: Any) -> List[str]: + """兼容 involved_user_ids 的 list / json-string / 单值字符串。""" + if raw_value is None: + return [] + if isinstance(raw_value, list): + return [str(item) for item in raw_value if item is not None] + if isinstance(raw_value, (bytes, bytearray)): + raw_value = raw_value.decode("utf-8", errors="ignore") + if isinstance(raw_value, str): + text = raw_value.strip() + if not text: + return [] + try: + parsed = json.loads(text) + except (TypeError, ValueError, json.JSONDecodeError): + if "," in text: + return [v.strip() for v in text.split(",") if v.strip()] + return [text] + if isinstance(parsed, list): + return [str(item) for item in parsed if item is not None] + if parsed is None: + return [] + return [str(parsed)] + return [str(raw_value)] + diff --git a/backend/app/api/ai_steward_api/routes_brief_blockers.py b/backend/app/api/ai_steward_api/routes_brief_blockers.py new file mode 100644 index 00000000..19cb628f --- /dev/null +++ b/backend/app/api/ai_steward_api/routes_brief_blockers.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +from .shared import * +from .schemas import BlockerResolveRequest, CodeCommentScanRequest, DailyBriefingResponse + +@router.get("/daily-briefing") +async def get_daily_briefing( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 获取个性化每日简报 + + AI 为每个角色生成定制化简报,只包含: + - 与你相关的任务变更 + - 风险提醒 + - 需要对齐的事项 + - 高影响信号 + - 团队更新 + + 彻底过滤无关信息,替代传统日报/周报 + """ + try: + service = get_daily_briefing_service(db) + briefing = await service.generate_personalized_briefing( + user_id=int(current_user.id), + tenant_id=tenant_id + ) + return briefing + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"生成简报失败: {str(e)}" + ) + +@router.get("/daily-briefing/team/{project_id}") +async def get_team_briefing( + project_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 获取团队简报(给管理者) + + 包含: + - 整体进度 + - 每个成员的工作量 + - 风险和阻塞 + - 需要决策的事项 + """ + try: + service = get_daily_briefing_service(db) + briefing = await service.generate_team_briefing( + project_id=project_id, + tenant_id=tenant_id + ) + return briefing + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"生成团队简报失败: {str(e)}" + ) + +@router.post("/analyze-blocker") +async def analyze_blocker( + code_context: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 分析代码阻塞 + + 当开发在代码注释中提到「这个接口达不到效果」时, + AI 自动触发分析,推荐替代方案或生成代码片段 + """ + from ..services.llm import LLMService + + service = get_ai_steward_service(db) + + prompt = f"""分析以下代码/注释中提到的问题,提供解决方案: + +代码上下文: +{code_context} + +请提供: +1. 问题分析 +2. 推荐的替代方案 +3. 示例代码(如适用) + +以 JSON 格式返回。""" + + try: + llm = LLMService() + response = await llm.generate( + prompt, + db=db, + tenant_id=tenant_id, + ) + + return { + 'analysis': response, + 'suggestions': [], + 'auto_generated': True + } + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"分析失败: {str(e)}" + ) + +@router.post("/blockers/scan-comment") +async def scan_code_comment_for_blockers( + request: CodeCommentScanRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 扫描代码注释,检测阻塞 + + 自动识别阻塞模式: + - "等待 X 接口完成" + - "这个 API 达不到效果" + - "由于 Y 的限制,暂时跳过" + + 如果检测到阻塞,AI 会自动: + 1. 分析严重程度 + 2. 生成解决方案建议 + 3. 创建追踪任务(如需要) + """ + try: + service = get_blocker_detection_service(db) + result = await service.scan_code_comment( + code_content=request.code_content, + file_path=request.file_path, + line_number=request.line_number, + project_id=request.project_id, + user_id=int(current_user.id), + tenant_id=tenant_id + ) + + if result: + return { + 'status': 'blocker_detected', + 'blocker': result + } + + return { + 'status': 'no_blocker', + 'message': '未检测到阻塞' + } + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"扫描失败: {str(e)}" + ) + +@router.post("/blockers/detect-tasks") +async def detect_task_blockers( + background_tasks: BackgroundTasks, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 检测所有任务中的阻塞 + + 扫描所有超过3天未更新的进行中任务, + 使用 AI 判断是否存在阻塞 + """ + try: + service = get_blocker_detection_service(db) + blockers = await service.detect_task_blockers(tenant_id=tenant_id) + + return { + 'total_detected': len(blockers), + 'blockers': blockers + } + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"检测失败: {str(e)}" + ) + +@router.get("/blockers/active") +async def get_active_blockers( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 获取所有活跃的阻塞 + + 返回所有未解决的阻塞,按严重程度排序 + """ + try: + service = get_blocker_detection_service(db) + blockers = await service.get_active_blockers(tenant_id=tenant_id) + + # 按严重程度排序 + severity_order = {'high': 0, 'medium': 1, 'low': 2} + blockers.sort(key=lambda b: severity_order.get(b['severity'], 1)) + + return { + 'total': len(blockers), + 'blockers': blockers + } + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"获取失败: {str(e)}" + ) + +@router.post("/blockers/resolve") +async def resolve_blocker( + request: BlockerResolveRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 标记阻塞为已解决 + + 记录解决方案并关闭阻塞记录 + """ + try: + service = get_blocker_detection_service(db) + result = await service.resolve_blocker( + blocker_id=request.blocker_id, + resolution_note=request.resolution_note, + tenant_id=tenant_id + ) + + return result + + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"解决失败: {str(e)}" + ) + diff --git a/backend/app/api/ai_steward_api/routes_capture.py b/backend/app/api/ai_steward_api/routes_capture.py new file mode 100644 index 00000000..074f65fa --- /dev/null +++ b/backend/app/api/ai_steward_api/routes_capture.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +from .shared import * +from .schemas import BrowserCaptureRequest, FeishuCaptureRequest, GitHubCaptureRequest, ProjectFromTextRequest, ProjectFromTextResponse + +@router.post("/project-from-text", response_model=ProjectFromTextResponse) +async def create_project_from_text( + request: ProjectFromTextRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 零配置项目创建 - 从一句话生成完整项目 + + 示例输入: + ``` + 我们要做一个 AI 客服工具,基于 GPT-4o mini,对标 XX 竞品,3 周上线 + ``` + + AI 将自动生成: + - 完整的项目配置 + - 竞品分析信号 + - 项目宪章 + - 任务链(含依赖关系) + - 风险预警规则 + """ + try: + service = get_ai_steward_service(db) + + result = await service.create_project_from_natural_language( + input_text=request.input_text, + creator_id=int(current_user.id), + tenant_id=tenant_id + ) + + return ProjectFromTextResponse(**result) + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"项目创建失败: {str(e)}" + ) + +@router.post("/signal/capture-feishu") +@audited_write(caller="ai") +async def capture_feishu_signal( + request: FeishuCaptureRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 从飞书群聊捕获信号 + + AI 会自动判断消息是否包含有价值的项目或技术信息, + 如果是,则创建信号并自动关联到相关项目 + """ + try: + service = ProactiveSignalCaptureService(db) + result = await service.capture_from_feishu( + group_chat_id=request.group_chat_id, + message_content=request.message_content, + sender=request.sender, + tenant_id=tenant_id, + feishu_message_id=request.feishu_message_id, + ) + response_payload = dict(result) if isinstance(result, dict) else {"result": result} + + should_enqueue_reply = bool(response_payload.get("direct_reply_required")) + reply_text = str(response_payload.get("direct_reply_text") or "").strip() or "已收到,正在处理。" + source_message_id = (str(request.feishu_message_id or "").strip()[:128] or None) + normalized_chat_id = str(request.group_chat_id or "").strip() + if should_enqueue_reply and normalized_chat_id: + try: + outbox_id = await FeishuOutboxService.enqueue( + db, + tenant_id=int(tenant_id), + chat_id=normalized_chat_id, + text=reply_text, + source_message_id=source_message_id, + dedupe_seed=f"capture-feishu:{normalized_chat_id}:{str(request.sender or '').strip()[:64]}", + trace_id=str(response_payload.get("ai_trace_id") or "").strip()[:64] or None, + ) + await db.commit() + response_payload["reply_outbox_enqueued"] = bool(outbox_id) + response_payload["reply_outbox_id"] = int(outbox_id) if outbox_id else None + except Exception as outbox_exc: + await db.rollback() + response_payload["reply_outbox_enqueued"] = False + response_payload["reply_outbox_error"] = str(outbox_exc)[:200] + return response_payload + except Exception as e: + raise HTTPException(status_code=500, detail=f"飞书信号捕获失败: {str(e)}") + +@router.post("/signal/capture-browser") +async def capture_browser_signal( + request: BrowserCaptureRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 从浏览器标签页捕获信号 + + 监控用户访问的技术文档、竞品页面、GitHub issues等 + """ + try: + service = ProactiveSignalCaptureService(db) + result = await service.capture_from_browser( + user_id=request.user_id, + url=request.url, + title=request.title, + content_preview=request.content_preview, + tenant_id=tenant_id + ) + return result + except Exception as e: + raise HTTPException(status_code=500, detail=f"浏览器信号捕获失败: {str(e)}") + +@router.post("/signal/capture-github") +async def capture_github_signal( + request: GitHubCaptureRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 从GitHub事件捕获信号 + + 监控竞品仓库更新、上游依赖更新等 + """ + try: + service = ProactiveSignalCaptureService(db) + result = await service.capture_from_github( + repo_url=request.repo_url, + event_type=request.event_type, + event_data=request.event_data, + tenant_id=tenant_id + ) + return result + except Exception as e: + raise HTTPException(status_code=500, detail=f"GitHub信号捕获失败: {str(e)}") + +@router.post("/signal/start-hn-monitor") +async def start_hn_monitor( + background_tasks: BackgroundTasks, + interval_minutes: int = 30, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 启动 HackerNews 定时监控(后台任务) + + 定期抓取 HackerNews 首页,分析技术趋势 + """ + async def run_monitor(): + service = ProactiveSignalCaptureService(db) + await service.start_hackernews_monitor(interval_minutes=interval_minutes, tenant_id=tenant_id) + + background_tasks.add_task(run_monitor) + return { + "status": "started", + "message": f"HackerNews 监控已启动,间隔: {interval_minutes} 分钟" + } + +@router.post("/sync-signal") +async def sync_external_signal( + source: str, + content: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 手动同步外部信号 + + 支持从以下来源同步: + - 飞书群聊 + - GitHub commits/PRs + - 浏览器标签页 + """ + from ..models import Signal + from ..services.ai_steward import get_ai_steward_service + + service = get_ai_steward_service(db) + + # AI 分析信号重要性 + prompt = f"""分析以下信号的重要性: + +来源:{source} +内容:{content} + +请评估: +1. impact_score (0-1) +2. priority (low/medium/high/critical) +3. 是否需要立即行动 + +以 JSON 格式返回。""" + + try: + from ..services.llm import LLMService + llm = LLMService() + response = await llm.generate( + prompt, + db=db, + tenant_id=tenant_id, + ) + + # 创建信号 + signal = Signal( + source=source, + content=content, + impact_score=0.7, + priority='medium', + processed=False, + tenant_id=tenant_id + ) + db.add(signal) + await db.commit() + + return { + 'signal_id': str(signal.id), + 'analysis': response, + 'status': 'captured' + } + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"信号同步失败: {str(e)}" + ) + diff --git a/backend/app/api/ai_steward_api/routes_conflicts.py b/backend/app/api/ai_steward_api/routes_conflicts.py new file mode 100644 index 00000000..30761b2b --- /dev/null +++ b/backend/app/api/ai_steward_api/routes_conflicts.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +from .shared import * +from .helpers import _parse_related_users, _utc_now_naive +from .schemas import CognitiveConflictResponse, ConflictDetectRequest + +@router.get("/cognitive-conflicts", response_model=List[CognitiveConflictResponse]) +async def get_cognitive_conflicts( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), + project_id: Optional[int] = None +): + """ + 获取认知冲突列表 + + AI 会自动识别团队中的隐性认知差异,例如: + - 产品说"用户留存提升10%",开发实现的是"DAU提升10%" + - 双方没有争论,但目标不一致,AI 会主动识别并推送对齐提醒 + """ + from ..models import CognitiveConflict, TruthNode + from sqlalchemy.future import select + + query = select(CognitiveConflict).where( + CognitiveConflict.tenant_id == tenant_id, + CognitiveConflict.status == 'active' + ) + + if project_id: + # 通过 truth_node 关联到项目 + query = query.join( + TruthNode, CognitiveConflict.truth_node_id == TruthNode.id + ).where( + TruthNode.project_id == project_id + ) + + result = await db.execute(query) + conflicts = result.scalars().all() + + return [ + CognitiveConflictResponse( + id=str(c.id), + type=c.conflict_type, + description=c.description, + severity="high" if "critical" in (c.description or "").lower() else "medium", + related_users=_parse_related_users(c.involved_user_ids), + ) + for c in conflicts + ] + +@router.post("/conflicts/detect") +async def detect_conflicts( + request: ConflictDetectRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 检测项目中的认知冲突 + + 支持检测类型: + - all: 全部检测(宪章冲突 + 定义冲突) + - charter: 仅检测宪章中的潜在冲突 + - definition: 仅检测用户定义冲突 + """ + try: + service = get_cognitive_conflict_service(db) + + detected_conflicts = [] + + if request.detect_type in ["all", "charter"]: + charter_conflicts = await service.detect_charter_conflicts( + project_id=request.project_id, + tenant_id=tenant_id + ) + detected_conflicts.extend(charter_conflicts) + + if request.detect_type in ["all", "definition"]: + definition_conflicts = await service.detect_user_definition_conflicts( + project_id=request.project_id, + tenant_id=tenant_id + ) + detected_conflicts.extend(definition_conflicts) + + return { + "project_id": request.project_id, + "detected_count": len(detected_conflicts), + "conflicts": detected_conflicts + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"冲突检测失败: {str(e)}") + +@router.post("/conflicts/{conflict_id}/propose-resolution") +async def propose_conflict_resolution( + conflict_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + AI 生成认知冲突解决方案 + + 为冲突双方生成对齐方案: + 1. 分析冲突根因 + 2. 提出妥协方案 + 3. 生成可执行的对齐计划 + """ + try: + service = get_cognitive_conflict_service(db) + resolution = await service.propose_alignment_resolution( + conflict_id=conflict_id, + tenant_id=tenant_id + ) + return resolution + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"方案生成失败: {str(e)}") + +@router.post("/conflicts/{conflict_id}/align") +async def trigger_async_alignment( + conflict_id: int, + user1_id: int, + user2_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 触发异步 1v1 认知对齐 + + 为冲突双方创建独立的对齐任务: + 1. 发送对齐邀请 + 2. 提供背景信息 + 3. 收集双方观点 + 4. AI 生成共识方案 + """ + try: + service = get_cognitive_conflict_service(db) + alignment = await service.trigger_async_alignment( + conflict_id=conflict_id, + user1_id=user1_id, + user2_id=user2_id, + tenant_id=tenant_id + ) + return alignment + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"对齐触发失败: {str(e)}") + +@router.post("/cognitive-conflicts/{conflict_id}/resolve") +async def resolve_cognitive_conflict( + conflict_id: int, + resolution_note: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 解决认知冲突 + + AI 会生成解决方案建议,双方确认后标记为已解决 + """ + from ..models import CognitiveConflict + from sqlalchemy.future import select + + result = await db.execute( + select(CognitiveConflict).where( + CognitiveConflict.id == conflict_id, + CognitiveConflict.tenant_id == tenant_id + ) + ) + conflict = result.scalar_one_or_none() + + if not conflict: + raise HTTPException(status_code=404, detail="冲突记录不存在") + + conflict.status = 'resolved' + conflict.resolved_at = _utc_now_naive() + conflict.description += f"\n\n解决方案({current_user.username}): {resolution_note}" + + await db.commit() + + return {"message": "冲突已标记为已解决"} + +@router.get("/conflicts/task-mismatch/{task_id}") +async def check_task_charter_mismatch( + task_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 检测任务与宪章的一致性 + + 检查: + 1. 任务描述是否与宪章目标一致 + 2. 技术选型是否符合约束 + 3. 是否偏离项目方向 + """ + from ..models import Task, TruthNode + from sqlalchemy.future import select + + try: + # 获取任务 + result = await db.execute( + select(Task).where( + Task.id == task_id, + Task.tenant_id == tenant_id + ) + ) + task = result.scalar_one_or_none() + + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + + # 获取宪章 + charter_result = await db.execute( + select(TruthNode).where( + TruthNode.id == task.truth_node_id, + TruthNode.tenant_id == tenant_id + ) + ) + charter = charter_result.scalar_one_or_none() + + if not charter: + raise HTTPException(status_code=404, detail="宪章不存在") + + # 检测一致性 + service = get_cognitive_conflict_service(db) + mismatch = await service.detect_task_charter_mismatch( + task=task, + charter_content=charter.content, + tenant_id=tenant_id + ) + + if mismatch: + return { + "is_aligned": False, + "mismatch": mismatch + } + + return { + "is_aligned": True, + "message": "任务与宪章一致" + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"一致性检测失败: {str(e)}") + diff --git a/backend/app/api/ai_steward_api/routes_rag_sources.py b/backend/app/api/ai_steward_api/routes_rag_sources.py new file mode 100644 index 00000000..f22a194e --- /dev/null +++ b/backend/app/api/ai_steward_api/routes_rag_sources.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from .shared import * +from .helpers import _ensure_project_signal_link +from .schemas import SignalSourceListResponse, SignalSourceOnboardRequest, SignalSourceRepairRequest + +@router.post("/rag/ask") +async def ask_ai_steward_rag( + request: RAGAskRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + service = AdaptiveRAGService(db, tenant_id=effective_tenant_id) + result = await service.answer_question( + question=request.question, + asked_by=int(current_user.id), + asked_via="pmflow", + project_id=request.project_id, + source_chat_id=request.source_chat_id, + session_id=request.session_id, + force_new_session=bool(request.new_session), + thinking_mode=request.thinking_mode, + action_mode=request.action_mode, + sync_requirement=bool(request.sync_requirement), + ) + return success_response(result) + +@router.post("/signal-sources/onboard") +@audited_write(caller="user") +async def onboard_signal_source( + request: SignalSourceOnboardRequest, + current_user: User = Depends(require_manager_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + signal = Signal( + tenant_id=effective_tenant_id, + source="PMFlow", + content=f"[PMFlow用户#{int(current_user.id)}]: {request.instruction[:1800]}", + impact_score=0.7, + priority="medium", + processed=False, + strategic_analysis=json.dumps( + { + "source": "pmflow_manual_signal_source_onboard", + "instruction": request.instruction[:2000], + "created_at": now_cn_aware().isoformat(), + }, + ensure_ascii=False, + ), + tasks_generated=json.dumps([], ensure_ascii=False), + matched_project_id=int(request.project_id), + title=(request.instruction[:80] or "新增信号源"), + signal_type="新需求", + summary=request.instruction[:300], + reasoning=json.dumps(["来自 AI Steward 手动信号源接入请求。"], ensure_ascii=False), + confidence=0.82, + recommended_action="enter_only", + status="待处理", + ) + if not request.dry_run: + db.add(signal) + await db.flush() + await _ensure_project_signal_link( + db, + tenant_id=effective_tenant_id, + project_id=int(request.project_id), + signal_id=int(signal.id), + ) + await db.commit() + await db.refresh(signal) + signal_id_value = int(signal.id) if not request.dry_run and getattr(signal, "id", None) is not None else None + + service = SignalSourceOrchestrationService(db) + result = await service.orchestrate_from_signal( + tenant_id=effective_tenant_id, + project_id=int(request.project_id), + signal_id=signal_id_value, + initiating_text=request.instruction, + requested_via="pmflow", + operator_user_id=int(current_user.id), + dry_run=bool(request.dry_run), + response_mode=request.response_mode, + ) + return success_response( + { + "signal_id": signal_id_value, + **result, + } + ) + +@router.get("/orchestration-runs/{trace_id}") +async def get_orchestration_run( + trace_id: str, + current_user: User = Depends(require_manager_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + row = await AIOrchestrationRunService(db).get_run( + tenant_id=effective_tenant_id, + trace_id=str(trace_id or "").strip(), + ) + if row is None: + raise HTTPException(status_code=404, detail="orchestration run not found") + return success_response(AIOrchestrationRunService.serialize_run(row)) + +@router.get("/projects/{project_id}/signal-sources") +async def list_project_signal_sources( + project_id: int, + current_user: User = Depends(require_manager_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + items = await SignalSourceOrchestrationService(db).list_project_sources( + tenant_id=effective_tenant_id, + project_id=int(project_id), + ) + return success_response(SignalSourceListResponse(items=items).model_dump()) + +@router.post("/signal-sources/repair-dirty") +@audited_write(caller="system") +async def repair_dirty_signal_sources( + request: SignalSourceRepairRequest, + current_user: User = Depends(require_manager_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) + try: + rows = await SignalSourceRepairService(db).repair_registries( + tenant_id=effective_tenant_id, + project_id=int(request.project_id), + registry_ids=[int(item) for item in request.registry_ids], + sender=str(request.sender or "PMFlow修复器").strip()[:64] or "PMFlow修复器", + ) + except SignalSourceRepairTimeoutError as exc: + raise HTTPException(status_code=504, detail=str(exc)) from exc + except SignalSourceRepairMissingSignalError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return success_response( + { + "project_id": int(request.project_id), + "items": rows, + } + ) + diff --git a/backend/app/api/ai_steward_api/routes_stats_config.py b/backend/app/api/ai_steward_api/routes_stats_config.py new file mode 100644 index 00000000..6b2df458 --- /dev/null +++ b/backend/app/api/ai_steward_api/routes_stats_config.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +from .shared import * +from .helpers import _utc_now_naive + +@router.get("/stats") +async def get_ai_steward_stats( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 获取 AI Steward 统计数据 + + 展示 AI 为团队节省的工作量: + - 自动捕获的信号数 + - 避免的认知冲突数 + - 自动解决的阻塞数 + - 节省的会议时间(小时) + """ + from ..models import Signal, CognitiveConflict + from sqlalchemy import func, text + + # 统计信号数量 + week_ago = _utc_now_naive() - timedelta(days=7) + month_ago = _utc_now_naive() - timedelta(days=30) + + signal_result = await db.execute( + select(func.count(Signal.id)).where( + Signal.tenant_id == tenant_id, + Signal.created_at >= week_ago + ) + ) + signals_captured = signal_result.scalar() or 0 + + # 统计高影响信号 + high_impact_result = await db.execute( + select(func.count(Signal.id)).where( + Signal.tenant_id == tenant_id, + Signal.impact_score >= 0.8, + Signal.created_at >= week_ago + ) + ) + high_impact_count = high_impact_result.scalar() or 0 + + # 统计认知冲突 + conflict_result = await db.execute( + select(func.count(CognitiveConflict.id)).where( + CognitiveConflict.tenant_id == tenant_id, + CognitiveConflict.status.in_(['detected', 'active']) + ) + ) + active_conflicts = conflict_result.scalar() or 0 + + # 统计已解决的冲突 + resolved_conflict_result = await db.execute( + select(func.count(CognitiveConflict.id)).where( + CognitiveConflict.tenant_id == tenant_id, + CognitiveConflict.status == 'resolved', + CognitiveConflict.resolved_at >= month_ago + ) + ) + conflicts_resolved = resolved_conflict_result.scalar() or 0 + + # 统计阻塞 + blocker_result = await db.execute( + text(""" + SELECT COUNT(*) FROM cognitive_conflicts + WHERE tenant_id = :tenant_id + AND conflict_type IN ('code_blocker', 'task_blocker') + AND status = 'detected' + """), + {"tenant_id": tenant_id} + ) + active_blockers = blocker_result.scalar() or 0 + + # 活跃项目数 + from ..models import Project + project_result = await db.execute( + select(func.count(Project.id)).where( + Project.tenant_id == tenant_id, + Project.status == 'active' + ) + ) + active_projects = project_result.scalar() or 0 + + # 估算节省时间(每个冲突/阻塞避免约2小时会议) + hours_saved = conflicts_resolved * 2 + + stats = { + 'signals_captured': signals_captured, + 'high_impact_signals': high_impact_count, + 'active_conflicts': active_conflicts, + 'conflicts_resolved': conflicts_resolved, + 'active_blockers': active_blockers, + 'active_projects': active_projects, + 'hours_saved': hours_saved, + 'last_updated': now_cn_aware().isoformat() + } + + return stats + +@router.get("/config/suggest") +async def get_ai_config_suggestion( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + AI 建议配置方案 + + 根据团队现状,智能推荐应该优先配置哪些集成源 + """ + from ..models import Integration, Project, Signal + from sqlalchemy.future import select + from sqlalchemy import func + + # 检查已配置的集成 + configured_sources = [] + + # 检查飞书配置 + feishu_result = await db.execute( + select(Integration).where( + Integration.tenant_id == tenant_id, + Integration.type == 'feishu', + Integration.is_active == True + ) + ) + if feishu_result.scalar_one_or_none(): + configured_sources.append('feishu') + + # 检查活跃项目数 + project_result = await db.execute( + select(func.count(Project.id)).where( + Project.tenant_id == tenant_id, + Project.status == 'active' + ) + ) + active_projects = project_result.scalar() or 0 + + # 检查近期信号数量 + signal_result = await db.execute( + select(func.count(Signal.id)).where( + Signal.tenant_id == tenant_id, + Signal.created_at >= (_utc_now_naive() - timedelta(days=7)) + ) + ) + recent_signals = signal_result.scalar() or 0 + + # AI 分析并推荐配置 + recommended = [] + reasoning_parts = [] + + # 根据项目数量推荐 + if active_projects > 0: + if 'feishu' not in configured_sources: + recommended.append('feishu') + reasoning_parts.append(f"您有 {active_projects} 个活跃项目,建议配置飞书群聊集成以自动捕获需求讨论") + + # 根据信号量推荐 + if recent_signals < 10: + if 'hn' not in [s.split('_')[0] for s in configured_sources]: + recommended.append('hn') + reasoning_parts.append("近期信号较少,建议启用 HackerNews 监控获取更多技术趋势") + + # 默认推荐 + if not recommended: + if 'feishu' not in configured_sources: + recommended.append('feishu') + if 'hn' not in [s.split('_')[0] for s in configured_sources]: + recommended.append('hn') + reasoning_parts.append("建议配置基础集成源以启动 AI 主动捕获能力") + + reasoning = ";".join(reasoning_parts) if reasoning_parts else "所有基础集成已配置,AI 主动捕获能力已全面启用" + + # 估算影响 + impact_levels = { + 0: "暂无明显影响", + 1: "预计每周节省 2 小时手动录入时间", + 2: "预计每周节省 4 小时手动录入时间,捕获 10+ 个需求信号", + 3: "预计每周节省 6 小时会议时间,自动对齐 5+ 个认知冲突", + 4: "预计每周节省 8 小时会议时间,主动捕获 20+ 个需求信号" + } + impact = impact_levels.get(len(recommended), impact_levels[4]) + + return { + 'recommended_sources': recommended, + 'reasoning': reasoning, + 'estimated_impact': impact, + 'current_config': { + 'feishu_configured': 'feishu' in configured_sources, + 'hn_configured': any('hn' in s for s in configured_sources), + 'github_configured': any('github' in s for s in configured_sources), + 'browser_installed': False # 需要前端检测 + } + } + diff --git a/backend/app/api/ai_steward_api/routes_workflow.py b/backend/app/api/ai_steward_api/routes_workflow.py new file mode 100644 index 00000000..4a099787 --- /dev/null +++ b/backend/app/api/ai_steward_api/routes_workflow.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from .shared import * +from .schemas import WorkflowTriggerRequest + +@router.post("/workflow/trigger") +async def trigger_workflow( + request: WorkflowTriggerRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 触发事件驱动工作流 + + 支持的事件类型: + - high_impact_signal: 高影响信号到达 + - blocker_detected: 阻塞检测 + - cognitive_conflict: 认知冲突 + - tech_stack_change: 技术栈变更 + - competitor_update: 竞品动态 + - task_completed: 任务完成 + - task_blocked: 任务阻塞 + - project_risk: 项目风险 + + 工作流会自动: + 1. 调整项目优先级 + 2. 创建响应任务 + 3. 通知相关角色 + 4. 更新依赖关系 + """ + try: + engine = get_event_workflow_engine(db) + result = await engine.trigger_workflow( + event_type=request.event_type, + event_data=request.event_data, + tenant_id=tenant_id + ) + return result + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"工作流触发失败: {str(e)}" + ) + +@router.post("/workflow/signal/{signal_id}") +async def trigger_signal_workflow( + signal_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 当接收到高影响信号时自动触发工作流 + + 检查信号的 impact_score,如果超过阈值(0.85), + 自动触发工作流来调整相关项目的优先级 + """ + from ..models import Signal + from sqlalchemy.future import select + + signal_result = await db.execute( + select(Signal).where( + Signal.id == signal_id, + Signal.tenant_id == tenant_id + ) + ) + signal = signal_result.scalar_one_or_none() + + if not signal: + raise HTTPException(status_code=404, detail="信号不存在") + + # 检查是否达到阈值 + if signal.impact_score < 0.85: + return { + 'status': 'ignored', + 'reason': f'信号影响分数 {signal.impact_score} 低于阈值 0.85' + } + + # 触发高影响信号工作流 + engine = get_event_workflow_engine(db) + result = await engine.trigger_workflow( + event_type=WorkflowEventType.HIGH_IMPACT_SIGNAL, + event_data={'signal_id': signal_id}, + tenant_id=tenant_id + ) + + return result + +@router.get("/workflow/status") +async def get_workflow_status( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 获取工作流状态 + + 显示最近触发的工作流及其结果 + """ + from ..models import AICallLog + from sqlalchemy.future import select + + # 获取最近的工作流记录 + # 这里简化处理,实际应该有专门的工作流记录表 + return { + 'active_workflows': [], + 'recent_triggers': [], + 'message': '工作流状态追踪' + } + +@router.post("/workflow/auto-enable") +async def enable_auto_workflow( + project_id: int, + enable: bool = True, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id) +): + """ + 启用/禁用项目的自动工作流 + + 启用后,当以下情况发生时会自动触发工作流: + - 高影响信号到达(>0.85) + - 任务阻塞检测 + - 认知冲突检测 + """ + from ..models import Project + from sqlalchemy.future import select + + project_result = await db.execute( + select(Project).where( + Project.id == project_id, + Project.tenant_id == tenant_id + ) + ) + project = project_result.scalar_one_or_none() + + if not project: + raise HTTPException(status_code=404, detail="项目不存在") + + # 这里应该设置项目的 auto_workflow 标志 + # 由于 Project 模型可能没有这个字段,暂时返回成功 + return { + 'project_id': project_id, + 'auto_workflow': enable, + 'message': f'自动工作流已{"启用" if enable else "禁用"}' + } + diff --git a/backend/app/api/ai_steward_api/schemas.py b/backend/app/api/ai_steward_api/schemas.py new file mode 100644 index 00000000..83976d06 --- /dev/null +++ b/backend/app/api/ai_steward_api/schemas.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from .shared import * + +class ProjectFromTextRequest(BaseModel): + """零配置项目创建请求""" + input_text: str = Field( + ..., + description="自然语言项目描述,例如:我们要做一个 AI 客服工具,基于 GPT-4o mini,对标 XX 竞品,3 周上线", + min_length=10, + max_length=500 + ) + +class ProjectFromTextResponse(BaseModel): + """零配置项目创建响应""" + project: Dict[str, Any] + signal: Dict[str, Any] + charter: Dict[str, Any] + tasks: List[Dict[str, Any]] + risks: List[Dict[str, Any]] + analysis: Dict[str, Any] + +class CognitiveConflictResponse(BaseModel): + """认知冲突响应""" + id: str + type: str + description: str + severity: str + resolution_suggestion: Optional[str] = None + related_users: List[str] + +class DailyBriefingResponse(BaseModel): + """每日简报响应""" + date: str + total_updates: int + your_updates: int + relevant_changes: List[Dict[str, Any]] + risk_alerts: List[Dict[str, Any]] + alignment_needed: List[Dict[str, Any]] + +class FeishuCaptureRequest(BaseModel): + """飞书信号捕获请求""" + group_chat_id: str + message_content: str + sender: str + feishu_message_id: Optional[str] = None + +class BrowserCaptureRequest(BaseModel): + """浏览器信号捕获请求""" + user_id: int + url: str + title: str + content_preview: str + +class GitHubCaptureRequest(BaseModel): + """GitHub信号捕获请求""" + repo_url: str + event_type: str + event_data: Dict[str, Any] + +class ConflictDetectRequest(BaseModel): + """认知冲突检测请求""" + project_id: int + detect_type: str = "all" # all, charter, definition + +class SignalSourceOnboardRequest(BaseModel): + project_id: int = Field(..., ge=1) + instruction: str = Field(..., min_length=6, max_length=2000) + dry_run: bool = False + response_mode: Literal["fast", "complete"] = "fast" + +class SignalSourceListResponse(BaseModel): + items: List[Dict[str, Any]] + +class SignalSourceRepairRequest(BaseModel): + project_id: int = Field(..., ge=1) + registry_ids: List[int] = Field(default_factory=list, min_length=1, max_length=50) + sender: str = Field(default="PMFlow修复器", min_length=1, max_length=64) + +class CodeCommentScanRequest(BaseModel): + """代码注释扫描请求""" + code_content: str + file_path: str + line_number: int + project_id: int + +class BlockerResolveRequest(BaseModel): + """阻塞解决请求""" + blocker_id: int + resolution_note: str + +class WorkflowTriggerRequest(BaseModel): + """工作流触发请求""" + event_type: str + event_data: Dict[str, Any] + diff --git a/backend/app/api/ai_steward_api/shared.py b/backend/app/api/ai_steward_api/shared.py new file mode 100644 index 00000000..ad210dcf --- /dev/null +++ b/backend/app/api/ai_steward_api/shared.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Literal, Optional + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.response import success_response +from ...database import get_db +from ...dependencies.auth import get_current_user +from ...dependencies.permissions import require_manager_user +from ...dependencies.tenant import get_tenant_id +from ...models import ProjectSignalLink, Signal, User +from ...schemas.rag import RAGAskRequest +from ...services.adaptive_rag_service import AdaptiveRAGService +from ...services.ai_orchestration_run_service import AIOrchestrationRunService +from ...services.ai_steward import AIStewardService, get_ai_steward_service +from ...services.blocker_detection import get_blocker_detection_service +from ...services.cognitive_conflict import get_cognitive_conflict_service +from ...services.daily_briefing import get_daily_briefing_service +from ...services.event_workflow import WorkflowEventType, get_event_workflow_engine +from ...services.feishu_outbox_service import FeishuOutboxService +from ...services.signal_capture import ProactiveSignalCaptureService +from ...services.signal_source_orchestration_service import SignalSourceOrchestrationService +from ...services.signal_source_repair_service import ( + SignalSourceRepairMissingSignalError, + SignalSourceRepairService, + SignalSourceRepairTimeoutError, +) +from ...utils.audited_write import audited_write +from ...utils.timezone import now_cn_aware, now_cn_naive + +router = APIRouter(prefix="/ai-steward", tags=["AI Steward"]) diff --git a/backend/app/api/endpoints/skill.py b/backend/app/api/endpoints/skill.py index d5549a1e..b88a38f8 100644 --- a/backend/app/api/endpoints/skill.py +++ b/backend/app/api/endpoints/skill.py @@ -1,381 +1,7 @@ -""" -PMFlow 人员能力图谱 API 端点 +"""PMFlow 人员能力图谱 API 端点。""" -- 技能标签 CRUD -- 用户技能绑定 -- 团队负载看板 -- 用户个人负载查询 -- 人员推荐 -""" +from __future__ import annotations -from typing import List, Optional -from uuid import UUID +from .skill_api import router -from fastapi import APIRouter, Depends, HTTPException, status, Query -from sqlalchemy.ext.asyncio import AsyncSession - -from ...database import get_db -from ...dependencies import get_current_user, require_roles, get_tenant_id -from ...models import User -from ...schemas.skill import ( - SkillTagCreate, - SkillTagUpdate, - SkillTagResponse, - UserSkillBindingCreate, - UserSkillBindingUpdate, - UserSkillBindingBatchUpdate, - UserSkillBindingResponse, - UserWorkloadResponse, - PersonnelRecommendationResponse, - PersonnelRecommendationItem, - SkillTaskHistoryResponse, - SkillTaskHistoryItem, -) -from ...services import skill_service -from ...services import workload_service -from ...services.auth import has_any_role - -router = APIRouter(prefix="/skills", tags=["人员能力图谱"]) - - -def _skill_tag_to_response(tag) -> SkillTagResponse: - """将 SkillTag ORM 转为响应模型""" - return SkillTagResponse( - id=str(tag.id), - tenant_id=tag.tenant_id, - name=tag.name, - slug=tag.slug, - category=tag.category, - description=tag.description, - is_system=tag.is_system, - sort_order=tag.sort_order, - created_at=tag.created_at.isoformat() if tag.created_at else None, - updated_at=tag.updated_at.isoformat() if tag.updated_at else None, - ) - - -# ============================================================================ -# 技能标签 CRUD -# ============================================================================ - - -@router.get("/tags", response_model=List[SkillTagResponse]) -async def list_skill_tags( - category: Optional[str] = Query(None, description="按分类过滤"), - include_system: bool = Query(True, description="是否包含系统标签"), - tenant_id: int = Depends(get_tenant_id), - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """ - 列出技能标签 - - 支持按分类过滤,系统标签可选包含。 - """ - tags = await skill_service.list_skill_tags( - db, tenant_id=tenant_id, category=category, include_system=include_system - ) - return [_skill_tag_to_response(t) for t in tags] - - -@router.post("/tags", response_model=SkillTagResponse) -async def create_skill_tag( - body: SkillTagCreate, - tenant_id: int = Depends(get_tenant_id), - current_user: User = Depends(require_roles("BOSS", "ADMIN")), - db: AsyncSession = Depends(get_db), -): - """ - 创建技能标签 - - 仅 BOSS、ADMIN 可创建。 - """ - try: - tag = await skill_service.create_skill_tag( - db, - tenant_id=tenant_id, - name=body.name, - slug=body.slug, - category=body.category, - description=body.description, - sort_order=body.sort_order, - ) - return _skill_tag_to_response(tag) - except ValueError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) - - -@router.get("/tags/{tag_id}", response_model=SkillTagResponse) -async def get_skill_tag( - tag_id: UUID, - tenant_id: int = Depends(get_tenant_id), - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """获取单个技能标签""" - tag = await skill_service.get_skill_tag(db, tag_id, tenant_id) - if not tag: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="技能标签不存在") - return _skill_tag_to_response(tag) - - -@router.patch("/tags/{tag_id}", response_model=SkillTagResponse) -async def update_skill_tag( - tag_id: UUID, - body: SkillTagUpdate, - tenant_id: int = Depends(get_tenant_id), - current_user: User = Depends(require_roles("BOSS", "ADMIN")), - db: AsyncSession = Depends(get_db), -): - """ - 更新技能标签 - - 系统预设标签不可修改。 - """ - try: - tag = await skill_service.update_skill_tag( - db, - tag_id=tag_id, - tenant_id=tenant_id, - name=body.name, - category=body.category, - description=body.description, - sort_order=body.sort_order, - ) - if not tag: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="技能标签不存在") - return _skill_tag_to_response(tag) - except ValueError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) - - -@router.delete("/tags/{tag_id}") -async def delete_skill_tag( - tag_id: UUID, - tenant_id: int = Depends(get_tenant_id), - current_user: User = Depends(require_roles("BOSS", "ADMIN")), - db: AsyncSession = Depends(get_db), -): - """ - 删除技能标签 - - 系统预设标签不可删除。 - """ - try: - ok = await skill_service.delete_skill_tag(db, tag_id, tenant_id) - if not ok: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="技能标签不存在") - return {"message": "已删除"} - except ValueError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) - - -# ============================================================================ -# 用户技能绑定 -# ============================================================================ - - -@router.get("/users/{user_id}/bindings", response_model=List[UserSkillBindingResponse]) -async def get_user_skill_bindings( - user_id: int, - tenant_id: int = Depends(get_tenant_id), - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """获取用户的技能绑定列表""" - bindings = await skill_service.get_user_skill_bindings(db, tenant_id, user_id) - return [ - UserSkillBindingResponse( - id=str(b.id), - user_id=str(b.user_id), - skill_tag_id=str(b.skill_tag_id), - skill_name=b.skill_tag.name if b.skill_tag else "", - proficiency=b.proficiency or 50, - level=b.level or b.proficiency_level or "intermediate", - years_experience=b.years_experience, - quality_score=getattr(b, "quality_score", None), - efficiency_score=getattr(b, "efficiency_score", None), - last_used_at=b.last_used_at.isoformat() if b.last_used_at else None, - created_at=b.created_at.isoformat() if b.created_at else None, - updated_at=b.updated_at.isoformat() if b.updated_at else None, - ) - for b in bindings - ] - - -@router.put("/users/{user_id}/bindings/batch") -async def batch_update_user_skill_bindings( - user_id: int, - body: UserSkillBindingBatchUpdate, - tenant_id: int = Depends(get_tenant_id), - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """ - 批量更新用户技能绑定 - - 请求体 bindings 每项格式: { "skill_tag_id": "uuid", "proficiency": 80, "level": "advanced", "years_experience": 3 } - 使用路径中的 user_id,body.user_id 可省略。 - """ - bindings = await skill_service.batch_update_user_skill_bindings( - db, tenant_id, user_id, body.bindings - ) - return {"updated_count": len(bindings), "message": "已更新"} - - -@router.get("/tags/{tag_id}/users") -async def get_skill_tag_users( - tag_id: int, - tenant_id: int = Depends(get_tenant_id), - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """按技能标签查询拥有该技能的用户""" - bindings = await skill_service.get_skill_tag_user_bindings(db, tenant_id, tag_id) - return [ - { - "user_id": str(b.user_id), - "user_name": b.user.name if b.user else "", - "proficiency": b.proficiency, - "level": b.level, - } - for b in bindings - ] - - -@router.get("/users/{user_id}/tasks", response_model=SkillTaskHistoryResponse) -async def get_user_skill_task_history( - user_id: int, - skill_id: int = Query(..., description="技能标签 ID"), - limit: int = Query(50, ge=1, le=200, description="返回条数上限"), - tenant_id: int = Depends(get_tenant_id), - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """ - 查询用户某技能下的历史任务。 - - 权限: - - 用户可查询自己 - - BOSS / ADMIN 可查询任意用户 - """ - from enum import Enum - - current_role = ( - current_user.role.value - if isinstance(current_user.role, Enum) - else current_user.role - ) - if current_user.id != user_id and not has_any_role(str(current_role), ["BOSS", "ADMIN"]): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="仅可查询自己的技能历史任务", - ) - - items = await skill_service.get_user_skill_task_history( - db=db, - tenant_id=tenant_id, - user_id=user_id, - skill_tag_id=skill_id, - limit=limit, - ) - return SkillTaskHistoryResponse( - items=[SkillTaskHistoryItem(**item) for item in items], - total=len(items), - ) - - -# ============================================================================ -# 团队负载看板 -# ============================================================================ - - -@router.get("/workload/team") -async def get_team_workload_board( - tenant_id: int = Depends(get_tenant_id), - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """ - 获取团队负载看板数据 - - 返回当前周期内所有用户的工作量汇总。 - """ - data = await workload_service.get_team_workload_board(db, tenant_id) - return {"items": data, "total": len(data)} - - -# ============================================================================ -# 用户个人负载 -# ============================================================================ - - -@router.get("/workload/users/{user_id}", response_model=dict) -async def get_user_personal_workload( - user_id: int, - tenant_id: int = Depends(get_tenant_id), - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """ - 获取用户个人负载详情 - - 用户只能查询自己的负载,BOSS/ADMIN 可查询任意用户。 - """ - from enum import Enum - - current_role = ( - current_user.role.value - if isinstance(current_user.role, Enum) - else current_user.role - ) - if current_user.id != user_id and not has_any_role(str(current_role), ["BOSS", "ADMIN"]): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="仅可查询自己的负载", - ) - - data = await workload_service.get_user_personal_workload(db, tenant_id, user_id) - if not data: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="无负载数据") - return data - - -# ============================================================================ -# 人员推荐 -# ============================================================================ - - -@router.get("/recommend", response_model=PersonnelRecommendationResponse) -async def recommend_personnel( - skills: str = Query(..., description="所需技能,逗号分隔,如: python,react,fastapi"), - task_description: Optional[str] = Query(None), - task_id: Optional[str] = Query(None), - limit: int = Query(5, ge=1, le=20), - tenant_id: int = Depends(get_tenant_id), - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """ - 根据所需技能推荐匹配人员 - - 返回匹配度评分、推荐理由等。 - """ - required_skills = [s.strip() for s in skills.split(",") if s.strip()] - result = await skill_service.recommend_personnel( - db, - tenant_id=tenant_id, - required_skills=required_skills, - task_description=task_description, - task_id=task_id, - limit=limit, - ) - return PersonnelRecommendationResponse( - task_id=result.get("task_id"), - task_description=result.get("task_description"), - required_skills=result.get("required_skills", []), - recommendations=[ - PersonnelRecommendationItem(**r) for r in result.get("recommendations", []) - ], - total_count=result.get("total_count", 0), - ) +__all__ = ["router"] diff --git a/backend/app/api/endpoints/skill_api/__init__.py b/backend/app/api/endpoints/skill_api/__init__.py new file mode 100644 index 00000000..4defeb70 --- /dev/null +++ b/backend/app/api/endpoints/skill_api/__init__.py @@ -0,0 +1,18 @@ +"""Router assembly for skill endpoints.""" + +from __future__ import annotations + +from fastapi import APIRouter + +from .bindings import router as bindings_router +from .recommend import router as recommend_router +from .tags import router as tags_router +from .workload import router as workload_router + +router = APIRouter(prefix="/skills", tags=["人员能力图谱"]) +router.include_router(tags_router) +router.include_router(bindings_router) +router.include_router(workload_router) +router.include_router(recommend_router) + +__all__ = ["router"] diff --git a/backend/app/api/endpoints/skill_api/bindings.py b/backend/app/api/endpoints/skill_api/bindings.py new file mode 100644 index 00000000..e526805a --- /dev/null +++ b/backend/app/api/endpoints/skill_api/bindings.py @@ -0,0 +1,115 @@ +"""User skill bindings and history endpoints.""" + +from __future__ import annotations + +from typing import List + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from ....database import get_db +from ....dependencies import get_current_user, get_tenant_id +from ....models import User +from ....schemas.skill import ( + SkillTaskHistoryItem, + SkillTaskHistoryResponse, + UserSkillBindingBatchUpdate, + UserSkillBindingResponse, +) +from ....services import skill_service +from .shared import ensure_self_or_admin + +router = APIRouter() + + +@router.get("/users/{user_id}/bindings", response_model=List[UserSkillBindingResponse]) +async def get_user_skill_bindings( + user_id: int, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + _ = current_user + bindings = await skill_service.get_user_skill_bindings(db, tenant_id, user_id) + return [ + UserSkillBindingResponse( + id=str(binding.id), + user_id=str(binding.user_id), + skill_tag_id=str(binding.skill_tag_id), + skill_name=binding.skill_tag.name if binding.skill_tag else "", + proficiency=binding.proficiency or 50, + level=binding.level or binding.proficiency_level or "intermediate", + years_experience=binding.years_experience, + quality_score=getattr(binding, "quality_score", None), + efficiency_score=getattr(binding, "efficiency_score", None), + last_used_at=binding.last_used_at.isoformat() if binding.last_used_at else None, + created_at=binding.created_at.isoformat() if binding.created_at else None, + updated_at=binding.updated_at.isoformat() if binding.updated_at else None, + ) + for binding in bindings + ] + + +@router.put("/users/{user_id}/bindings/batch") +async def batch_update_user_skill_bindings( + user_id: int, + body: UserSkillBindingBatchUpdate, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + _ = current_user + bindings = await skill_service.batch_update_user_skill_bindings( + db, + tenant_id, + user_id, + body.bindings, + ) + return {"updated_count": len(bindings), "message": "已更新"} + + +@router.get("/tags/{tag_id}/users") +async def get_skill_tag_users( + tag_id: int, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + _ = current_user + bindings = await skill_service.get_skill_tag_user_bindings(db, tenant_id, tag_id) + return [ + { + "user_id": str(binding.user_id), + "user_name": binding.user.name if binding.user else "", + "proficiency": binding.proficiency, + "level": binding.level, + } + for binding in bindings + ] + + +@router.get("/users/{user_id}/tasks", response_model=SkillTaskHistoryResponse) +async def get_user_skill_task_history( + user_id: int, + skill_id: int = Query(..., description="技能标签 ID"), + limit: int = Query(50, ge=1, le=200, description="返回条数上限"), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + ensure_self_or_admin( + current_user=current_user, + target_user_id=user_id, + detail="仅可查询自己的技能历史任务", + ) + items = await skill_service.get_user_skill_task_history( + db=db, + tenant_id=tenant_id, + user_id=user_id, + skill_tag_id=skill_id, + limit=limit, + ) + return SkillTaskHistoryResponse( + items=[SkillTaskHistoryItem(**item) for item in items], + total=len(items), + ) diff --git a/backend/app/api/endpoints/skill_api/recommend.py b/backend/app/api/endpoints/skill_api/recommend.py new file mode 100644 index 00000000..080da1ab --- /dev/null +++ b/backend/app/api/endpoints/skill_api/recommend.py @@ -0,0 +1,51 @@ +"""Personnel recommendation endpoint.""" + +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from ....database import get_db +from ....dependencies import get_current_user, get_tenant_id +from ....models import User +from ....schemas.skill import ( + PersonnelRecommendationItem, + PersonnelRecommendationResponse, +) +from ....services import skill_service + +router = APIRouter() + + +@router.get("/recommend", response_model=PersonnelRecommendationResponse) +async def recommend_personnel( + skills: str = Query(..., description="所需技能,逗号分隔,如: python,react,fastapi"), + task_description: Optional[str] = Query(None), + task_id: Optional[str] = Query(None), + limit: int = Query(5, ge=1, le=20), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + _ = current_user + required_skills = [skill.strip() for skill in skills.split(",") if skill.strip()] + result = await skill_service.recommend_personnel( + db, + tenant_id=tenant_id, + required_skills=required_skills, + task_description=task_description, + task_id=task_id, + limit=limit, + ) + return PersonnelRecommendationResponse( + task_id=result.get("task_id"), + task_description=result.get("task_description"), + required_skills=result.get("required_skills", []), + recommendations=[ + PersonnelRecommendationItem(**item) + for item in result.get("recommendations", []) + ], + total_count=result.get("total_count", 0), + ) diff --git a/backend/app/api/endpoints/skill_api/shared.py b/backend/app/api/endpoints/skill_api/shared.py new file mode 100644 index 00000000..31811060 --- /dev/null +++ b/backend/app/api/endpoints/skill_api/shared.py @@ -0,0 +1,31 @@ +"""Shared helpers for skill endpoints.""" + +from __future__ import annotations + +from enum import Enum + +from fastapi import HTTPException, status + +from ....schemas.skill import SkillTagResponse +from ....services.auth import has_any_role + + +def skill_tag_to_response(tag) -> SkillTagResponse: + return SkillTagResponse( + id=str(tag.id), + tenant_id=tag.tenant_id, + name=tag.name, + slug=tag.slug, + category=tag.category, + description=tag.description, + is_system=tag.is_system, + sort_order=tag.sort_order, + created_at=tag.created_at.isoformat() if tag.created_at else None, + updated_at=tag.updated_at.isoformat() if tag.updated_at else None, + ) + + +def ensure_self_or_admin(*, current_user, target_user_id: int, detail: str) -> None: + current_role = current_user.role.value if isinstance(current_user.role, Enum) else current_user.role + if current_user.id != target_user_id and not has_any_role(str(current_role), ["BOSS", "ADMIN"]): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) diff --git a/backend/app/api/endpoints/skill_api/tags.py b/backend/app/api/endpoints/skill_api/tags.py new file mode 100644 index 00000000..ee59a346 --- /dev/null +++ b/backend/app/api/endpoints/skill_api/tags.py @@ -0,0 +1,116 @@ +"""Skill tag CRUD endpoints.""" + +from __future__ import annotations + +from typing import List, Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from ....database import get_db +from ....dependencies import get_current_user, get_tenant_id, require_roles +from ....models import User +from ....schemas.skill import SkillTagCreate, SkillTagResponse, SkillTagUpdate +from ....services import skill_service +from .shared import skill_tag_to_response + +router = APIRouter() + + +@router.get("/tags", response_model=List[SkillTagResponse]) +async def list_skill_tags( + category: Optional[str] = Query(None, description="按分类过滤"), + include_system: bool = Query(True, description="是否包含系统标签"), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + _ = current_user + tags = await skill_service.list_skill_tags( + db, + tenant_id=tenant_id, + category=category, + include_system=include_system, + ) + return [skill_tag_to_response(tag) for tag in tags] + + +@router.post("/tags", response_model=SkillTagResponse) +async def create_skill_tag( + body: SkillTagCreate, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), + db: AsyncSession = Depends(get_db), +): + _ = current_user + try: + tag = await skill_service.create_skill_tag( + db, + tenant_id=tenant_id, + name=body.name, + slug=body.slug, + category=body.category, + description=body.description, + sort_order=body.sort_order, + ) + return skill_tag_to_response(tag) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) + + +@router.get("/tags/{tag_id}", response_model=SkillTagResponse) +async def get_skill_tag( + tag_id: UUID, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + _ = current_user + tag = await skill_service.get_skill_tag(db, tag_id, tenant_id) + if not tag: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="技能标签不存在") + return skill_tag_to_response(tag) + + +@router.patch("/tags/{tag_id}", response_model=SkillTagResponse) +async def update_skill_tag( + tag_id: UUID, + body: SkillTagUpdate, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), + db: AsyncSession = Depends(get_db), +): + _ = current_user + try: + tag = await skill_service.update_skill_tag( + db, + tag_id=tag_id, + tenant_id=tenant_id, + name=body.name, + category=body.category, + description=body.description, + sort_order=body.sort_order, + ) + if not tag: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="技能标签不存在") + return skill_tag_to_response(tag) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) + + +@router.delete("/tags/{tag_id}") +async def delete_skill_tag( + tag_id: UUID, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), + db: AsyncSession = Depends(get_db), +): + _ = current_user + try: + ok = await skill_service.delete_skill_tag(db, tag_id, tenant_id) + if not ok: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="技能标签不存在") + return {"message": "已删除"} + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) diff --git a/backend/app/api/endpoints/skill_api/workload.py b/backend/app/api/endpoints/skill_api/workload.py new file mode 100644 index 00000000..ce7058d7 --- /dev/null +++ b/backend/app/api/endpoints/skill_api/workload.py @@ -0,0 +1,44 @@ +"""Workload endpoints for skill domain.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from ....database import get_db +from ....dependencies import get_current_user, get_tenant_id +from ....models import User +from ....services import workload_service +from .shared import ensure_self_or_admin + +router = APIRouter() + + +@router.get("/workload/team") +async def get_team_workload_board( + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + _ = current_user + data = await workload_service.get_team_workload_board(db, tenant_id) + return {"items": data, "total": len(data)} + + +@router.get("/workload/users/{user_id}", response_model=dict) +async def get_user_personal_workload( + user_id: int, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + ensure_self_or_admin( + current_user=current_user, + target_user_id=user_id, + detail="仅可查询自己的负载", + ) + + data = await workload_service.get_user_personal_workload(db, tenant_id, user_id) + if not data: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="无负载数据") + return data diff --git a/backend/app/api/feishu.py b/backend/app/api/feishu.py index 0eb0ec0a..4234346d 100644 --- a/backend/app/api/feishu.py +++ b/backend/app/api/feishu.py @@ -1,376 +1,7 @@ -"""Feishu Integration API - 飞书集成接口。""" -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from pydantic import BaseModel, Field -from typing import List, Optional, Dict, Any -from ..database import get_db -from ..dependencies.auth import get_current_user -from ..dependencies.tenant import get_tenant_id -from ..models import User -from ..services.auth import has_any_role -from ..services.feishu_integration import get_feishu_service -from ..services.feishu_long_connection import get_feishu_long_connection_manager +"""Feishu API router compatibility entrypoint.""" +from __future__ import annotations -router = APIRouter(prefix="/feishu", tags=["Feishu Integration"]) +from .feishu_api import router - -def _build_long_connection_failure(long_status: Dict[str, Any]) -> tuple[str, Optional[str]]: - code = str(long_status.get("last_error_code") or "").strip().lower() - last_error = str(long_status.get("last_error") or "").strip() - retryable = bool(long_status.get("last_error_retryable")) - if code in {"network_error", "thread_exited"} and not retryable: - return ( - "长连接自动重连次数已耗尽,请检查网络/凭证后手动重启飞书长连接", - last_error or None, - ) - if code == "auth_failed": - return ( - "长连接鉴权失败,请检查 App ID / App Secret、应用凭证是否已生效,以及飞书应用权限是否完整", - last_error or None, - ) - if code == "network_error": - return ( - "长连接预检失败,后端到飞书开放平台的网络或 TLS 证书链异常", - last_error or None, - ) - if code == "sdk_missing": - return ("长连接 SDK 未安装(缺少 lark-oapi)", None) - if code == "thread_exited": - return ("长连接已退出,系统正在等待下一次自动重连窗口", last_error or None) - if last_error: - return ("长连接未建立,请检查配置与后端日志", last_error) - return ("长连接未建立,请检查配置后重试", None) - - -# ==================== Request/Response Models ==================== - -class FeishuConfigRequest(BaseModel): - """飞书配置请求""" - app_id: str = Field(..., description="飞书 App ID") - app_secret: str = Field(..., description="飞书 App Secret") - encrypt_key: Optional[str] = Field(None, description="已废弃字段,保留仅为兼容旧客户端") - verification_token: Optional[str] = Field(None, description="已废弃字段,保留仅为兼容旧客户端") - event_mode: str = Field("long_connection", description="事件接收模式,现仅支持 long_connection") - - -class FeishuSendMessageRequest(BaseModel): - """发送消息请求""" - chat_id: str = Field(..., description="群聊 ID") - message: str = Field(..., description="消息内容") - - -class FeishuSendCardRequest(BaseModel): - """发送卡片请求""" - chat_id: str = Field(..., description="群聊 ID") - title: str = Field(..., description="卡片标题") - content: List[Dict[str, Any]] = Field(..., description="卡片内容") - - -# ==================== Configuration Endpoints ==================== - -@router.post("/config") -async def save_feishu_config( - request: FeishuConfigRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 保存飞书配置 - - 配置信息说明: - - app_id: 飞书应用的 App ID,从飞书开放平台获取 - - app_secret: 飞书应用的 App Secret - - 获取步骤: - 1. 访问 https://open.feishu.cn/ - 2. 创建企业自建应用 - 3. 在「凭证与基础信息」页面获取 App ID 和 App Secret - 4. 添加权限:im:message, im:message:group_at_msg, im:chat - 5. 订阅事件:接收消息 v1 (message.receive_v1) - """ - # 检查用户权限 - if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]): - raise HTTPException(status_code=403, detail="只有管理员可以配置飞书集成") - - try: - event_mode = "long_connection" - - service = get_feishu_service(db) - await service.save_config( - app_id=request.app_id, - app_secret=request.app_secret, - encrypt_key="", - verification_token="", - event_mode=event_mode, - tenant_id=tenant_id - ) - manager = get_feishu_long_connection_manager() - if manager: - await manager.restart() - - next_steps = [ - "1. 飞书应用事件订阅至少包含:接收消息 v1 (message.receive_v1)", - "2. 当前模式:long_connection", - "3. 后端已自动重启飞书长连接", - ] - - return { - 'status': 'success', - 'message': '飞书配置已保存,并已切换到长连接模式', - 'next_steps': next_steps - } - except HTTPException: - raise - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"保存配置失败: {str(e)}" - ) - - -@router.get("/config") -async def get_feishu_config( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """获取飞书配置状态。管理员可获取完整配置用于回填修改表单。""" - try: - service = get_feishu_service(db) - await service.load_config(tenant_id) - manager = get_feishu_long_connection_manager() - can_view_secrets = has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]) - - return { - 'is_configured': service.is_configured(), - 'app_id': service.app_id, - 'app_secret': service.app_secret if can_view_secrets else None, - 'event_mode': "long_connection", - 'long_connection': manager.status() if manager else None, - } - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"获取配置失败: {str(e)}" - ) - - -@router.delete("/config") -async def delete_feishu_config( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """删除飞书配置""" - from ..models import Integration - from sqlalchemy.future import select - - if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]): - raise HTTPException(status_code=403, detail="只有管理员可以删除配置") - - try: - result = await db.execute( - select(Integration).where( - Integration.tenant_id == tenant_id, - Integration.type == 'feishu' - ) - ) - integration = result.scalar_one_or_none() - - if integration: - integration.is_active = False - await db.commit() - manager = get_feishu_long_connection_manager() - if manager: - await manager.restart() - - return {'status': 'success', 'message': '飞书配置已删除'} - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"删除配置失败: {str(e)}" - ) - - -@router.get("/long-connection/status") -async def feishu_long_connection_status( - current_user: User = Depends(get_current_user), -): - """查询飞书长连接状态。""" - manager = get_feishu_long_connection_manager() - if not manager: - return { - "status": "error", - "message": "Feishu 长连接管理器未初始化", - "mode": "long_connection", - "data": None, - } - return { - "status": "success", - "mode": "long_connection", - "data": manager.status(), - } - - -@router.post("/long-connection/restart") -async def restart_feishu_long_connection( - current_user: User = Depends(get_current_user), -): - """手动重启飞书长连接。""" - if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]): - raise HTTPException(status_code=403, detail="只有管理员可以重启飞书长连接") - - manager = get_feishu_long_connection_manager() - if not manager: - raise HTTPException(status_code=500, detail="Feishu 长连接管理器未初始化") - - await manager.restart() - return { - "status": "success", - "message": "Feishu 长连接已重启", - "mode": "long_connection", - "data": manager.status(), - } - -# ==================== Message Endpoints ==================== - -@router.post("/send-message") -async def send_feishu_message( - request: FeishuSendMessageRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 发送文本消息到飞书群 - - 用于发送简报、通知等 - """ - try: - service = get_feishu_service(db) - await service.load_config(tenant_id) - - result = await service.send_message_to_group( - chat_id=request.chat_id, - message=request.message - ) - - return { - 'status': 'success', - 'result': result - } - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"发送消息失败: {str(e)}" - ) - - -@router.post("/send-card") -async def send_feishu_card( - request: FeishuSendCardRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 发送富文本卡片到飞书群 - - 用于发送格式化的每日简报、风险预警等 - """ - try: - service = get_feishu_service(db) - await service.load_config(tenant_id) - - result = await service.send_rich_card( - chat_id=request.chat_id, - title=request.title, - content=request.content - ) - - return { - 'status': 'success', - 'result': result - } - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"发送卡片失败: {str(e)}" - ) - - -# ==================== Test Endpoints ==================== - -@router.post("/test-connection") -async def test_feishu_connection( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """测试飞书连接是否正常""" - try: - service = get_feishu_service(db) - await service.load_config(tenant_id) - manager = get_feishu_long_connection_manager() - - if not service.is_configured(): - return { - 'status': 'error', - 'message': '飞书未配置' - } - - # 尝试获取 tenant_access_token 测试连接 - try: - access_token = await service._get_tenant_access_token() - event_mode = "long_connection" - long_status = manager.status() if manager else None - - if not long_status: - return { - 'status': 'error', - 'message': '长连接管理器未初始化', - 'event_mode': event_mode, - } - if not long_status.get("sdk_available"): - return { - 'status': 'error', - 'message': '长连接 SDK 未安装(缺少 lark-oapi)', - 'event_mode': event_mode, - 'long_connection': long_status, - } - if not long_status.get("thread_alive"): - message, detail = _build_long_connection_failure(long_status) - return { - 'status': 'error', - 'message': message, - 'event_mode': event_mode, - 'long_connection': long_status, - 'detail': detail, - } - - return { - 'status': 'success', - 'message': '飞书连接正常', - 'has_token': bool(access_token), - 'mode': 'long_connection', - 'event_mode': event_mode, - 'long_connection': long_status, - } - except Exception as token_error: - return { - 'status': 'error', - 'message': f'获取 Token 失败: {str(token_error)}', - 'hint': '请检查 App ID 和 App Secret 是否正确' - } - - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"测试连接失败: {str(e)}" - ) +__all__ = ["router"] diff --git a/backend/app/api/feishu_api/__init__.py b/backend/app/api/feishu_api/__init__.py new file mode 100644 index 00000000..515f12f8 --- /dev/null +++ b/backend/app/api/feishu_api/__init__.py @@ -0,0 +1,5 @@ +"""Feishu API module package.""" + +from .router import router + +__all__ = ["router"] diff --git a/backend/app/api/feishu_api/config_routes.py b/backend/app/api/feishu_api/config_routes.py new file mode 100644 index 00000000..86f0641b --- /dev/null +++ b/backend/app/api/feishu_api/config_routes.py @@ -0,0 +1,129 @@ +"""Feishu configuration routes.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from ...database import get_db +from ...dependencies.auth import get_current_user +from ...dependencies.tenant import get_tenant_id +from ...models import Integration, User +from ...services.auth import has_any_role +from ...services.feishu_integration import get_feishu_service +from ...services.feishu_long_connection import get_feishu_long_connection_manager +from .schemas import FeishuConfigRequest + +router = APIRouter() + + +@router.post("/config") +async def save_feishu_config( + request: FeishuConfigRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + """ + 保存飞书配置 + + 配置信息说明: + - app_id: 飞书应用的 App ID,从飞书开放平台获取 + - app_secret: 飞书应用的 App Secret + + 获取步骤: + 1. 访问 https://open.feishu.cn/ + 2. 创建企业自建应用 + 3. 在「凭证与基础信息」页面获取 App ID 和 App Secret + 4. 添加权限:im:message, im:message:group_at_msg, im:chat + 5. 订阅事件:接收消息 v1 (message.receive_v1) + """ + if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]): + raise HTTPException(status_code=403, detail="只有管理员可以配置飞书集成") + + try: + event_mode = "long_connection" + service = get_feishu_service(db) + await service.save_config( + app_id=request.app_id, + app_secret=request.app_secret, + encrypt_key="", + verification_token="", + event_mode=event_mode, + tenant_id=tenant_id, + ) + manager = get_feishu_long_connection_manager() + if manager: + await manager.restart() + + next_steps = [ + "1. 飞书应用事件订阅至少包含:接收消息 v1 (message.receive_v1)", + "2. 当前模式:long_connection", + "3. 后端已自动重启飞书长连接", + ] + + return { + "status": "success", + "message": "飞书配置已保存,并已切换到长连接模式", + "next_steps": next_steps, + } + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=f"保存配置失败: {str(exc)}") + + +@router.get("/config") +async def get_feishu_config( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + """获取飞书配置状态。管理员可获取完整配置用于回填修改表单。""" + try: + service = get_feishu_service(db) + await service.load_config(tenant_id) + manager = get_feishu_long_connection_manager() + can_view_secrets = has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]) + + return { + "is_configured": service.is_configured(), + "app_id": service.app_id, + "app_secret": service.app_secret if can_view_secrets else None, + "event_mode": "long_connection", + "long_connection": manager.status() if manager else None, + } + except Exception as exc: + raise HTTPException(status_code=500, detail=f"获取配置失败: {str(exc)}") + + +@router.delete("/config") +async def delete_feishu_config( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + """删除飞书配置。""" + if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]): + raise HTTPException(status_code=403, detail="只有管理员可以删除配置") + + try: + result = await db.execute( + select(Integration).where( + Integration.tenant_id == tenant_id, + Integration.type == "feishu", + ) + ) + integration = result.scalar_one_or_none() + if integration: + integration.is_active = False + await db.commit() + + manager = get_feishu_long_connection_manager() + if manager: + await manager.restart() + + return {"status": "success", "message": "飞书配置已删除"} + except Exception as exc: + raise HTTPException(status_code=500, detail=f"删除配置失败: {str(exc)}") diff --git a/backend/app/api/feishu_api/helpers.py b/backend/app/api/feishu_api/helpers.py new file mode 100644 index 00000000..5c2e2a8d --- /dev/null +++ b/backend/app/api/feishu_api/helpers.py @@ -0,0 +1,33 @@ +"""Shared helpers for Feishu API routes.""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + + +def build_long_connection_failure(long_status: Dict[str, Any]) -> tuple[str, Optional[str]]: + code = str(long_status.get("last_error_code") or "").strip().lower() + last_error = str(long_status.get("last_error") or "").strip() + retryable = bool(long_status.get("last_error_retryable")) + if code in {"network_error", "thread_exited"} and not retryable: + return ( + "长连接自动重连次数已耗尽,请检查网络/凭证后手动重启飞书长连接", + last_error or None, + ) + if code == "auth_failed": + return ( + "长连接鉴权失败,请检查 App ID / App Secret、应用凭证是否已生效,以及飞书应用权限是否完整", + last_error or None, + ) + if code == "network_error": + return ( + "长连接预检失败,后端到飞书开放平台的网络或 TLS 证书链异常", + last_error or None, + ) + if code == "sdk_missing": + return ("长连接 SDK 未安装(缺少 lark-oapi)", None) + if code == "thread_exited": + return ("长连接已退出,系统正在等待下一次自动重连窗口", last_error or None) + if last_error: + return ("长连接未建立,请检查配置与后端日志", last_error) + return ("长连接未建立,请检查配置后重试", None) diff --git a/backend/app/api/feishu_api/long_connection_routes.py b/backend/app/api/feishu_api/long_connection_routes.py new file mode 100644 index 00000000..57b7c821 --- /dev/null +++ b/backend/app/api/feishu_api/long_connection_routes.py @@ -0,0 +1,54 @@ +"""Feishu long-connection routes.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException + +from ...dependencies.auth import get_current_user +from ...models import User +from ...services.auth import has_any_role +from ...services.feishu_long_connection import get_feishu_long_connection_manager + +router = APIRouter() + + +@router.get("/long-connection/status") +async def feishu_long_connection_status( + current_user: User = Depends(get_current_user), +): + """查询飞书长连接状态。""" + _ = current_user + manager = get_feishu_long_connection_manager() + if not manager: + return { + "status": "error", + "message": "Feishu 长连接管理器未初始化", + "mode": "long_connection", + "data": None, + } + return { + "status": "success", + "mode": "long_connection", + "data": manager.status(), + } + + +@router.post("/long-connection/restart") +async def restart_feishu_long_connection( + current_user: User = Depends(get_current_user), +): + """手动重启飞书长连接。""" + if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]): + raise HTTPException(status_code=403, detail="只有管理员可以重启飞书长连接") + + manager = get_feishu_long_connection_manager() + if not manager: + raise HTTPException(status_code=500, detail="Feishu 长连接管理器未初始化") + + await manager.restart() + return { + "status": "success", + "message": "Feishu 长连接已重启", + "mode": "long_connection", + "data": manager.status(), + } diff --git a/backend/app/api/feishu_api/message_routes.py b/backend/app/api/feishu_api/message_routes.py new file mode 100644 index 00000000..841efe29 --- /dev/null +++ b/backend/app/api/feishu_api/message_routes.py @@ -0,0 +1,76 @@ +"""Feishu message sending routes.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from ...database import get_db +from ...dependencies.auth import get_current_user +from ...dependencies.tenant import get_tenant_id +from ...models import User +from ...services.feishu_integration import get_feishu_service +from .schemas import FeishuSendCardRequest, FeishuSendMessageRequest + +router = APIRouter() + + +@router.post("/send-message") +async def send_feishu_message( + request: FeishuSendMessageRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + """ + 发送文本消息到飞书群。 + + 用于发送简报、通知等。 + """ + _ = current_user + try: + service = get_feishu_service(db) + await service.load_config(tenant_id) + result = await service.send_message_to_group( + chat_id=request.chat_id, + message=request.message, + ) + return { + "status": "success", + "result": result, + } + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"发送消息失败: {str(exc)}") + + +@router.post("/send-card") +async def send_feishu_card( + request: FeishuSendCardRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + """ + 发送富文本卡片到飞书群。 + + 用于发送格式化的每日简报、风险预警等。 + """ + _ = current_user + try: + service = get_feishu_service(db) + await service.load_config(tenant_id) + result = await service.send_rich_card( + chat_id=request.chat_id, + title=request.title, + content=request.content, + ) + return { + "status": "success", + "result": result, + } + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"发送卡片失败: {str(exc)}") diff --git a/backend/app/api/feishu_api/router.py b/backend/app/api/feishu_api/router.py new file mode 100644 index 00000000..b89f7c77 --- /dev/null +++ b/backend/app/api/feishu_api/router.py @@ -0,0 +1,16 @@ +"""Aggregate Feishu API routes.""" + +from __future__ import annotations + +from fastapi import APIRouter + +from .config_routes import router as config_router +from .long_connection_routes import router as long_connection_router +from .message_routes import router as message_router +from .test_routes import router as test_router + +router = APIRouter(prefix="/feishu", tags=["Feishu Integration"]) +router.include_router(config_router) +router.include_router(long_connection_router) +router.include_router(message_router) +router.include_router(test_router) diff --git a/backend/app/api/feishu_api/schemas.py b/backend/app/api/feishu_api/schemas.py new file mode 100644 index 00000000..3dc3681a --- /dev/null +++ b/backend/app/api/feishu_api/schemas.py @@ -0,0 +1,32 @@ +"""Schemas for Feishu API endpoints.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class FeishuConfigRequest(BaseModel): + """飞书配置请求。""" + + app_id: str = Field(..., description="飞书 App ID") + app_secret: str = Field(..., description="飞书 App Secret") + encrypt_key: Optional[str] = Field(None, description="已废弃字段,保留仅为兼容旧客户端") + verification_token: Optional[str] = Field(None, description="已废弃字段,保留仅为兼容旧客户端") + event_mode: str = Field("long_connection", description="事件接收模式,现仅支持 long_connection") + + +class FeishuSendMessageRequest(BaseModel): + """发送消息请求。""" + + chat_id: str = Field(..., description="群聊 ID") + message: str = Field(..., description="消息内容") + + +class FeishuSendCardRequest(BaseModel): + """发送卡片请求。""" + + chat_id: str = Field(..., description="群聊 ID") + title: str = Field(..., description="卡片标题") + content: List[Dict[str, Any]] = Field(..., description="卡片内容") diff --git a/backend/app/api/feishu_api/test_routes.py b/backend/app/api/feishu_api/test_routes.py new file mode 100644 index 00000000..e2aca228 --- /dev/null +++ b/backend/app/api/feishu_api/test_routes.py @@ -0,0 +1,82 @@ +"""Feishu connection test routes.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from ...database import get_db +from ...dependencies.auth import get_current_user +from ...dependencies.tenant import get_tenant_id +from ...models import User +from ...services.feishu_integration import get_feishu_service +from ...services.feishu_long_connection import get_feishu_long_connection_manager +from .helpers import build_long_connection_failure + +router = APIRouter() + + +@router.post("/test-connection") +async def test_feishu_connection( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + """测试飞书连接是否正常。""" + _ = current_user + try: + service = get_feishu_service(db) + await service.load_config(tenant_id) + manager = get_feishu_long_connection_manager() + + if not service.is_configured(): + return { + "status": "error", + "message": "飞书未配置", + } + + try: + access_token = await service._get_tenant_access_token() + event_mode = "long_connection" + long_status = manager.status() if manager else None + + if not long_status: + return { + "status": "error", + "message": "长连接管理器未初始化", + "event_mode": event_mode, + } + if not long_status.get("sdk_available"): + return { + "status": "error", + "message": "长连接 SDK 未安装(缺少 lark-oapi)", + "event_mode": event_mode, + "long_connection": long_status, + } + if not long_status.get("thread_alive"): + message, detail = build_long_connection_failure(long_status) + return { + "status": "error", + "message": message, + "event_mode": event_mode, + "long_connection": long_status, + "detail": detail, + } + + return { + "status": "success", + "message": "飞书连接正常", + "has_token": bool(access_token), + "mode": "long_connection", + "event_mode": event_mode, + "long_connection": long_status, + } + except Exception as token_error: + return { + "status": "error", + "message": f"获取 Token 失败: {str(token_error)}", + "hint": "请检查 App ID 和 App Secret 是否正确", + } + + except Exception as exc: + raise HTTPException(status_code=500, detail=f"测试连接失败: {str(exc)}") diff --git a/backend/app/api/model_capability.py b/backend/app/api/model_capability.py index 7c788788..eb3ed6db 100644 --- a/backend/app/api/model_capability.py +++ b/backend/app/api/model_capability.py @@ -1,491 +1,5 @@ -"""模型能力矩阵 API""" +from __future__ import annotations -from typing import Optional - -from fastapi import APIRouter, Depends, Query -from fastapi.responses import JSONResponse -from sqlalchemy import and_, select -from sqlalchemy.ext.asyncio import AsyncSession - -from ..core.response import ErrorCode, error_response, success_response -from ..database import get_db -from ..dependencies import get_current_user, get_tenant_id -from ..models import Experiment, TruthNode, User -from ..schemas.model_capability import ( - CostOptimizationAnalyzeRequest, - ExperimentSyncRequest, - ExperimentSyncReviewRequest, - ExperimentSyncRollbackRequest, - GenerateMigrationTasksRequest, - LifecycleImpactRequest, - ValidateMatchRequest, -) -from ..services.ai_service import get_ai_service -from ..services.cost_optimization_service import CostOptimizationService -from ..services.experiment_sync_service import ExperimentSyncService -from ..services.model_capability_service import ModelCapabilityService -from ..services.model_lifecycle_service import ModelLifecycleService - -router = APIRouter(prefix="/model-capability", tags=["model-capability"]) - - -def _build_service(db: AsyncSession) -> ModelCapabilityService: - return ModelCapabilityService(db=db, ai_service=get_ai_service()) - - -@router.post("/validate-match") -async def validate_model_requirement_match( - request: ValidateMatchRequest, - current_user: User = Depends(get_current_user), - tenant_id: int = Depends(get_tenant_id), - db: AsyncSession = Depends(get_db), -): - """需求-模型匹配校验""" - _ = current_user - service = _build_service(db) - try: - data = await service.validate_model_requirement_match( - truth_node_id=request.truth_node_id, - model_version_id=request.model_version_id, - tenant_id=tenant_id, - project_id=request.project_id, - ) - return success_response(data=data) - except ValueError as exc: - return JSONResponse( - status_code=404, - content=error_response(ErrorCode.NOT_FOUND, str(exc)), - ) - except Exception as exc: - return JSONResponse( - status_code=500, - content=error_response(ErrorCode.INTERNAL_ERROR, f"匹配校验失败: {exc}"), - ) - - -@router.get("/capability-gaps") -async def list_capability_gaps( - status: Optional[str] = Query(default=None), - severity: Optional[str] = Query(default=None), - limit: int = Query(default=100, ge=1, le=500), - current_user: User = Depends(get_current_user), - tenant_id: int = Depends(get_tenant_id), - db: AsyncSession = Depends(get_db), -): - """列出能力差距预警""" - _ = current_user - service = _build_service(db) - try: - data = await service.list_capability_gaps( - tenant_id=tenant_id, - status=status, - severity=severity, - limit=limit, - ) - return success_response(data=data) - except Exception as exc: - return JSONResponse( - status_code=500, - content=error_response(ErrorCode.INTERNAL_ERROR, f"查询能力差距失败: {exc}"), - ) - - -@router.get("/cost-tracking/{truth_node_id}") -async def get_cost_tracking( - truth_node_id: int, - period: str = Query(default="weekly", pattern="^(daily|weekly|monthly|project)$"), - current_user: User = Depends(get_current_user), - tenant_id: int = Depends(get_tenant_id), - db: AsyncSession = Depends(get_db), -): - """查询成本跟踪与 ROI""" - _ = current_user - service = _build_service(db) - try: - data = await service.get_cost_tracking( - truth_node_id=truth_node_id, - period=period, - tenant_id=tenant_id, - ) - return success_response(data=data) - except Exception as exc: - return JSONResponse( - status_code=500, - content=error_response(ErrorCode.INTERNAL_ERROR, f"查询成本跟踪失败: {exc}"), - ) - - -@router.get("/cost-optimization/{truth_node_id}") -async def get_cost_optimization( - truth_node_id: int, - period: str = Query(default="weekly", pattern="^(daily|weekly|monthly|project)$"), - current_user: User = Depends(get_current_user), - tenant_id: int = Depends(get_tenant_id), - db: AsyncSession = Depends(get_db), -): - """查询成本优化建议(必要时自动触发一次计算)""" - _ = current_user - service = _build_service(db) - try: - tracking = await service.get_cost_tracking( - truth_node_id=truth_node_id, - period=period, - tenant_id=tenant_id, - ) - - optimization = tracking.get("optimization_suggestions") or {} - if not optimization: - computed = await service.track_cost_and_roi( - truth_node_id=truth_node_id, - period=period, - tenant_id=tenant_id, - ) - optimization = computed.get("optimization_suggestions", {}) - tracking = await service.get_cost_tracking( - truth_node_id=truth_node_id, - period=period, - tenant_id=tenant_id, - ) - - return success_response( - data={ - "truth_node_id": truth_node_id, - "period": period, - "optimization_suggestions": optimization, - "roi_score": tracking.get("roi_score"), - "actual_cost_usd": tracking.get("actual_cost_usd", 0.0), - } - ) - except Exception as exc: - return JSONResponse( - status_code=500, - content=error_response(ErrorCode.INTERNAL_ERROR, f"查询成本优化失败: {exc}"), - ) - - -@router.post("/lifecycle/assess-impact") -async def assess_model_update_impact( - request: LifecycleImpactRequest, - current_user: User = Depends(get_current_user), - tenant_id: int = Depends(get_tenant_id), - db: AsyncSession = Depends(get_db), -): - """评估模型升级对现有需求集合的影响。""" - _ = current_user - service = ModelLifecycleService(db) - try: - data = await service.assess_model_update_impact( - old_model_version_id=request.old_model_version_id, - new_model_version_id=request.new_model_version_id, - tenant_id=tenant_id, - ) - return success_response(data=data) - except ValueError as exc: - return JSONResponse( - status_code=404, - content=error_response(ErrorCode.NOT_FOUND, str(exc)), - ) - except Exception as exc: - return JSONResponse( - status_code=500, - content=error_response(ErrorCode.INTERNAL_ERROR, f"影响评估失败: {exc}"), - ) - - -@router.get("/lifecycle/impacts") -async def list_lifecycle_impacts( - limit: int = Query(default=50, ge=1, le=200), - current_user: User = Depends(get_current_user), - tenant_id: int = Depends(get_tenant_id), - db: AsyncSession = Depends(get_db), -): - """列出模型生命周期影响评估记录。""" - _ = current_user - service = ModelLifecycleService(db) - try: - data = await service.list_impacts(tenant_id=tenant_id, limit=limit) - return success_response(data=data) - except Exception as exc: - return JSONResponse( - status_code=500, - content=error_response(ErrorCode.INTERNAL_ERROR, f"查询影响评估失败: {exc}"), - ) - - -@router.post("/lifecycle/{impact_id}/migration-tasks") -async def create_migration_tasks( - impact_id: int, - request: GenerateMigrationTasksRequest, - current_user: User = Depends(get_current_user), - tenant_id: int = Depends(get_tenant_id), - db: AsyncSession = Depends(get_db), -): - """基于影响评估自动生成迁移任务建议。""" - _ = current_user - service = ModelLifecycleService(db) - try: - data = await service.generate_migration_tasks( - impact_id=impact_id, - tenant_id=tenant_id, - max_tasks=request.max_tasks, - ) - return success_response(data=data) - except ValueError as exc: - return JSONResponse( - status_code=404, - content=error_response(ErrorCode.NOT_FOUND, str(exc)), - ) - except Exception as exc: - return JSONResponse( - status_code=500, - content=error_response(ErrorCode.INTERNAL_ERROR, f"生成迁移任务失败: {exc}"), - ) - - -@router.post("/experiment-sync") -async def create_experiment_sync( - request: ExperimentSyncRequest, - current_user: User = Depends(get_current_user), - tenant_id: int = Depends(get_tenant_id), - db: AsyncSession = Depends(get_db), -): - """创建实验结果同步记录,满足阈值时自动同步。""" - _ = current_user - service = ExperimentSyncService(db) - try: - data = await service.auto_sync_experiment_result( - experiment_id=request.experiment_id, - truth_node_id=request.truth_node_id, - tenant_id=tenant_id, - project_id=request.project_id, - sync_type=request.sync_type, - auto_apply_threshold=request.auto_apply_threshold, - ) - return success_response(data=data) - except ValueError as exc: - return JSONResponse( - status_code=404, - content=error_response(ErrorCode.NOT_FOUND, str(exc)), - ) - except Exception as exc: - return JSONResponse( - status_code=500, - content=error_response(ErrorCode.INTERNAL_ERROR, f"创建同步失败: {exc}"), - ) - - -@router.get("/experiment-sync") -async def list_experiment_sync_records( - truth_node_id: Optional[int] = Query(default=None), - limit: int = Query(default=50, ge=1, le=200), - current_user: User = Depends(get_current_user), - tenant_id: int = Depends(get_tenant_id), - db: AsyncSession = Depends(get_db), -): - """查询实验同步记录。""" - _ = current_user - service = ExperimentSyncService(db) - try: - data = await service.list_sync_records( - tenant_id=tenant_id, - truth_node_id=truth_node_id, - limit=limit, - ) - return success_response(data=data) - except Exception as exc: - return JSONResponse( - status_code=500, - content=error_response(ErrorCode.INTERNAL_ERROR, f"查询同步记录失败: {exc}"), - ) - - -@router.post("/experiment-sync/{sync_id}/review") -async def review_experiment_sync( - sync_id: int, - request: ExperimentSyncReviewRequest, - current_user: User = Depends(get_current_user), - tenant_id: int = Depends(get_tenant_id), - db: AsyncSession = Depends(get_db), -): - """人工审核实验同步记录。""" - service = ExperimentSyncService(db) - try: - data = await service.review_sync( - sync_id=sync_id, - tenant_id=tenant_id, - reviewer_id=current_user.id, - action=request.action, - review_notes=request.review_notes, - ) - return success_response(data=data) - except ValueError as exc: - return JSONResponse( - status_code=400, - content=error_response(ErrorCode.BAD_REQUEST, str(exc)), - ) - except Exception as exc: - return JSONResponse( - status_code=500, - content=error_response(ErrorCode.INTERNAL_ERROR, f"审核同步失败: {exc}"), - ) - - -@router.post("/experiment-sync/{sync_id}/rollback") -async def rollback_experiment_sync( - sync_id: int, - request: ExperimentSyncRollbackRequest, - current_user: User = Depends(get_current_user), - tenant_id: int = Depends(get_tenant_id), - db: AsyncSession = Depends(get_db), -): - """回滚已应用的实验同步。""" - service = ExperimentSyncService(db) - try: - data = await service.rollback_sync( - sync_id=sync_id, - tenant_id=tenant_id, - reviewer_id=current_user.id, - rollback_notes=request.rollback_notes, - ) - return success_response(data=data) - except ValueError as exc: - return JSONResponse( - status_code=400, - content=error_response(ErrorCode.BAD_REQUEST, str(exc)), - ) - except Exception as exc: - return JSONResponse( - status_code=500, - content=error_response(ErrorCode.INTERNAL_ERROR, f"回滚同步失败: {exc}"), - ) - - -@router.post("/cost-optimization/analyze") -async def analyze_cost_optimization( - request: CostOptimizationAnalyzeRequest, - current_user: User = Depends(get_current_user), - tenant_id: int = Depends(get_tenant_id), - db: AsyncSession = Depends(get_db), -): - """生成并持久化成本优化建议。""" - _ = current_user - service = CostOptimizationService(db) - try: - data = await service.analyze_cost_optimization( - truth_node_id=request.truth_node_id, - tenant_id=tenant_id, - period=request.period, - project_id=request.project_id, - ) - return success_response(data=data) - except ValueError as exc: - return JSONResponse( - status_code=400, - content=error_response(ErrorCode.BAD_REQUEST, str(exc)), - ) - except Exception as exc: - return JSONResponse( - status_code=500, - content=error_response(ErrorCode.INTERNAL_ERROR, f"成本优化分析失败: {exc}"), - ) - - -@router.get("/context/projects/{project_id}/truth-nodes") -async def list_project_truth_nodes( - project_id: int, - current_user: User = Depends(get_current_user), - tenant_id: int = Depends(get_tenant_id), - db: AsyncSession = Depends(get_db), -): - """获取项目上下文中的宪章列表,用于前端选择器。""" - _ = current_user - result = await db.execute( - select(TruthNode).where( - and_( - TruthNode.tenant_id == tenant_id, - TruthNode.project_id == project_id, - ) - ).order_by(TruthNode.created_at.desc()) - ) - nodes = result.scalars().all() - return success_response( - data={ - "items": [ - { - "id": n.id, - "title": n.title, - "status": n.status, - "node_type": n.node_type, - } - for n in nodes - ] - } - ) - - -@router.get("/context/projects/{project_id}/experiments") -async def list_project_experiments( - project_id: int, - current_user: User = Depends(get_current_user), - tenant_id: int = Depends(get_tenant_id), - db: AsyncSession = Depends(get_db), -): - """获取项目上下文中的实验列表(基于 linked_context_nodes 关联)。""" - _ = current_user - truth_ids_result = await db.execute( - select(TruthNode.id).where( - and_( - TruthNode.tenant_id == tenant_id, - TruthNode.project_id == project_id, - ) - ) - ) - truth_ids = {int(row[0]) for row in truth_ids_result.all()} - if not truth_ids: - return success_response(data={"items": []}) - - exp_result = await db.execute( - select(Experiment) - .where(Experiment.tenant_id == tenant_id) - .order_by(Experiment.created_at.desc()) - ) - experiments = exp_result.scalars().all() - - def _linked_ids(raw) -> set[int]: - ids = set() - if isinstance(raw, list): - for x in raw: - try: - ids.add(int(x)) - except (TypeError, ValueError): - continue - return ids - - items = [] - for e in experiments: - if e.project_id is not None: - if int(e.project_id) != int(project_id): - continue - items.append( - { - "id": e.id, - "title": e.title or f"Experiment {e.id}", - "status": e.status, - "target_metric": e.target_metric, - } - ) - continue - - # 迁移兼容:project_id 为空时,回退到 linked_context_nodes 交叉判断。 - if _linked_ids(e.linked_context_nodes).isdisjoint(truth_ids): - continue - items.append( - { - "id": e.id, - "title": e.title or f"Experiment {e.id}", - "status": e.status, - "target_metric": e.target_metric, - } - ) - return success_response(data={"items": items}) +from .model_capability_api import router +__all__ = ["router"] diff --git a/backend/app/api/model_capability_api/__init__.py b/backend/app/api/model_capability_api/__init__.py new file mode 100644 index 00000000..46a1aace --- /dev/null +++ b/backend/app/api/model_capability_api/__init__.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from fastapi import APIRouter + +from . import routes_context, routes_experiment_sync, routes_lifecycle, routes_match_cost + +router = APIRouter(prefix="/model-capability", tags=["model-capability"]) +for _module in [routes_match_cost, routes_lifecycle, routes_experiment_sync, routes_context]: + router.include_router(_module.router) + +__all__ = ["router"] diff --git a/backend/app/api/model_capability_api/helpers.py b/backend/app/api/model_capability_api/helpers.py new file mode 100644 index 00000000..2caf280b --- /dev/null +++ b/backend/app/api/model_capability_api/helpers.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from .shared import AsyncSession, ModelCapabilityService, get_ai_service + +def _build_service(db: AsyncSession) -> ModelCapabilityService: + return ModelCapabilityService(db=db, ai_service=get_ai_service()) + diff --git a/backend/app/api/model_capability_api/routes_context.py b/backend/app/api/model_capability_api/routes_context.py new file mode 100644 index 00000000..ccdef2a1 --- /dev/null +++ b/backend/app/api/model_capability_api/routes_context.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from .shared import * + +@router.post("/cost-optimization/analyze") +async def analyze_cost_optimization( + request: CostOptimizationAnalyzeRequest, + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """生成并持久化成本优化建议。""" + _ = current_user + service = CostOptimizationService(db) + try: + data = await service.analyze_cost_optimization( + truth_node_id=request.truth_node_id, + tenant_id=tenant_id, + period=request.period, + project_id=request.project_id, + ) + return success_response(data=data) + except ValueError as exc: + return JSONResponse( + status_code=400, + content=error_response(ErrorCode.BAD_REQUEST, str(exc)), + ) + except Exception as exc: + return JSONResponse( + status_code=500, + content=error_response(ErrorCode.INTERNAL_ERROR, f"成本优化分析失败: {exc}"), + ) + +@router.get("/context/projects/{project_id}/truth-nodes") +async def list_project_truth_nodes( + project_id: int, + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """获取项目上下文中的宪章列表,用于前端选择器。""" + _ = current_user + result = await db.execute( + select(TruthNode).where( + and_( + TruthNode.tenant_id == tenant_id, + TruthNode.project_id == project_id, + ) + ).order_by(TruthNode.created_at.desc()) + ) + nodes = result.scalars().all() + return success_response( + data={ + "items": [ + { + "id": n.id, + "title": n.title, + "status": n.status, + "node_type": n.node_type, + } + for n in nodes + ] + } + ) + +@router.get("/context/projects/{project_id}/experiments") +async def list_project_experiments( + project_id: int, + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """获取项目上下文中的实验列表(基于 linked_context_nodes 关联)。""" + _ = current_user + truth_ids_result = await db.execute( + select(TruthNode.id).where( + and_( + TruthNode.tenant_id == tenant_id, + TruthNode.project_id == project_id, + ) + ) + ) + truth_ids = {int(row[0]) for row in truth_ids_result.all()} + if not truth_ids: + return success_response(data={"items": []}) + + exp_result = await db.execute( + select(Experiment) + .where(Experiment.tenant_id == tenant_id) + .order_by(Experiment.created_at.desc()) + ) + experiments = exp_result.scalars().all() + + def _linked_ids(raw) -> set[int]: + ids = set() + if isinstance(raw, list): + for x in raw: + try: + ids.add(int(x)) + except (TypeError, ValueError): + continue + return ids + + items = [] + for e in experiments: + if e.project_id is not None: + if int(e.project_id) != int(project_id): + continue + items.append( + { + "id": e.id, + "title": e.title or f"Experiment {e.id}", + "status": e.status, + "target_metric": e.target_metric, + } + ) + continue + + # 迁移兼容:project_id 为空时,回退到 linked_context_nodes 交叉判断。 + if _linked_ids(e.linked_context_nodes).isdisjoint(truth_ids): + continue + items.append( + { + "id": e.id, + "title": e.title or f"Experiment {e.id}", + "status": e.status, + "target_metric": e.target_metric, + } + ) + return success_response(data={"items": items}) + diff --git a/backend/app/api/model_capability_api/routes_experiment_sync.py b/backend/app/api/model_capability_api/routes_experiment_sync.py new file mode 100644 index 00000000..478b7a91 --- /dev/null +++ b/backend/app/api/model_capability_api/routes_experiment_sync.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from .shared import * + +@router.post("/experiment-sync") +async def create_experiment_sync( + request: ExperimentSyncRequest, + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """创建实验结果同步记录,满足阈值时自动同步。""" + _ = current_user + service = ExperimentSyncService(db) + try: + data = await service.auto_sync_experiment_result( + experiment_id=request.experiment_id, + truth_node_id=request.truth_node_id, + tenant_id=tenant_id, + project_id=request.project_id, + sync_type=request.sync_type, + auto_apply_threshold=request.auto_apply_threshold, + ) + return success_response(data=data) + except ValueError as exc: + return JSONResponse( + status_code=404, + content=error_response(ErrorCode.NOT_FOUND, str(exc)), + ) + except Exception as exc: + return JSONResponse( + status_code=500, + content=error_response(ErrorCode.INTERNAL_ERROR, f"创建同步失败: {exc}"), + ) + +@router.get("/experiment-sync") +async def list_experiment_sync_records( + truth_node_id: Optional[int] = Query(default=None), + limit: int = Query(default=50, ge=1, le=200), + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """查询实验同步记录。""" + _ = current_user + service = ExperimentSyncService(db) + try: + data = await service.list_sync_records( + tenant_id=tenant_id, + truth_node_id=truth_node_id, + limit=limit, + ) + return success_response(data=data) + except Exception as exc: + return JSONResponse( + status_code=500, + content=error_response(ErrorCode.INTERNAL_ERROR, f"查询同步记录失败: {exc}"), + ) + +@router.post("/experiment-sync/{sync_id}/review") +async def review_experiment_sync( + sync_id: int, + request: ExperimentSyncReviewRequest, + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """人工审核实验同步记录。""" + service = ExperimentSyncService(db) + try: + data = await service.review_sync( + sync_id=sync_id, + tenant_id=tenant_id, + reviewer_id=current_user.id, + action=request.action, + review_notes=request.review_notes, + ) + return success_response(data=data) + except ValueError as exc: + return JSONResponse( + status_code=400, + content=error_response(ErrorCode.BAD_REQUEST, str(exc)), + ) + except Exception as exc: + return JSONResponse( + status_code=500, + content=error_response(ErrorCode.INTERNAL_ERROR, f"审核同步失败: {exc}"), + ) + +@router.post("/experiment-sync/{sync_id}/rollback") +async def rollback_experiment_sync( + sync_id: int, + request: ExperimentSyncRollbackRequest, + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """回滚已应用的实验同步。""" + service = ExperimentSyncService(db) + try: + data = await service.rollback_sync( + sync_id=sync_id, + tenant_id=tenant_id, + reviewer_id=current_user.id, + rollback_notes=request.rollback_notes, + ) + return success_response(data=data) + except ValueError as exc: + return JSONResponse( + status_code=400, + content=error_response(ErrorCode.BAD_REQUEST, str(exc)), + ) + except Exception as exc: + return JSONResponse( + status_code=500, + content=error_response(ErrorCode.INTERNAL_ERROR, f"回滚同步失败: {exc}"), + ) + diff --git a/backend/app/api/model_capability_api/routes_lifecycle.py b/backend/app/api/model_capability_api/routes_lifecycle.py new file mode 100644 index 00000000..f6b8c56c --- /dev/null +++ b/backend/app/api/model_capability_api/routes_lifecycle.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from .shared import * + +@router.post("/lifecycle/assess-impact") +async def assess_model_update_impact( + request: LifecycleImpactRequest, + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """评估模型升级对现有需求集合的影响。""" + _ = current_user + service = ModelLifecycleService(db) + try: + data = await service.assess_model_update_impact( + old_model_version_id=request.old_model_version_id, + new_model_version_id=request.new_model_version_id, + tenant_id=tenant_id, + ) + return success_response(data=data) + except ValueError as exc: + return JSONResponse( + status_code=404, + content=error_response(ErrorCode.NOT_FOUND, str(exc)), + ) + except Exception as exc: + return JSONResponse( + status_code=500, + content=error_response(ErrorCode.INTERNAL_ERROR, f"影响评估失败: {exc}"), + ) + +@router.get("/lifecycle/impacts") +async def list_lifecycle_impacts( + limit: int = Query(default=50, ge=1, le=200), + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """列出模型生命周期影响评估记录。""" + _ = current_user + service = ModelLifecycleService(db) + try: + data = await service.list_impacts(tenant_id=tenant_id, limit=limit) + return success_response(data=data) + except Exception as exc: + return JSONResponse( + status_code=500, + content=error_response(ErrorCode.INTERNAL_ERROR, f"查询影响评估失败: {exc}"), + ) + +@router.post("/lifecycle/{impact_id}/migration-tasks") +async def create_migration_tasks( + impact_id: int, + request: GenerateMigrationTasksRequest, + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """基于影响评估自动生成迁移任务建议。""" + _ = current_user + service = ModelLifecycleService(db) + try: + data = await service.generate_migration_tasks( + impact_id=impact_id, + tenant_id=tenant_id, + max_tasks=request.max_tasks, + ) + return success_response(data=data) + except ValueError as exc: + return JSONResponse( + status_code=404, + content=error_response(ErrorCode.NOT_FOUND, str(exc)), + ) + except Exception as exc: + return JSONResponse( + status_code=500, + content=error_response(ErrorCode.INTERNAL_ERROR, f"生成迁移任务失败: {exc}"), + ) + diff --git a/backend/app/api/model_capability_api/routes_match_cost.py b/backend/app/api/model_capability_api/routes_match_cost.py new file mode 100644 index 00000000..1a1aa827 --- /dev/null +++ b/backend/app/api/model_capability_api/routes_match_cost.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from .shared import * +from .helpers import _build_service + +@router.post("/validate-match") +async def validate_model_requirement_match( + request: ValidateMatchRequest, + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """需求-模型匹配校验""" + _ = current_user + service = _build_service(db) + try: + data = await service.validate_model_requirement_match( + truth_node_id=request.truth_node_id, + model_version_id=request.model_version_id, + tenant_id=tenant_id, + project_id=request.project_id, + ) + return success_response(data=data) + except ValueError as exc: + return JSONResponse( + status_code=404, + content=error_response(ErrorCode.NOT_FOUND, str(exc)), + ) + except Exception as exc: + return JSONResponse( + status_code=500, + content=error_response(ErrorCode.INTERNAL_ERROR, f"匹配校验失败: {exc}"), + ) + +@router.get("/capability-gaps") +async def list_capability_gaps( + status: Optional[str] = Query(default=None), + severity: Optional[str] = Query(default=None), + limit: int = Query(default=100, ge=1, le=500), + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """列出能力差距预警""" + _ = current_user + service = _build_service(db) + try: + data = await service.list_capability_gaps( + tenant_id=tenant_id, + status=status, + severity=severity, + limit=limit, + ) + return success_response(data=data) + except Exception as exc: + return JSONResponse( + status_code=500, + content=error_response(ErrorCode.INTERNAL_ERROR, f"查询能力差距失败: {exc}"), + ) + +@router.get("/cost-tracking/{truth_node_id}") +async def get_cost_tracking( + truth_node_id: int, + period: str = Query(default="weekly", pattern="^(daily|weekly|monthly|project)$"), + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """查询成本跟踪与 ROI""" + _ = current_user + service = _build_service(db) + try: + data = await service.get_cost_tracking( + truth_node_id=truth_node_id, + period=period, + tenant_id=tenant_id, + ) + return success_response(data=data) + except Exception as exc: + return JSONResponse( + status_code=500, + content=error_response(ErrorCode.INTERNAL_ERROR, f"查询成本跟踪失败: {exc}"), + ) + +@router.get("/cost-optimization/{truth_node_id}") +async def get_cost_optimization( + truth_node_id: int, + period: str = Query(default="weekly", pattern="^(daily|weekly|monthly|project)$"), + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + """查询成本优化建议(必要时自动触发一次计算)""" + _ = current_user + service = _build_service(db) + try: + tracking = await service.get_cost_tracking( + truth_node_id=truth_node_id, + period=period, + tenant_id=tenant_id, + ) + + optimization = tracking.get("optimization_suggestions") or {} + if not optimization: + computed = await service.track_cost_and_roi( + truth_node_id=truth_node_id, + period=period, + tenant_id=tenant_id, + ) + optimization = computed.get("optimization_suggestions", {}) + tracking = await service.get_cost_tracking( + truth_node_id=truth_node_id, + period=period, + tenant_id=tenant_id, + ) + + return success_response( + data={ + "truth_node_id": truth_node_id, + "period": period, + "optimization_suggestions": optimization, + "roi_score": tracking.get("roi_score"), + "actual_cost_usd": tracking.get("actual_cost_usd", 0.0), + } + ) + except Exception as exc: + return JSONResponse( + status_code=500, + content=error_response(ErrorCode.INTERNAL_ERROR, f"查询成本优化失败: {exc}"), + ) + diff --git a/backend/app/api/model_capability_api/shared.py b/backend/app/api/model_capability_api/shared.py new file mode 100644 index 00000000..f2b9d88d --- /dev/null +++ b/backend/app/api/model_capability_api/shared.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from fastapi.responses import JSONResponse +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.response import ErrorCode, error_response, success_response +from ...database import get_db +from ...dependencies import get_current_user, get_tenant_id +from ...models import Experiment, TruthNode, User +from ...schemas.model_capability import ( + CostOptimizationAnalyzeRequest, + ExperimentSyncRequest, + ExperimentSyncReviewRequest, + ExperimentSyncRollbackRequest, + GenerateMigrationTasksRequest, + LifecycleImpactRequest, + ValidateMatchRequest, +) +from ...services.ai_service import get_ai_service +from ...services.cost_optimization_service import CostOptimizationService +from ...services.experiment_sync_service import ExperimentSyncService +from ...services.model_capability_service import ModelCapabilityService +from ...services.model_lifecycle_service import ModelLifecycleService + +router = APIRouter(prefix="/model-capability", tags=["model-capability"]) diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py index 10b7a2f3..5d23fbe3 100644 --- a/backend/app/api/projects.py +++ b/backend/app/api/projects.py @@ -1,686 +1,5 @@ -"""项目管理 API。""" - from __future__ import annotations -import json -from typing import Any, Optional +from .projects_api import router -from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel, Field -from sqlalchemy import and_, delete, func, select, update -from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession - -from ..database import get_db -from ..dependencies import get_current_user, get_tenant_id, require_roles -from ..models import ( - AIOrchestrationRun, - AIWorkerExecution, - Budget, - ConversationSession, - CostTracking, - Experiment, - OperationExecutionLedger, - OperationLog, - Project, - ProjectSignalLink, - Signal, - SignalSourceRegistry, - Task, - User, - UserProject, - WorkItem, -) -from ..services.ai_pmo_kernel_service import AIPMOKernelService -from ..services.context_cache import context_cache_service -from ..services.operation_log_service import log_operation -from ..services.project_member_service import ( - ProjectMemberAssignmentError, - notify_member_assignment_failure, - replace_project_members, - resolve_project_member_ids, -) -from ..utils.sequence import ensure_project_id_sequence -from ..utils.title import compact_title - -router = APIRouter(prefix="/projects", tags=["projects"]) - -DONE_STATUSES = {"done", "completed", "closed", "resolved", "archived"} - - -def _as_dict(value: object) -> dict[str, Any]: - if isinstance(value, dict): - return value - if isinstance(value, str) and value.strip(): - try: - parsed = json.loads(value) - return parsed if isinstance(parsed, dict) else {"text": value} - except (TypeError, ValueError): - return {"text": value} - return {} - - -class ProjectCreateRequest(BaseModel): - title: str - description: Optional[str] = None - status: str = "active" - domain_id: Optional[int] = None - assigned_role: Optional[str] = None - member_user_ids: list[int] = Field(default_factory=list) - - -class ProjectUpdateRequest(BaseModel): - title: Optional[str] = None - description: Optional[str] = None - status: Optional[str] = None - domain_id: Optional[int] = None - assigned_role: Optional[str] = None - - -class LinkSignalRequest(BaseModel): - link_type: Optional[str] = "related" - relevance_score: Optional[float] = 1.0 - - -class ProjectMembersAssignRequest(BaseModel): - member_user_ids: list[int] = Field(default_factory=list) - - -def _serialize_project_row(project: Project) -> dict: - return { - "id": str(project.id), - "title": project.title or project.name, - "description": project.description, - "status": project.status, - "domain_id": str(project.domain_id) if project.domain_id is not None else None, - "assigned_role": project.assigned_role, - "signal_count": int(project.signal_count or 0), - "created_at": project.created_at.isoformat() if project.created_at else None, - "updated_at": project.updated_at.isoformat() if project.updated_at else None, - } - - -async def _build_project_summary( - db: AsyncSession, - *, - tenant_id: int, - project: Project, -) -> dict: - base = _serialize_project_row(project) - - signal_count_result = await db.execute( - select(func.count(ProjectSignalLink.id)).where( - and_( - ProjectSignalLink.tenant_id == tenant_id, - ProjectSignalLink.project_id == int(project.id), - ) - ) - ) - signal_count = int(signal_count_result.scalar() or 0) - base["signal_count"] = signal_count - - task_rows = ( - await db.execute( - select(Task).where( - and_( - Task.tenant_id == tenant_id, - Task.project_id == int(project.id), - ) - ) - ) - ).scalars().all() - - total_tasks = len(task_rows) - completed_tasks = 0 - in_progress_tasks = 0 - pending_tasks = 0 - task_items: list[dict] = [] - for task in task_rows: - status_value = (task.status or "").lower() - if status_value in DONE_STATUSES: - completed_tasks += 1 - elif status_value in {"in_progress", "review", "blocked"}: - in_progress_tasks += 1 - else: - pending_tasks += 1 - - task_items.append( - { - "task_name": task.title, - "type": "task", - "status": task.status, - "signal_id": str(task.truth_node_id) if task.truth_node_id is not None else "", - } - ) - - base["progress"] = round((completed_tasks / total_tasks) * 100, 2) if total_tasks else 0.0 - base["total_tasks"] = total_tasks - base["completed_tasks"] = completed_tasks - base["in_progress_tasks"] = in_progress_tasks - base["pending_tasks"] = pending_tasks - base["tasks"] = task_items - return base - - -async def _fetch_project_member_user_ids( - db: AsyncSession, - *, - tenant_id: int, - project_id: int, -) -> list[int]: - rows = await db.execute( - select(UserProject.user_id).where( - and_( - UserProject.tenant_id == int(tenant_id), - UserProject.project_id == int(project_id), - ) - ) - ) - return sorted(int(user_id) for user_id in rows.scalars().all() if user_id is not None) - - -async def _get_project_or_404( - db: AsyncSession, - *, - tenant_id: int, - project_id: int, -) -> Project: - project = ( - await db.execute( - select(Project).where( - and_( - Project.tenant_id == tenant_id, - Project.id == project_id, - ) - ) - ) - ).scalar_one_or_none() - if not project: - raise HTTPException(status_code=404, detail="Project not found") - return project - - -async def _cleanup_project_dependencies( - db: AsyncSession, - *, - tenant_id: int, - project_id: int, -) -> None: - task_ids_subquery = ( - select(Task.id) - .where( - and_( - Task.tenant_id == tenant_id, - Task.project_id == project_id, - ) - ) - .scalar_subquery() - ) - - # 先断开任务子依赖,避免删除 tasks 时触发约束错误。 - await db.execute( - update(CostTracking) - .where( - and_( - CostTracking.tenant_id == tenant_id, - CostTracking.task_id.in_(task_ids_subquery), - ) - ) - .values(task_id=None) - ) - - # 非空外键:必须先删依赖记录。 - await db.execute( - delete(AIWorkerExecution).where( - and_( - AIWorkerExecution.tenant_id == tenant_id, - AIWorkerExecution.project_id == project_id, - ) - ) - ) - await db.execute( - delete(AIOrchestrationRun).where( - and_( - AIOrchestrationRun.tenant_id == tenant_id, - AIOrchestrationRun.project_id == project_id, - ) - ) - ) - await db.execute( - delete(SignalSourceRegistry).where( - and_( - SignalSourceRegistry.tenant_id == tenant_id, - SignalSourceRegistry.project_id == project_id, - ) - ) - ) - await db.execute( - delete(ProjectSignalLink).where( - and_( - ProjectSignalLink.tenant_id == tenant_id, - ProjectSignalLink.project_id == project_id, - ) - ) - ) - await db.execute( - delete(Task).where( - and_( - Task.tenant_id == tenant_id, - Task.project_id == project_id, - ) - ) - ) - await db.execute( - delete(UserProject).where( - and_( - UserProject.tenant_id == tenant_id, - UserProject.project_id == project_id, - ) - ) - ) - - # 可空外键:保留记录,将 project 解绑为 null。 - await db.execute( - update(WorkItem) - .where( - and_( - WorkItem.tenant_id == tenant_id, - WorkItem.project_id == project_id, - ) - ) - .values(project_id=None) - ) - await db.execute( - update(OperationLog) - .where( - and_( - OperationLog.tenant_id == tenant_id, - OperationLog.project_id == project_id, - ) - ) - .values(project_id=None) - ) - await db.execute( - update(OperationExecutionLedger) - .where( - and_( - OperationExecutionLedger.tenant_id == tenant_id, - OperationExecutionLedger.project_id == project_id, - ) - ) - .values(project_id=None) - ) - await db.execute( - update(Experiment) - .where( - and_( - Experiment.tenant_id == tenant_id, - Experiment.project_id == project_id, - ) - ) - .values(project_id=None) - ) - await db.execute( - update(Budget) - .where( - and_( - Budget.tenant_id == tenant_id, - Budget.project_id == project_id, - ) - ) - .values(project_id=None) - ) - await db.execute( - update(ConversationSession) - .where( - and_( - ConversationSession.tenant_id == tenant_id, - ConversationSession.current_project_id == project_id, - ) - ) - .values(current_project_id=None) - ) - - -@router.get("/") -async def list_projects( - summary: Optional[int] = Query(default=0), - status: Optional[str] = Query(default=None), - assigned_role: Optional[str] = Query(default=None), - tenant_id: int = Depends(get_tenant_id), - db: AsyncSession = Depends(get_db), -): - query = select(Project).where(Project.tenant_id == tenant_id) - if status: - query = query.where(Project.status == status) - if assigned_role: - query = query.where(Project.assigned_role == assigned_role) - query = query.order_by(Project.updated_at.desc(), Project.id.desc()) - projects = (await db.execute(query)).scalars().all() - - if int(summary or 0) == 1: - rows = [ - await _build_project_summary( - db, - tenant_id=tenant_id, - project=project, - ) - for project in projects - ] - return {"projects": rows} - - return [_serialize_project_row(project) for project in projects] - - -@router.get("/{project_id}") -async def get_project( - project_id: int, - tenant_id: int = Depends(get_tenant_id), - db: AsyncSession = Depends(get_db), -): - project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id) - return _serialize_project_row(project) - - -@router.post("/") -async def create_project( - body: ProjectCreateRequest, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(require_roles("BOSS", "ADMIN")), -): - tenant_id = int(current_user.tenant_id) - title = compact_title(body.title, max_len=24) - if not title: - raise HTTPException(status_code=400, detail="title required") - try: - member_user_ids = await resolve_project_member_ids( - db, - tenant_id=tenant_id, - owner_id=int(current_user.id), - assigned_role=body.assigned_role, - requested_member_user_ids=body.member_user_ids, - require_requested_members=False, - include_owner=True, - ) - except ProjectMemberAssignmentError as exc: - await notify_member_assignment_failure( - db, - tenant_id=tenant_id, - actor_user_id=int(current_user.id), - project_title=title, - reason=str(exc), - interface_path="/projects/", - commit=True, - ) - raise HTTPException(status_code=400, detail=str(exc)) from exc - - await ensure_project_id_sequence(db) - project = Project( - tenant_id=tenant_id, - name=title, - title=title, - description=body.description, - status=body.status or "active", - domain_id=body.domain_id, - assigned_role=body.assigned_role, - owner_id=int(current_user.id), - signal_count=0, - ) - db.add(project) - try: - await db.flush() - await replace_project_members( - db, - tenant_id=tenant_id, - project_id=int(project.id), - member_user_ids=member_user_ids, - ) - await log_operation( - db, - tenant_id=tenant_id, - user_id=int(current_user.id), - project_id=int(project.id), - entity_type="project", - entity_id=str(project.id), - action="project.create", - before_data=None, - after_data={ - "title": project.title, - "status": project.status, - "member_user_ids": member_user_ids, - }, - interface_path="/projects/", - caller="user", - ) - await db.commit() - except Exception: - await db.rollback() - raise - await db.refresh(project) - await context_cache_service.invalidate_system_context(tenant_id=tenant_id) - await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=int(project.id)) - return _serialize_project_row(project) - - -@router.put("/{project_id}/members") -async def assign_project_members( - project_id: int, - body: ProjectMembersAssignRequest, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(require_roles("BOSS", "ADMIN")), -): - tenant_id = int(current_user.tenant_id) - project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id) - before_member_user_ids = await _fetch_project_member_user_ids( - db, - tenant_id=tenant_id, - project_id=int(project.id), - ) - - try: - member_user_ids = await resolve_project_member_ids( - db, - tenant_id=tenant_id, - owner_id=None, - assigned_role=project.assigned_role, - requested_member_user_ids=body.member_user_ids, - require_requested_members=True, - include_owner=False, - ) - await replace_project_members( - db, - tenant_id=tenant_id, - project_id=int(project.id), - member_user_ids=member_user_ids, - ) - await log_operation( - db, - tenant_id=tenant_id, - user_id=int(current_user.id), - project_id=int(project.id), - entity_type="project", - entity_id=str(project.id), - action="project.members.assign", - before_data={"member_user_ids": before_member_user_ids}, - after_data={"member_user_ids": member_user_ids}, - interface_path=f"/projects/{project_id}/members", - caller="user", - ) - await db.commit() - except ProjectMemberAssignmentError as exc: - await db.rollback() - await notify_member_assignment_failure( - db, - tenant_id=tenant_id, - actor_user_id=int(current_user.id), - project_id=int(project.id), - project_title=project.title or project.name, - reason=str(exc), - interface_path=f"/projects/{project_id}/members", - commit=True, - ) - raise HTTPException(status_code=400, detail=str(exc)) from exc - except Exception: - await db.rollback() - raise - - await context_cache_service.invalidate_system_context(tenant_id=tenant_id) - await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=int(project.id)) - return { - "project_id": str(project.id), - "member_user_ids": [str(user_id) for user_id in member_user_ids], - } - - -@router.patch("/{project_id}") -async def update_project( - project_id: int, - body: ProjectUpdateRequest, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(require_roles("BOSS", "ADMIN")), -): - tenant_id = int(current_user.tenant_id) - project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id) - if body.title is not None: - title = compact_title(body.title, max_len=24) - if not title: - raise HTTPException(status_code=400, detail="title cannot be empty") - project.title = title - project.name = title - if body.description is not None: - project.description = body.description - if body.status is not None: - project.status = body.status - if body.domain_id is not None: - project.domain_id = body.domain_id - if body.assigned_role is not None: - project.assigned_role = body.assigned_role - await db.commit() - await db.refresh(project) - await context_cache_service.invalidate_system_context(tenant_id=tenant_id) - await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=int(project.id)) - return _serialize_project_row(project) - - -@router.delete("/{project_id}") -async def delete_project( - project_id: int, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(require_roles("BOSS", "ADMIN")), -): - tenant_id = int(current_user.tenant_id) - _ = current_user - try: - await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id) - await _cleanup_project_dependencies( - db, - tenant_id=tenant_id, - project_id=project_id, - ) - await db.execute( - delete(Project).where( - and_( - Project.tenant_id == tenant_id, - Project.id == project_id, - ) - ) - ) - await db.commit() - except IntegrityError as exc: - await db.rollback() - raise HTTPException(status_code=409, detail="Project has related records and cannot be deleted") from exc - except Exception: - await db.rollback() - raise - await context_cache_service.invalidate_system_context(tenant_id=tenant_id) - await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=project_id) - return {"message": "Project deleted"} - - -@router.post("/{project_id}/signals/{signal_id}") -async def link_signal_to_project( - project_id: int, - signal_id: int, - body: LinkSignalRequest, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(require_roles("BOSS", "ADMIN")), -): - tenant_id = int(current_user.tenant_id) - await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id) - - signal = ( - await db.execute( - select(Signal).where( - and_( - Signal.tenant_id == tenant_id, - Signal.id == signal_id, - ) - ) - ) - ).scalar_one_or_none() - if not signal: - raise HTTPException(status_code=404, detail="Signal not found") - - existing = ( - await db.execute( - select(ProjectSignalLink).where( - and_( - ProjectSignalLink.tenant_id == tenant_id, - ProjectSignalLink.project_id == project_id, - ProjectSignalLink.signal_id == signal_id, - ) - ) - ) - ).scalar_one_or_none() - if existing: - return { - "id": str(existing.id), - "project_id": str(existing.project_id), - "signal_id": str(existing.signal_id), - "link_type": existing.link_type, - "relevance_score": float(existing.relevance_score or 0.0), - "created_at": existing.created_at.isoformat() if existing.created_at else None, - } - - link = ProjectSignalLink( - tenant_id=tenant_id, - project_id=project_id, - signal_id=signal_id, - link_type=body.link_type or "related", - relevance_score=float(body.relevance_score or 1.0), - ) - db.add(link) - await db.commit() - await db.refresh(link) - await context_cache_service.invalidate_system_context(tenant_id=tenant_id) - return { - "id": str(link.id), - "project_id": str(link.project_id), - "signal_id": str(link.signal_id), - "link_type": link.link_type, - "relevance_score": float(link.relevance_score or 0.0), - "created_at": link.created_at.isoformat() if link.created_at else None, - } - - -@router.delete("/{project_id}/signals/{signal_id}") -async def unlink_signal_from_project( - project_id: int, - signal_id: int, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(require_roles("BOSS", "ADMIN")), -): - tenant_id = int(current_user.tenant_id) - _ = current_user - await db.execute( - delete(ProjectSignalLink).where( - and_( - ProjectSignalLink.tenant_id == tenant_id, - ProjectSignalLink.project_id == project_id, - ProjectSignalLink.signal_id == signal_id, - ) - ) - ) - await db.commit() - await context_cache_service.invalidate_system_context(tenant_id=tenant_id) - return {"message": "Signal unlinked"} +__all__ = ["router"] diff --git a/backend/app/api/projects_api/__init__.py b/backend/app/api/projects_api/__init__.py new file mode 100644 index 00000000..5357738b --- /dev/null +++ b/backend/app/api/projects_api/__init__.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from fastapi import APIRouter + +from . import routes_list, routes_mutation, routes_signals + +router = APIRouter(prefix="/projects", tags=["projects"]) +for _module in [routes_list, routes_mutation, routes_signals]: + router.include_router(_module.router) + +__all__ = ["router"] diff --git a/backend/app/api/projects_api/helpers.py b/backend/app/api/projects_api/helpers.py new file mode 100644 index 00000000..dc972960 --- /dev/null +++ b/backend/app/api/projects_api/helpers.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +from .shared import * + +def _as_dict(value: object) -> dict[str, Any]: + if isinstance(value, dict): + return value + if isinstance(value, str) and value.strip(): + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {"text": value} + except (TypeError, ValueError): + return {"text": value} + return {} + +def _serialize_project_row(project: Project) -> dict: + return { + "id": str(project.id), + "title": project.title or project.name, + "description": project.description, + "status": project.status, + "domain_id": str(project.domain_id) if project.domain_id is not None else None, + "assigned_role": project.assigned_role, + "signal_count": int(project.signal_count or 0), + "created_at": project.created_at.isoformat() if project.created_at else None, + "updated_at": project.updated_at.isoformat() if project.updated_at else None, + } + +async def _build_project_summary( + db: AsyncSession, + *, + tenant_id: int, + project: Project, +) -> dict: + base = _serialize_project_row(project) + + signal_count_result = await db.execute( + select(func.count(ProjectSignalLink.id)).where( + and_( + ProjectSignalLink.tenant_id == tenant_id, + ProjectSignalLink.project_id == int(project.id), + ) + ) + ) + signal_count = int(signal_count_result.scalar() or 0) + base["signal_count"] = signal_count + + task_rows = ( + await db.execute( + select(Task).where( + and_( + Task.tenant_id == tenant_id, + Task.project_id == int(project.id), + ) + ) + ) + ).scalars().all() + + total_tasks = len(task_rows) + completed_tasks = 0 + in_progress_tasks = 0 + pending_tasks = 0 + task_items: list[dict] = [] + for task in task_rows: + status_value = (task.status or "").lower() + if status_value in DONE_STATUSES: + completed_tasks += 1 + elif status_value in {"in_progress", "review", "blocked"}: + in_progress_tasks += 1 + else: + pending_tasks += 1 + + task_items.append( + { + "task_name": task.title, + "type": "task", + "status": task.status, + "signal_id": str(task.truth_node_id) if task.truth_node_id is not None else "", + } + ) + + base["progress"] = round((completed_tasks / total_tasks) * 100, 2) if total_tasks else 0.0 + base["total_tasks"] = total_tasks + base["completed_tasks"] = completed_tasks + base["in_progress_tasks"] = in_progress_tasks + base["pending_tasks"] = pending_tasks + base["tasks"] = task_items + return base + +async def _fetch_project_member_user_ids( + db: AsyncSession, + *, + tenant_id: int, + project_id: int, +) -> list[int]: + rows = await db.execute( + select(UserProject.user_id).where( + and_( + UserProject.tenant_id == int(tenant_id), + UserProject.project_id == int(project_id), + ) + ) + ) + return sorted(int(user_id) for user_id in rows.scalars().all() if user_id is not None) + +async def _get_project_or_404( + db: AsyncSession, + *, + tenant_id: int, + project_id: int, +) -> Project: + project = ( + await db.execute( + select(Project).where( + and_( + Project.tenant_id == tenant_id, + Project.id == project_id, + ) + ) + ) + ).scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + return project + +async def _cleanup_project_dependencies( + db: AsyncSession, + *, + tenant_id: int, + project_id: int, +) -> None: + task_ids_subquery = ( + select(Task.id) + .where( + and_( + Task.tenant_id == tenant_id, + Task.project_id == project_id, + ) + ) + .scalar_subquery() + ) + + # 先断开任务子依赖,避免删除 tasks 时触发约束错误。 + await db.execute( + update(CostTracking) + .where( + and_( + CostTracking.tenant_id == tenant_id, + CostTracking.task_id.in_(task_ids_subquery), + ) + ) + .values(task_id=None) + ) + + # 非空外键:必须先删依赖记录。 + await db.execute( + delete(AIWorkerExecution).where( + and_( + AIWorkerExecution.tenant_id == tenant_id, + AIWorkerExecution.project_id == project_id, + ) + ) + ) + await db.execute( + delete(AIOrchestrationRun).where( + and_( + AIOrchestrationRun.tenant_id == tenant_id, + AIOrchestrationRun.project_id == project_id, + ) + ) + ) + await db.execute( + delete(SignalSourceRegistry).where( + and_( + SignalSourceRegistry.tenant_id == tenant_id, + SignalSourceRegistry.project_id == project_id, + ) + ) + ) + await db.execute( + delete(ProjectSignalLink).where( + and_( + ProjectSignalLink.tenant_id == tenant_id, + ProjectSignalLink.project_id == project_id, + ) + ) + ) + await db.execute( + delete(Task).where( + and_( + Task.tenant_id == tenant_id, + Task.project_id == project_id, + ) + ) + ) + await db.execute( + delete(UserProject).where( + and_( + UserProject.tenant_id == tenant_id, + UserProject.project_id == project_id, + ) + ) + ) + + # 可空外键:保留记录,将 project 解绑为 null。 + await db.execute( + update(WorkItem) + .where( + and_( + WorkItem.tenant_id == tenant_id, + WorkItem.project_id == project_id, + ) + ) + .values(project_id=None) + ) + await db.execute( + update(OperationLog) + .where( + and_( + OperationLog.tenant_id == tenant_id, + OperationLog.project_id == project_id, + ) + ) + .values(project_id=None) + ) + await db.execute( + update(OperationExecutionLedger) + .where( + and_( + OperationExecutionLedger.tenant_id == tenant_id, + OperationExecutionLedger.project_id == project_id, + ) + ) + .values(project_id=None) + ) + await db.execute( + update(Experiment) + .where( + and_( + Experiment.tenant_id == tenant_id, + Experiment.project_id == project_id, + ) + ) + .values(project_id=None) + ) + await db.execute( + update(Budget) + .where( + and_( + Budget.tenant_id == tenant_id, + Budget.project_id == project_id, + ) + ) + .values(project_id=None) + ) + await db.execute( + update(ConversationSession) + .where( + and_( + ConversationSession.tenant_id == tenant_id, + ConversationSession.current_project_id == project_id, + ) + ) + .values(current_project_id=None) + ) + diff --git a/backend/app/api/projects_api/routes_list.py b/backend/app/api/projects_api/routes_list.py new file mode 100644 index 00000000..c5fd3022 --- /dev/null +++ b/backend/app/api/projects_api/routes_list.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from .shared import * +from .helpers import _build_project_summary, _get_project_or_404 + +@router.get("/") +async def list_projects( + summary: Optional[int] = Query(default=0), + status: Optional[str] = Query(default=None), + assigned_role: Optional[str] = Query(default=None), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + query = select(Project).where(Project.tenant_id == tenant_id) + if status: + query = query.where(Project.status == status) + if assigned_role: + query = query.where(Project.assigned_role == assigned_role) + query = query.order_by(Project.updated_at.desc(), Project.id.desc()) + projects = (await db.execute(query)).scalars().all() + + if int(summary or 0) == 1: + rows = [ + await _build_project_summary( + db, + tenant_id=tenant_id, + project=project, + ) + for project in projects + ] + return {"projects": rows} + + return [_serialize_project_row(project) for project in projects] + +@router.get("/{project_id}") +async def get_project( + project_id: int, + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id) + return _serialize_project_row(project) + diff --git a/backend/app/api/projects_api/routes_mutation.py b/backend/app/api/projects_api/routes_mutation.py new file mode 100644 index 00000000..304d0a7a --- /dev/null +++ b/backend/app/api/projects_api/routes_mutation.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +from .shared import * +from .helpers import _cleanup_project_dependencies, _fetch_project_member_user_ids, _get_project_or_404 +from .schemas import ProjectCreateRequest, ProjectMembersAssignRequest, ProjectUpdateRequest + +@router.post("/") +async def create_project( + body: ProjectCreateRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), +): + tenant_id = int(current_user.tenant_id) + title = compact_title(body.title, max_len=24) + if not title: + raise HTTPException(status_code=400, detail="title required") + try: + member_user_ids = await resolve_project_member_ids( + db, + tenant_id=tenant_id, + owner_id=int(current_user.id), + assigned_role=body.assigned_role, + requested_member_user_ids=body.member_user_ids, + require_requested_members=False, + include_owner=True, + ) + except ProjectMemberAssignmentError as exc: + await notify_member_assignment_failure( + db, + tenant_id=tenant_id, + actor_user_id=int(current_user.id), + project_title=title, + reason=str(exc), + interface_path="/projects/", + commit=True, + ) + raise HTTPException(status_code=400, detail=str(exc)) from exc + + await ensure_project_id_sequence(db) + project = Project( + tenant_id=tenant_id, + name=title, + title=title, + description=body.description, + status=body.status or "active", + domain_id=body.domain_id, + assigned_role=body.assigned_role, + owner_id=int(current_user.id), + signal_count=0, + ) + db.add(project) + try: + await db.flush() + await replace_project_members( + db, + tenant_id=tenant_id, + project_id=int(project.id), + member_user_ids=member_user_ids, + ) + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=int(project.id), + entity_type="project", + entity_id=str(project.id), + action="project.create", + before_data=None, + after_data={ + "title": project.title, + "status": project.status, + "member_user_ids": member_user_ids, + }, + interface_path="/projects/", + caller="user", + ) + await db.commit() + except Exception: + await db.rollback() + raise + await db.refresh(project) + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=int(project.id)) + return _serialize_project_row(project) + +@router.put("/{project_id}/members") +async def assign_project_members( + project_id: int, + body: ProjectMembersAssignRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), +): + tenant_id = int(current_user.tenant_id) + project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id) + before_member_user_ids = await _fetch_project_member_user_ids( + db, + tenant_id=tenant_id, + project_id=int(project.id), + ) + + try: + member_user_ids = await resolve_project_member_ids( + db, + tenant_id=tenant_id, + owner_id=None, + assigned_role=project.assigned_role, + requested_member_user_ids=body.member_user_ids, + require_requested_members=True, + include_owner=False, + ) + await replace_project_members( + db, + tenant_id=tenant_id, + project_id=int(project.id), + member_user_ids=member_user_ids, + ) + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=int(project.id), + entity_type="project", + entity_id=str(project.id), + action="project.members.assign", + before_data={"member_user_ids": before_member_user_ids}, + after_data={"member_user_ids": member_user_ids}, + interface_path=f"/projects/{project_id}/members", + caller="user", + ) + await db.commit() + except ProjectMemberAssignmentError as exc: + await db.rollback() + await notify_member_assignment_failure( + db, + tenant_id=tenant_id, + actor_user_id=int(current_user.id), + project_id=int(project.id), + project_title=project.title or project.name, + reason=str(exc), + interface_path=f"/projects/{project_id}/members", + commit=True, + ) + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception: + await db.rollback() + raise + + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=int(project.id)) + return { + "project_id": str(project.id), + "member_user_ids": [str(user_id) for user_id in member_user_ids], + } + +@router.patch("/{project_id}") +async def update_project( + project_id: int, + body: ProjectUpdateRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), +): + tenant_id = int(current_user.tenant_id) + project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id) + if body.title is not None: + title = compact_title(body.title, max_len=24) + if not title: + raise HTTPException(status_code=400, detail="title cannot be empty") + project.title = title + project.name = title + if body.description is not None: + project.description = body.description + if body.status is not None: + project.status = body.status + if body.domain_id is not None: + project.domain_id = body.domain_id + if body.assigned_role is not None: + project.assigned_role = body.assigned_role + await db.commit() + await db.refresh(project) + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=int(project.id)) + return _serialize_project_row(project) + +@router.delete("/{project_id}") +async def delete_project( + project_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), +): + tenant_id = int(current_user.tenant_id) + _ = current_user + try: + await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id) + await _cleanup_project_dependencies( + db, + tenant_id=tenant_id, + project_id=project_id, + ) + await db.execute( + delete(Project).where( + and_( + Project.tenant_id == tenant_id, + Project.id == project_id, + ) + ) + ) + await db.commit() + except IntegrityError as exc: + await db.rollback() + raise HTTPException(status_code=409, detail="Project has related records and cannot be deleted") from exc + except Exception: + await db.rollback() + raise + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=project_id) + return {"message": "Project deleted"} + diff --git a/backend/app/api/projects_api/routes_signals.py b/backend/app/api/projects_api/routes_signals.py new file mode 100644 index 00000000..9b7087ef --- /dev/null +++ b/backend/app/api/projects_api/routes_signals.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from .shared import * +from .helpers import _as_dict, _get_project_or_404 +from .schemas import LinkSignalRequest + +@router.post("/{project_id}/signals/{signal_id}") +async def link_signal_to_project( + project_id: int, + signal_id: int, + body: LinkSignalRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), +): + tenant_id = int(current_user.tenant_id) + await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id) + + signal = ( + await db.execute( + select(Signal).where( + and_( + Signal.tenant_id == tenant_id, + Signal.id == signal_id, + ) + ) + ) + ).scalar_one_or_none() + if not signal: + raise HTTPException(status_code=404, detail="Signal not found") + + existing = ( + await db.execute( + select(ProjectSignalLink).where( + and_( + ProjectSignalLink.tenant_id == tenant_id, + ProjectSignalLink.project_id == project_id, + ProjectSignalLink.signal_id == signal_id, + ) + ) + ) + ).scalar_one_or_none() + if existing: + return { + "id": str(existing.id), + "project_id": str(existing.project_id), + "signal_id": str(existing.signal_id), + "link_type": existing.link_type, + "relevance_score": float(existing.relevance_score or 0.0), + "created_at": existing.created_at.isoformat() if existing.created_at else None, + } + + link = ProjectSignalLink( + tenant_id=tenant_id, + project_id=project_id, + signal_id=signal_id, + link_type=body.link_type or "related", + relevance_score=float(body.relevance_score or 1.0), + ) + db.add(link) + await db.commit() + await db.refresh(link) + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + return { + "id": str(link.id), + "project_id": str(link.project_id), + "signal_id": str(link.signal_id), + "link_type": link.link_type, + "relevance_score": float(link.relevance_score or 0.0), + "created_at": link.created_at.isoformat() if link.created_at else None, + } + +@router.delete("/{project_id}/signals/{signal_id}") +async def unlink_signal_from_project( + project_id: int, + signal_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), +): + tenant_id = int(current_user.tenant_id) + _ = current_user + await db.execute( + delete(ProjectSignalLink).where( + and_( + ProjectSignalLink.tenant_id == tenant_id, + ProjectSignalLink.project_id == project_id, + ProjectSignalLink.signal_id == signal_id, + ) + ) + ) + await db.commit() + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + return {"message": "Signal unlinked"} + diff --git a/backend/app/api/projects_api/schemas.py b/backend/app/api/projects_api/schemas.py new file mode 100644 index 00000000..75d02318 --- /dev/null +++ b/backend/app/api/projects_api/schemas.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from .shared import BaseModel, Field, Optional + +class ProjectCreateRequest(BaseModel): + title: str + description: Optional[str] = None + status: str = "active" + domain_id: Optional[int] = None + assigned_role: Optional[str] = None + member_user_ids: list[int] = Field(default_factory=list) + +class ProjectUpdateRequest(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + status: Optional[str] = None + domain_id: Optional[int] = None + assigned_role: Optional[str] = None + +class LinkSignalRequest(BaseModel): + link_type: Optional[str] = "related" + relevance_score: Optional[float] = 1.0 + +class ProjectMembersAssignRequest(BaseModel): + member_user_ids: list[int] = Field(default_factory=list) + diff --git a/backend/app/api/projects_api/shared.py b/backend/app/api/projects_api/shared.py new file mode 100644 index 00000000..ba136145 --- /dev/null +++ b/backend/app/api/projects_api/shared.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import json +from typing import Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field +from sqlalchemy import and_, delete, func, select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from ...database import get_db +from ...dependencies import get_current_user, get_tenant_id, require_roles +from ...models import ( + AIOrchestrationRun, + AIWorkerExecution, + Budget, + ConversationSession, + CostTracking, + Experiment, + OperationExecutionLedger, + OperationLog, + Project, + ProjectSignalLink, + Signal, + SignalSourceRegistry, + Task, + User, + UserProject, + WorkItem, +) +from ...services.ai_pmo_kernel_service import AIPMOKernelService +from ...services.context_cache import context_cache_service +from ...services.operation_log_service import log_operation +from ...services.project_member_service import ( + ProjectMemberAssignmentError, + notify_member_assignment_failure, + replace_project_members, + resolve_project_member_ids, +) +from ...utils.sequence import ensure_project_id_sequence +from ...utils.title import compact_title + +router = APIRouter(prefix="/projects", tags=["projects"]) + +DONE_STATUSES = {"done", "completed", "closed", "resolved", "archived"} diff --git a/backend/app/api/rag.py b/backend/app/api/rag.py index 11fdc8c4..a0f0fb3a 100644 --- a/backend/app/api/rag.py +++ b/backend/app/api/rag.py @@ -1,570 +1,5 @@ -"""Adaptive RAG API。""" - from __future__ import annotations -import asyncio -import io -import re -from datetime import datetime +from .rag_api import router -from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile -from sqlalchemy.ext.asyncio import AsyncSession - -from ..core.response import success_response -from ..database import get_db -from ..dependencies.auth import get_current_user, require_roles -from ..dependencies.tenant import get_tenant_id -from ..models import User -from ..schemas.rag import ( - EntityResolutionRequest, - KnowledgeDocCreateRequest, - KnowledgeURLCreateRequest, - MultiStepSearchRequest, - QAExportQuery, - QAFeedbackCreateRequest, - QAListQuery, - QASessionListQuery, - QASessionRenameRequest, - RAGAskRequest, - SymbolSearchRequest, - SymbolSyncRequest, -) -from ..services.adaptive_rag_service import AdaptiveRAGService -from ..services.symbol_index_service import SymbolIndexService - -router = APIRouter(prefix="/rag", tags=["Adaptive RAG"]) - - -def _resolve_tenant_id(user: User, fallback_tenant_id: int) -> int: - try: - return int(getattr(user, "tenant_id") or fallback_tenant_id or 1) - except Exception: - return int(fallback_tenant_id or 1) - - -def _safe_decode_bytes(raw: bytes) -> str: - for encoding in ("utf-8", "utf-8-sig", "gb18030", "latin-1"): - try: - return raw.decode(encoding) - except Exception: - continue - return raw.decode("utf-8", errors="ignore") - - -def _extract_text_from_upload(filename: str, raw: bytes) -> tuple[str, str]: - name = str(filename or "uploaded.txt") - suffix = name.rsplit(".", 1)[-1].lower() if "." in name else "txt" - - if suffix in {"txt", "md", "markdown", "csv", "json", "py", "ts", "tsx", "js", "jsx"}: - return ("md" if suffix in {"md", "markdown"} else "txt", _safe_decode_bytes(raw)) - - if suffix == "pdf": - try: - from pypdf import PdfReader # type: ignore - except Exception as exc: - raise HTTPException(status_code=400, detail=f"PDF 解析依赖缺失: {exc}") from exc - reader = PdfReader(io.BytesIO(raw)) - parts: list[str] = [] - for idx, page in enumerate(reader.pages, start=1): - page_text = (page.extract_text() or "").strip() - if not page_text: - continue - # 写入页码标记,向量分块时会保留 page_number 元数据,便于引用来源页码。 - parts.append(f"[[PAGE:{idx}]]\n{page_text}") - text = "\n\n".join(parts) - return ("pdf", text) - - if suffix in {"docx", "doc"}: - try: - from docx import Document # type: ignore - except Exception as exc: - raise HTTPException(status_code=400, detail=f"Word 解析依赖缺失: {exc}") from exc - doc = Document(io.BytesIO(raw)) - text = "\n".join([p.text for p in doc.paragraphs if p.text]) - return ("word", text) - - raise HTTPException(status_code=400, detail=f"不支持的文件类型: .{suffix}") - - -@router.post("/ask") -async def rag_ask( - request: RAGAskRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) - result = await service.answer_question( - question=request.question, - asked_by=int(current_user.id), - asked_via=request.asked_via, - project_id=request.project_id, - source_chat_id=request.source_chat_id, - session_id=request.session_id, - force_new_session=bool(request.new_session), - thinking_mode=request.thinking_mode, - action_mode=request.action_mode, - sync_requirement=bool(request.sync_requirement), - ) - return success_response(result) - - -@router.get("/manifest") -async def rag_manifest( - project_id: int | None = Query(default=None), - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) - data = await service.get_project_manifest(project_id=project_id) - return success_response(data) - - -@router.get("/features/catalog") -async def rag_feature_catalog( - query: str | None = Query(default=None), - limit: int = Query(default=20, ge=1, le=200), - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) - data = await service.get_feature_catalog(query=query, limit=limit) - return success_response(data) - - -@router.post("/entity-resolution") -async def rag_entity_resolution( - request: EntityResolutionRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) - data = await service.entity_resolution( - question=request.question, - project_id=request.project_id, - limit=request.limit, - ) - return success_response(data) - - -@router.post("/multi-step-search") -async def rag_multi_step_search( - request: MultiStepSearchRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) - data = await service.multi_step_search( - question=request.question, - project_id=request.project_id, - source_chat_id=request.source_chat_id, - session_state=None, - ) - return success_response(data) - - -@router.post("/symbols/sync") -async def rag_symbols_sync( - request: SymbolSyncRequest, - current_user: User = Depends(require_roles("ADMIN", "BOSS")), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) - data = await service.sync_codebase_symbols( - base_dir=request.base_dir, - max_files=request.max_files, - force_reindex=request.force_reindex, - ) - return success_response(data) - - -@router.post("/symbols/search") -async def rag_symbols_search( - request: SymbolSearchRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) - data = await service.search_symbols( - query=request.query, - language=request.language, - symbol_type=request.symbol_type, - limit=request.limit, - ) - return success_response(data) - - -@router.get("/symbols/overview") -async def rag_symbols_overview( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) - data = await service.get_overview(top_n_files=10, auto_sync_if_empty=False) - return success_response(data) - - -@router.get("/symbols/{symbol_id}") -async def rag_symbol_detail( - symbol_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) - try: - data = await service.get_symbol_detail(symbol_id=symbol_id) - except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - return success_response(data) - - -@router.get("/knowledge-base/docs") -async def list_knowledge_docs( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - _ = current_user - service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) - return success_response(await service.list_knowledge_docs()) - - -@router.post("/knowledge-base/docs") -async def create_knowledge_doc( - request: KnowledgeDocCreateRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) - result = await service.create_knowledge_doc( - title=request.title, - file_type=request.file_type, - raw_content=request.content, - uploaded_by=int(current_user.id), - source_type=request.source_type, - file_url=request.file_url, - project_id=request.project_id, - ) - return success_response(result) - - -@router.post("/knowledge-base/upload") -async def upload_knowledge_doc( - file: UploadFile = File(...), - project_id: int | None = Form(default=None), - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - raw = await file.read() - if not raw: - raise HTTPException(status_code=400, detail="上传文件为空") - if len(raw) > 15 * 1024 * 1024: - raise HTTPException(status_code=400, detail="文件过大,限制 15MB") - - file_type, text = _extract_text_from_upload(file.filename or "uploaded.txt", raw) - if not str(text or "").strip(): - raise HTTPException(status_code=400, detail="无法提取有效文本内容") - - effective_tenant_id = _resolve_tenant_id(current_user, tenant_id) - service = AdaptiveRAGService(db, tenant_id=effective_tenant_id) - doc = await service.create_knowledge_doc_placeholder( - title=(file.filename or "uploaded.txt")[:255], - file_type=file_type, - uploaded_by=int(current_user.id), - source_type="file", - file_url=None, - ) - asyncio.create_task( - AdaptiveRAGService.process_knowledge_doc_content_async( - tenant_id=effective_tenant_id, - doc_id=int(doc.id), - raw_content=text, - project_id=project_id, - ) - ) - return success_response(service._serialize_doc(doc)) - - -@router.post("/knowledge-base/urls") -async def create_knowledge_url( - request: KnowledgeURLCreateRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - url = str(request.url or "").strip() - if not re.match(r"^https?://", url, flags=re.IGNORECASE): - raise HTTPException(status_code=400, detail="URL 必须以 http:// 或 https:// 开头") - - effective_tenant_id = _resolve_tenant_id(current_user, tenant_id) - service = AdaptiveRAGService(db, tenant_id=effective_tenant_id) - doc = await service.create_knowledge_doc_placeholder( - title=(request.title or url)[:255], - file_type="url", - uploaded_by=int(current_user.id), - source_type="url", - file_url=url, - ) - asyncio.create_task( - AdaptiveRAGService.refresh_url_doc_async( - tenant_id=effective_tenant_id, - doc_id=int(doc.id), - project_id=request.project_id, - ) - ) - return success_response(service._serialize_doc(doc)) - - -@router.post("/knowledge-base/docs/{doc_id}/refresh") -async def refresh_knowledge_doc( - doc_id: int, - project_id: int | None = Query(default=None), - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - effective_tenant_id = _resolve_tenant_id(current_user, tenant_id) - service = AdaptiveRAGService(db, tenant_id=effective_tenant_id) - try: - doc = await service.get_knowledge_doc(doc_id=doc_id) - if str(doc.get("source_type") or "").lower() != "url": - raise HTTPException(status_code=400, detail="仅 URL 类型条目支持 refresh") - marked = await service.mark_doc_processing(doc_id=doc_id) - except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - - asyncio.create_task( - AdaptiveRAGService.refresh_url_doc_async( - tenant_id=effective_tenant_id, - doc_id=doc_id, - project_id=project_id, - ) - ) - return success_response(marked) - - -@router.delete("/knowledge-base/docs/{doc_id}") -async def delete_knowledge_doc( - doc_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - _ = current_user - service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) - ok = await service.delete_knowledge_doc(doc_id=doc_id) - if not ok: - raise HTTPException(status_code=404, detail="文档不存在") - return success_response({"deleted": True, "doc_id": doc_id}) - - -@router.post("/qa-records/{qa_id}/feedback") -async def submit_qa_feedback( - qa_id: int, - request: QAFeedbackCreateRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) - try: - result = await service.submit_feedback( - qa_id=qa_id, - rating=request.rating, - helpfulness=request.helpfulness, - error_types=request.error_types, - correct_answer=request.correct_answer, - submitted_by=int(current_user.id), - ) - except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - return success_response(result) - - -@router.get("/qa-records") -async def list_qa_records( - project_id: int | None = Query(default=None), - date_from: datetime | None = Query(default=None), - date_to: datetime | None = Query(default=None), - rating: str | None = Query(default=None), - page: int = Query(default=1, ge=1), - page_size: int = Query(default=20, ge=1, le=100), - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - _ = current_user - query = QAListQuery( - project_id=project_id, - date_from=date_from, - date_to=date_to, - rating=rating, - page=page, - page_size=page_size, - ) - service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) - result = await service.list_qa_records( - project_id=query.project_id, - date_from=query.date_from, - date_to=query.date_to, - rating=query.rating, - page=query.page, - page_size=query.page_size, - ) - return success_response(result) - - -@router.get("/qa-sessions") -async def list_qa_sessions( - date_from: datetime | None = Query(default=None), - date_to: datetime | None = Query(default=None), - page: int = Query(default=1, ge=1), - page_size: int = Query(default=20, ge=1, le=100), - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - query = QASessionListQuery( - date_from=date_from, - date_to=date_to, - page=page, - page_size=page_size, - ) - service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) - result = await service.list_qa_sessions( - user_id=int(current_user.id), - date_from=query.date_from, - date_to=query.date_to, - page=query.page, - page_size=query.page_size, - ) - return success_response(result) - - -@router.get("/qa-sessions/{session_id}") -async def qa_session_detail( - session_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) - try: - result = await service.get_qa_session_detail(session_id=session_id, user_id=int(current_user.id)) - except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - return success_response(result) - - -@router.get("/qa-sessions/{session_id}/state") -async def qa_session_state_replay( - session_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) - try: - result = await service.get_session_state_replay( - session_id=session_id, - user_id=int(current_user.id), - ) - except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - return success_response(result) - - -@router.patch("/qa-sessions/{session_id}/title") -async def rename_qa_session( - session_id: int, - request: QASessionRenameRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) - try: - result = await service.rename_qa_session( - session_id=session_id, - user_id=int(current_user.id), - title=request.title, - ) - except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - return success_response(result) - - -@router.delete("/qa-sessions/{session_id}") -async def delete_qa_session( - session_id: int, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) - deleted = await service.delete_qa_session(session_id=session_id, user_id=int(current_user.id)) - if not deleted: - raise HTTPException(status_code=404, detail="qa session 不存在") - return success_response({"deleted": True, "session_id": session_id}) - - -@router.get("/qa-records/stats") -async def qa_records_stats( - project_id: int | None = Query(default=None), - date_from: datetime | None = Query(default=None), - date_to: datetime | None = Query(default=None), - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) - result = await service.get_qa_stats( - project_id=project_id, - date_from=date_from, - date_to=date_to, - ) - return success_response(result) - - -@router.get("/qa-records/export") -async def export_qa_records_jsonl( - include_unrated: bool = Query(default=False), - project_id: int | None = Query(default=None), - rating: str | None = Query(default=None), - domain: str | None = Query(default=None), - date_from: datetime | None = Query(default=None), - date_to: datetime | None = Query(default=None), - current_user: User = Depends(require_roles("ADMIN", "BOSS")), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - _ = current_user - query = QAExportQuery( - include_unrated=include_unrated, - project_id=project_id, - rating=rating, - domain=domain, - date_from=date_from, - date_to=date_to, - ) - - service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) - payload = await service.export_qa_jsonl( - include_unrated=query.include_unrated, - project_id=query.project_id, - rating=query.rating, - domain=query.domain, - date_from=query.date_from, - date_to=query.date_to, - ) - return Response(content=payload, media_type="application/x-ndjson") +__all__ = ["router"] diff --git a/backend/app/api/rag_api/__init__.py b/backend/app/api/rag_api/__init__.py new file mode 100644 index 00000000..48bf177c --- /dev/null +++ b/backend/app/api/rag_api/__init__.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from fastapi import APIRouter + +from . import routes_knowledge, routes_qa, routes_query + +router = APIRouter(prefix="/rag", tags=["Adaptive RAG"]) +for _module in [routes_query, routes_knowledge, routes_qa]: + router.include_router(_module.router) + +__all__ = ["router"] diff --git a/backend/app/api/rag_api/helpers.py b/backend/app/api/rag_api/helpers.py new file mode 100644 index 00000000..abc16da7 --- /dev/null +++ b/backend/app/api/rag_api/helpers.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from .shared import * + +def _resolve_tenant_id(user: User, fallback_tenant_id: int) -> int: + try: + return int(getattr(user, "tenant_id") or fallback_tenant_id or 1) + except Exception: + return int(fallback_tenant_id or 1) + +def _safe_decode_bytes(raw: bytes) -> str: + for encoding in ("utf-8", "utf-8-sig", "gb18030", "latin-1"): + try: + return raw.decode(encoding) + except Exception: + continue + return raw.decode("utf-8", errors="ignore") + +def _extract_text_from_upload(filename: str, raw: bytes) -> tuple[str, str]: + name = str(filename or "uploaded.txt") + suffix = name.rsplit(".", 1)[-1].lower() if "." in name else "txt" + + if suffix in {"txt", "md", "markdown", "csv", "json", "py", "ts", "tsx", "js", "jsx"}: + return ("md" if suffix in {"md", "markdown"} else "txt", _safe_decode_bytes(raw)) + + if suffix == "pdf": + try: + from pypdf import PdfReader # type: ignore + except Exception as exc: + raise HTTPException(status_code=400, detail=f"PDF 解析依赖缺失: {exc}") from exc + reader = PdfReader(io.BytesIO(raw)) + parts: list[str] = [] + for idx, page in enumerate(reader.pages, start=1): + page_text = (page.extract_text() or "").strip() + if not page_text: + continue + # 写入页码标记,向量分块时会保留 page_number 元数据,便于引用来源页码。 + parts.append(f"[[PAGE:{idx}]]\n{page_text}") + text = "\n\n".join(parts) + return ("pdf", text) + + if suffix in {"docx", "doc"}: + try: + from docx import Document # type: ignore + except Exception as exc: + raise HTTPException(status_code=400, detail=f"Word 解析依赖缺失: {exc}") from exc + doc = Document(io.BytesIO(raw)) + text = "\n".join([p.text for p in doc.paragraphs if p.text]) + return ("word", text) + + raise HTTPException(status_code=400, detail=f"不支持的文件类型: .{suffix}") + diff --git a/backend/app/api/rag_api/routes_knowledge.py b/backend/app/api/rag_api/routes_knowledge.py new file mode 100644 index 00000000..340166a4 --- /dev/null +++ b/backend/app/api/rag_api/routes_knowledge.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from .shared import * +from .helpers import _extract_text_from_upload, _resolve_tenant_id + +@router.get("/knowledge-base/docs") +async def list_knowledge_docs( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _ = current_user + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + return success_response(await service.list_knowledge_docs()) + +@router.post("/knowledge-base/docs") +async def create_knowledge_doc( + request: KnowledgeDocCreateRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + result = await service.create_knowledge_doc( + title=request.title, + file_type=request.file_type, + raw_content=request.content, + uploaded_by=int(current_user.id), + source_type=request.source_type, + file_url=request.file_url, + project_id=request.project_id, + ) + return success_response(result) + +@router.post("/knowledge-base/upload") +async def upload_knowledge_doc( + file: UploadFile = File(...), + project_id: int | None = Form(default=None), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + raw = await file.read() + if not raw: + raise HTTPException(status_code=400, detail="上传文件为空") + if len(raw) > 15 * 1024 * 1024: + raise HTTPException(status_code=400, detail="文件过大,限制 15MB") + + file_type, text = _extract_text_from_upload(file.filename or "uploaded.txt", raw) + if not str(text or "").strip(): + raise HTTPException(status_code=400, detail="无法提取有效文本内容") + + effective_tenant_id = _resolve_tenant_id(current_user, tenant_id) + service = AdaptiveRAGService(db, tenant_id=effective_tenant_id) + doc = await service.create_knowledge_doc_placeholder( + title=(file.filename or "uploaded.txt")[:255], + file_type=file_type, + uploaded_by=int(current_user.id), + source_type="file", + file_url=None, + ) + asyncio.create_task( + AdaptiveRAGService.process_knowledge_doc_content_async( + tenant_id=effective_tenant_id, + doc_id=int(doc.id), + raw_content=text, + project_id=project_id, + ) + ) + return success_response(service._serialize_doc(doc)) + +@router.post("/knowledge-base/urls") +async def create_knowledge_url( + request: KnowledgeURLCreateRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + url = str(request.url or "").strip() + if not re.match(r"^https?://", url, flags=re.IGNORECASE): + raise HTTPException(status_code=400, detail="URL 必须以 http:// 或 https:// 开头") + + effective_tenant_id = _resolve_tenant_id(current_user, tenant_id) + service = AdaptiveRAGService(db, tenant_id=effective_tenant_id) + doc = await service.create_knowledge_doc_placeholder( + title=(request.title or url)[:255], + file_type="url", + uploaded_by=int(current_user.id), + source_type="url", + file_url=url, + ) + asyncio.create_task( + AdaptiveRAGService.refresh_url_doc_async( + tenant_id=effective_tenant_id, + doc_id=int(doc.id), + project_id=request.project_id, + ) + ) + return success_response(service._serialize_doc(doc)) + +@router.post("/knowledge-base/docs/{doc_id}/refresh") +async def refresh_knowledge_doc( + doc_id: int, + project_id: int | None = Query(default=None), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + effective_tenant_id = _resolve_tenant_id(current_user, tenant_id) + service = AdaptiveRAGService(db, tenant_id=effective_tenant_id) + try: + doc = await service.get_knowledge_doc(doc_id=doc_id) + if str(doc.get("source_type") or "").lower() != "url": + raise HTTPException(status_code=400, detail="仅 URL 类型条目支持 refresh") + marked = await service.mark_doc_processing(doc_id=doc_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + asyncio.create_task( + AdaptiveRAGService.refresh_url_doc_async( + tenant_id=effective_tenant_id, + doc_id=doc_id, + project_id=project_id, + ) + ) + return success_response(marked) + +@router.delete("/knowledge-base/docs/{doc_id}") +async def delete_knowledge_doc( + doc_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _ = current_user + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + ok = await service.delete_knowledge_doc(doc_id=doc_id) + if not ok: + raise HTTPException(status_code=404, detail="文档不存在") + return success_response({"deleted": True, "doc_id": doc_id}) + diff --git a/backend/app/api/rag_api/routes_qa.py b/backend/app/api/rag_api/routes_qa.py new file mode 100644 index 00000000..1bae2213 --- /dev/null +++ b/backend/app/api/rag_api/routes_qa.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +from .shared import * +from .helpers import _resolve_tenant_id + +@router.post("/qa-records/{qa_id}/feedback") +async def submit_qa_feedback( + qa_id: int, + request: QAFeedbackCreateRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + try: + result = await service.submit_feedback( + qa_id=qa_id, + rating=request.rating, + helpfulness=request.helpfulness, + error_types=request.error_types, + correct_answer=request.correct_answer, + submitted_by=int(current_user.id), + ) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return success_response(result) + +@router.get("/qa-records") +async def list_qa_records( + project_id: int | None = Query(default=None), + date_from: datetime | None = Query(default=None), + date_to: datetime | None = Query(default=None), + rating: str | None = Query(default=None), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _ = current_user + query = QAListQuery( + project_id=project_id, + date_from=date_from, + date_to=date_to, + rating=rating, + page=page, + page_size=page_size, + ) + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + result = await service.list_qa_records( + project_id=query.project_id, + date_from=query.date_from, + date_to=query.date_to, + rating=query.rating, + page=query.page, + page_size=query.page_size, + ) + return success_response(result) + +@router.get("/qa-sessions") +async def list_qa_sessions( + date_from: datetime | None = Query(default=None), + date_to: datetime | None = Query(default=None), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + query = QASessionListQuery( + date_from=date_from, + date_to=date_to, + page=page, + page_size=page_size, + ) + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + result = await service.list_qa_sessions( + user_id=int(current_user.id), + date_from=query.date_from, + date_to=query.date_to, + page=query.page, + page_size=query.page_size, + ) + return success_response(result) + +@router.get("/qa-sessions/{session_id}") +async def qa_session_detail( + session_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + try: + result = await service.get_qa_session_detail(session_id=session_id, user_id=int(current_user.id)) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return success_response(result) + +@router.get("/qa-sessions/{session_id}/state") +async def qa_session_state_replay( + session_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + try: + result = await service.get_session_state_replay( + session_id=session_id, + user_id=int(current_user.id), + ) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return success_response(result) + +@router.patch("/qa-sessions/{session_id}/title") +async def rename_qa_session( + session_id: int, + request: QASessionRenameRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + try: + result = await service.rename_qa_session( + session_id=session_id, + user_id=int(current_user.id), + title=request.title, + ) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return success_response(result) + +@router.delete("/qa-sessions/{session_id}") +async def delete_qa_session( + session_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + deleted = await service.delete_qa_session(session_id=session_id, user_id=int(current_user.id)) + if not deleted: + raise HTTPException(status_code=404, detail="qa session 不存在") + return success_response({"deleted": True, "session_id": session_id}) + +@router.get("/qa-records/stats") +async def qa_records_stats( + project_id: int | None = Query(default=None), + date_from: datetime | None = Query(default=None), + date_to: datetime | None = Query(default=None), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + result = await service.get_qa_stats( + project_id=project_id, + date_from=date_from, + date_to=date_to, + ) + return success_response(result) + +@router.get("/qa-records/export") +async def export_qa_records_jsonl( + include_unrated: bool = Query(default=False), + project_id: int | None = Query(default=None), + rating: str | None = Query(default=None), + domain: str | None = Query(default=None), + date_from: datetime | None = Query(default=None), + date_to: datetime | None = Query(default=None), + current_user: User = Depends(require_roles("ADMIN", "BOSS")), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _ = current_user + query = QAExportQuery( + include_unrated=include_unrated, + project_id=project_id, + rating=rating, + domain=domain, + date_from=date_from, + date_to=date_to, + ) + + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + payload = await service.export_qa_jsonl( + include_unrated=query.include_unrated, + project_id=query.project_id, + rating=query.rating, + domain=query.domain, + date_from=query.date_from, + date_to=query.date_to, + ) + return Response(content=payload, media_type="application/x-ndjson") + diff --git a/backend/app/api/rag_api/routes_query.py b/backend/app/api/rag_api/routes_query.py new file mode 100644 index 00000000..ea3175bc --- /dev/null +++ b/backend/app/api/rag_api/routes_query.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from .shared import * +from .helpers import _resolve_tenant_id + +@router.post("/ask") +async def rag_ask( + request: RAGAskRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + result = await service.answer_question( + question=request.question, + asked_by=int(current_user.id), + asked_via=request.asked_via, + project_id=request.project_id, + source_chat_id=request.source_chat_id, + session_id=request.session_id, + force_new_session=bool(request.new_session), + thinking_mode=request.thinking_mode, + action_mode=request.action_mode, + sync_requirement=bool(request.sync_requirement), + ) + return success_response(result) + +@router.get("/manifest") +async def rag_manifest( + project_id: int | None = Query(default=None), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + data = await service.get_project_manifest(project_id=project_id) + return success_response(data) + +@router.get("/features/catalog") +async def rag_feature_catalog( + query: str | None = Query(default=None), + limit: int = Query(default=20, ge=1, le=200), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + data = await service.get_feature_catalog(query=query, limit=limit) + return success_response(data) + +@router.post("/entity-resolution") +async def rag_entity_resolution( + request: EntityResolutionRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + data = await service.entity_resolution( + question=request.question, + project_id=request.project_id, + limit=request.limit, + ) + return success_response(data) + +@router.post("/multi-step-search") +async def rag_multi_step_search( + request: MultiStepSearchRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + data = await service.multi_step_search( + question=request.question, + project_id=request.project_id, + source_chat_id=request.source_chat_id, + session_state=None, + ) + return success_response(data) + +@router.post("/symbols/sync") +async def rag_symbols_sync( + request: SymbolSyncRequest, + current_user: User = Depends(require_roles("ADMIN", "BOSS")), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + data = await service.sync_codebase_symbols( + base_dir=request.base_dir, + max_files=request.max_files, + force_reindex=request.force_reindex, + ) + return success_response(data) + +@router.post("/symbols/search") +async def rag_symbols_search( + request: SymbolSearchRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + data = await service.search_symbols( + query=request.query, + language=request.language, + symbol_type=request.symbol_type, + limit=request.limit, + ) + return success_response(data) + +@router.get("/symbols/overview") +async def rag_symbols_overview( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + data = await service.get_overview(top_n_files=10, auto_sync_if_empty=False) + return success_response(data) + +@router.get("/symbols/{symbol_id}") +async def rag_symbol_detail( + symbol_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) + try: + data = await service.get_symbol_detail(symbol_id=symbol_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return success_response(data) + diff --git a/backend/app/api/rag_api/shared.py b/backend/app/api/rag_api/shared.py new file mode 100644 index 00000000..27413048 --- /dev/null +++ b/backend/app/api/rag_api/shared.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import asyncio +import io +import re +from datetime import datetime + +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.response import success_response +from ...database import get_db +from ...dependencies.auth import get_current_user, require_roles +from ...dependencies.tenant import get_tenant_id +from ...models import User +from ...schemas.rag import ( + EntityResolutionRequest, + KnowledgeDocCreateRequest, + KnowledgeURLCreateRequest, + MultiStepSearchRequest, + QAExportQuery, + QAFeedbackCreateRequest, + QAListQuery, + QASessionListQuery, + QASessionRenameRequest, + RAGAskRequest, + SymbolSearchRequest, + SymbolSyncRequest, +) +from ...services.adaptive_rag_service import AdaptiveRAGService +from ...services.symbol_index_service import SymbolIndexService + +router = APIRouter(prefix="/rag", tags=["Adaptive RAG"]) diff --git a/backend/app/api/signal_sources.py b/backend/app/api/signal_sources.py index 0ab97679..1d5ebafc 100644 --- a/backend/app/api/signal_sources.py +++ b/backend/app/api/signal_sources.py @@ -1,120 +1,24 @@ -"""食谱系统信号源 API +"""食谱系统信号源 API。""" + +from __future__ import annotations -功能: -1. 接收食谱系统的 webhook 请求 -2. 将食谱识别需求转换为 PMFlow 信号 -3. 自动关联到指定项目(食谱系统项目) -""" -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.ext.asyncio import AsyncSession -from pydantic import BaseModel, Field -from typing import Optional, Dict, Any import json +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..core.logging import get_logger from ..database import get_db from ..dependencies.tenant import get_tenant_id -from ..core.logging import get_logger -from ..models import Signal, ProjectSignalLink, Project +from ..models import ProjectSignalLink, Signal +from .signal_sources_models import RecipeBatchRequest, RecipeRecognitionRequest, RecipeRecognitionResponse +from .signal_sources_utils import resolve_target_project_id router = APIRouter(prefix="/signal-sources", tags=["Signal Sources"]) logger = get_logger(__name__) -RECIPE_PROJECT_KEYWORDS = ("食谱", "recipe", "sstc") - - -async def _resolve_target_project_id( - *, - db: AsyncSession, - tenant_id: int, - project_id: Optional[int], -) -> int: - """解析目标项目:优先显式 project_id,其次按关键字自动匹配。""" - from sqlalchemy import select, or_ - - if project_id is not None: - result = await db.execute( - select(Project.id).where( - Project.tenant_id == tenant_id, - Project.id == int(project_id), - ).limit(1) - ) - found = result.scalar_one_or_none() - if found is None: - raise HTTPException(status_code=404, detail=f"project_id={project_id} 不存在或无权限") - return int(found) - - keyword_conditions = [] - for keyword in RECIPE_PROJECT_KEYWORDS: - keyword_conditions.extend([ - Project.title.ilike(f"%{keyword}%"), - Project.name.ilike(f"%{keyword}%"), - ]) - - match_result = await db.execute( - select(Project.id).where( - Project.tenant_id == tenant_id, - or_(*keyword_conditions), - ).limit(1) - ) - matched = match_result.scalar_one_or_none() - if matched is not None: - return int(matched) - - # 兜底:若租户仅存在一个活跃项目,自动绑定,避免“已调用但未入库”。 - active_result = await db.execute( - select(Project.id).where( - Project.tenant_id == tenant_id, - Project.status == "active", - ).limit(2) - ) - active_ids = list(active_result.scalars().all()) - if len(active_ids) == 1: - return int(active_ids[0]) - - raise HTTPException( - status_code=422, - detail="未找到目标项目,请在请求中显式传入 project_id(或确保项目名包含 食谱/recipe/sstc)", - ) - - -# ============================================================================ -# 食谱系统信号源 Webhook -# ============================================================================ - -class RecipeRecognitionRequest(BaseModel): - """食谱识别请求""" - # 食谱信息 - food_name: str = Field(..., description="食物名称") - food_description: Optional[str] = Field(None, description="食物描述") - image_url: Optional[str] = Field(None, description="图片URL") - category: Optional[str] = Field(None, description="分类") - cuisine: Optional[str] = Field(None, description="菜系") - - # 营养信息(如果来源系统已分析) - calories: Optional[float] = Field(None, description="热量(千卡)") - protein: Optional[float] = Field(None, description="蛋白质(克)") - fat: Optional[float] = Field(None, description="脂肪(克)") - carbs: Optional[float] = Field(None, description="碳水化合物(克)") - fiber: Optional[float] = Field(None, description="膳食纤维(克)") - - # 意图 - intent: str = Field( - default="analyze", - description="意图:analyze-热量分析, save-保存食谱, recommend-推荐菜谱" - ) - - # 用户信息(可选) - user_name: Optional[str] = Field(None, description="用户名") - - -class RecipeRecognitionResponse(BaseModel): - """响应""" - success: bool - signal_id: Optional[int] = None - project_id: Optional[int] = None - message: str - details: Optional[Dict[str, Any]] = None - @router.post("/recipe/webhook", response_model=RecipeRecognitionResponse, summary="食谱系统 Webhook") async def recipe_webhook( @@ -123,47 +27,18 @@ async def recipe_webhook( db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): - """ - 接收食谱系统的识别请求 - - 用法: - - 食谱系统完成拍照识别后,调用此 API 将需求推送到 PMFlow - - PMFlow 会自动创建信号并关联到食谱系统项目 - - 请求示例: - ```json - { - "food_name": "红烧肉", - "food_description": "一块红烧肉,约200克,肥瘦相间", - "image_url": "https://example.com/image.jpg", - "category": "肉类", - "cuisine": "中式", - "calories": 350, - "protein": 20, - "fat": 25, - "carbs": 10, - "fiber": 0, - "intent": "analyze", - "user_name": "张三" - } - ``` - """ + """接收食谱系统的识别请求。""" try: - # 1. 查找目标项目 - target_project_id = await _resolve_target_project_id( + target_project_id = await resolve_target_project_id( db=db, tenant_id=tenant_id, project_id=project_id, ) - - # 2. 构建信号内容 - content_parts = [] - content_parts.append(f"食物:{request.food_name}") - + + content_parts = [f"食物:{request.food_name}"] if request.food_description: content_parts.append(f"描述:{request.food_description}") - - # 营养信息 + if request.calories or request.protein or request.fat or request.carbs or request.fiber: nutrition = [] if request.calories: @@ -177,33 +52,30 @@ async def recipe_webhook( if request.fiber: nutrition.append(f"纤维 {request.fiber}g") content_parts.append("营养:" + ", ".join(nutrition)) - + if request.category: content_parts.append(f"分类:{request.category}") if request.cuisine: content_parts.append(f"菜系:{request.cuisine}") content_parts.append(f"意图:{request.intent}") - + content = "\n".join(content_parts) - - # 3. 构建标题 + intent_text = { "analyze": "热量分析", "save": "保存食谱", - "recommend": "推荐菜谱" + "recommend": "推荐菜谱", }.get(request.intent, request.intent) - + title = f"🍽️ 食谱{intent_text}:{request.food_name}" - - # 4. 构建摘要 + summary = f"食物:{request.food_name}" if request.calories: summary += f"\n热量:{request.calories} kcal" if request.category: summary += f"\n分类:{request.category}" summary += f"\n意图:{intent_text}" - - # 5. 构建信号元数据 + signal_metadata = { "source": "recipe_system", "intent": request.intent, @@ -219,8 +91,7 @@ async def recipe_webhook( "image_url": request.image_url, "user_name": request.user_name, } - - # 6. 创建信号 + signal = Signal( tenant_id=tenant_id, source="recipe_system", @@ -234,21 +105,20 @@ async def recipe_webhook( processed=False, strategic_analysis=json.dumps(signal_metadata, ensure_ascii=False), ) - + db.add(signal) - await db.flush() # 获取 signal.id - - # 7. 关联到项目 + await db.flush() + link = ProjectSignalLink( tenant_id=tenant_id, project_id=target_project_id, signal_id=signal.id, ) db.add(link) - + await db.commit() await db.refresh(signal) - + return RecipeRecognitionResponse( success=True, signal_id=signal.id, @@ -259,22 +129,22 @@ async def recipe_webhook( "project_id": target_project_id, "title": title, "status": signal.status, - } + }, ) - + except HTTPException: await db.rollback() raise - except Exception as e: + except Exception as exc: await db.rollback() logger.exception( "recipe_webhook_create_signal_failed", tenant_id=tenant_id, project_id=project_id, food_name=request.food_name, - error=str(e), + error=str(exc), ) - raise HTTPException(status_code=500, detail=f"创建信号失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"创建信号失败: {str(exc)}") @router.get("/recipe/status/{signal_id}", summary="查询食谱信号状态") @@ -284,8 +154,6 @@ async def get_recipe_signal_status( tenant_id: int = Depends(get_tenant_id), ): """查询食谱识别信号的处理状态""" - from sqlalchemy import select - result = await db.execute( select(Signal).where( Signal.id == signal_id, @@ -294,10 +162,10 @@ async def get_recipe_signal_status( ) ) signal = result.scalar_one_or_none() - + if not signal: raise HTTPException(status_code=404, detail="信号不存在") - + return { "signal_id": signal.id, "title": signal.title, @@ -307,15 +175,6 @@ async def get_recipe_signal_status( } -# ============================================================================ -# 批量导入食谱记录 -# ============================================================================ - -class RecipeBatchRequest(BaseModel): - """批量导入请求""" - recipes: list[RecipeRecognitionRequest] = Field(..., description="食谱列表") - - @router.post("/recipe/batch", response_model=RecipeRecognitionResponse, summary="批量导入食谱") async def recipe_batch_import( request: RecipeBatchRequest, @@ -325,21 +184,20 @@ async def recipe_batch_import( ): """批量导入食谱记录,每个都会创建信号""" try: - target_project_id = await _resolve_target_project_id( + target_project_id = await resolve_target_project_id( db=db, tenant_id=tenant_id, project_id=project_id, ) - + signal_ids = [] for recipe in request.recipes: - # 构建内容 content = f"食物:{recipe.food_name}" if recipe.calories: content += f"\n热量:{recipe.calories} kcal" - + title = f"🍽️ 食谱记录:{recipe.food_name}" - + metadata = { "source": "recipe_system", "intent": recipe.intent, @@ -348,9 +206,9 @@ async def recipe_batch_import( "protein": recipe.protein, "fat": recipe.fat, "carbs": recipe.carbs, - } + }, } - + signal = Signal( tenant_id=tenant_id, source="recipe_system", @@ -361,13 +219,12 @@ async def recipe_batch_import( priority="low", impact_score=0.3, status="待处理", - processed=True, # 批量导入的视为已处理 + processed=True, strategic_analysis=json.dumps(metadata), ) db.add(signal) await db.flush() - - # 关联项目 + link = ProjectSignalLink( tenant_id=tenant_id, project_id=target_project_id, @@ -375,27 +232,27 @@ async def recipe_batch_import( ) db.add(link) signal_ids.append(signal.id) - + await db.commit() - + return RecipeRecognitionResponse( success=True, - signal_id=len(signal_ids), # 返回数量 + signal_id=len(signal_ids), project_id=target_project_id, message=f"成功导入 {len(signal_ids)} 条食谱记录", - details={"signal_count": len(signal_ids)} + details={"signal_count": len(signal_ids)}, ) - + except HTTPException: await db.rollback() raise - except Exception as e: + except Exception as exc: await db.rollback() logger.exception( "recipe_batch_import_failed", tenant_id=tenant_id, project_id=project_id, recipe_count=len(request.recipes), - error=str(e), + error=str(exc), ) - raise HTTPException(status_code=500, detail=f"批量导入失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"批量导入失败: {str(exc)}") diff --git a/backend/app/api/signal_sources_models.py b/backend/app/api/signal_sources_models.py new file mode 100644 index 00000000..9284315d --- /dev/null +++ b/backend/app/api/signal_sources_models.py @@ -0,0 +1,46 @@ +"""Schemas for signal source APIs.""" + +from __future__ import annotations + +from typing import Any, Optional + +from pydantic import BaseModel, Field + + +class RecipeRecognitionRequest(BaseModel): + """食谱识别请求""" + + food_name: str = Field(..., description="食物名称") + food_description: Optional[str] = Field(None, description="食物描述") + image_url: Optional[str] = Field(None, description="图片URL") + category: Optional[str] = Field(None, description="分类") + cuisine: Optional[str] = Field(None, description="菜系") + + calories: Optional[float] = Field(None, description="热量(千卡)") + protein: Optional[float] = Field(None, description="蛋白质(克)") + fat: Optional[float] = Field(None, description="脂肪(克)") + carbs: Optional[float] = Field(None, description="碳水化合物(克)") + fiber: Optional[float] = Field(None, description="膳食纤维(克)") + + intent: str = Field( + default="analyze", + description="意图:analyze-热量分析, save-保存食谱, recommend-推荐菜谱", + ) + + user_name: Optional[str] = Field(None, description="用户名") + + +class RecipeRecognitionResponse(BaseModel): + """响应""" + + success: bool + signal_id: Optional[int] = None + project_id: Optional[int] = None + message: str + details: Optional[dict[str, Any]] = None + + +class RecipeBatchRequest(BaseModel): + """批量导入请求""" + + recipes: list[RecipeRecognitionRequest] = Field(..., description="食谱列表") diff --git a/backend/app/api/signal_sources_utils.py b/backend/app/api/signal_sources_utils.py new file mode 100644 index 00000000..e5fc7be9 --- /dev/null +++ b/backend/app/api/signal_sources_utils.py @@ -0,0 +1,67 @@ +"""Shared helper functions for signal source APIs.""" + +from __future__ import annotations + +from typing import Optional + +from fastapi import HTTPException +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import Project + +RECIPE_PROJECT_KEYWORDS = ("食谱", "recipe", "sstc") + + +async def resolve_target_project_id( + *, + db: AsyncSession, + tenant_id: int, + project_id: Optional[int], +) -> int: + """解析目标项目:优先显式 project_id,其次按关键字自动匹配。""" + if project_id is not None: + result = await db.execute( + select(Project.id).where( + Project.tenant_id == tenant_id, + Project.id == int(project_id), + ).limit(1) + ) + found = result.scalar_one_or_none() + if found is None: + raise HTTPException(status_code=404, detail=f"project_id={project_id} 不存在或无权限") + return int(found) + + keyword_conditions = [] + for keyword in RECIPE_PROJECT_KEYWORDS: + keyword_conditions.extend( + [ + Project.title.ilike(f"%{keyword}%"), + Project.name.ilike(f"%{keyword}%"), + ] + ) + + match_result = await db.execute( + select(Project.id).where( + Project.tenant_id == tenant_id, + or_(*keyword_conditions), + ).limit(1) + ) + matched = match_result.scalar_one_or_none() + if matched is not None: + return int(matched) + + active_result = await db.execute( + select(Project.id).where( + Project.tenant_id == tenant_id, + Project.status == "active", + ).limit(2) + ) + active_ids = list(active_result.scalars().all()) + if len(active_ids) == 1: + return int(active_ids[0]) + + raise HTTPException( + status_code=422, + detail="未找到目标项目,请在请求中显式传入 project_id(或确保项目名包含 食谱/recipe/sstc)", + ) diff --git a/backend/app/api/signals.py b/backend/app/api/signals.py index 28b9ae64..4c358a62 100644 --- a/backend/app/api/signals.py +++ b/backend/app/api/signals.py @@ -1,462 +1,7 @@ -"""信号相关 API 路由""" +"""信号相关 API 路由。""" -import json -from typing import List, Optional +from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Query -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select +from .signals_api import router -from ..database import get_db -from ..dependencies.tenant import get_tenant_id -from ..models import Signal, SignalFeedback, Project, ProjectSignalLink -from ..schemas import ( - SignalCreate, - SignalPriority, - classify_signal_priority, - SignalFeedbackCreate, - SignalFeedbackResponse, -) -from ..services.fetcher import fetch_hn_stories -from ..services.analyzer import analyze_impact -from ..services.llm import llm_service -from ..services.plane import plane_service -from ..services.lark import LarkService - -router = APIRouter(prefix="/signals", tags=["signals"]) -lark_service = LarkService() - - -@router.get("/") -async def list_signals( - roles: Optional[str] = Query(None, description="逗号分隔的角色列表,如 'frontend,backend'"), - db: AsyncSession = Depends(get_db), -): - """获取信号列表,支持按角色过滤""" - result = await db.execute(select(Signal).order_by(Signal.impact_score.desc())) - signals = result.scalars().all() - - if roles: - role_list = [r.strip() for r in roles.split(",")] - filtered_signals = [] - for signal in signals: - tasks = [] - if signal.tasks_generated: - try: - tasks = json.loads(signal.tasks_generated) - except Exception: - tasks = [] - - filtered_tasks = [t for t in tasks if t.get("type") in role_list] - - if filtered_tasks: - signal_dict = { - "id": str(signal.id), - "source": signal.source, - "content": signal.content, - "impact_score": signal.impact_score, - "priority": signal.priority, - "processed": signal.processed, - "strategic_analysis": signal.strategic_analysis, - "tasks_generated": signal.tasks_generated, - "tasks_filtered": filtered_tasks, - "created_at": signal.created_at.isoformat() if signal.created_at else None, - } - filtered_signals.append(signal_dict) - - return filtered_signals - - return signals - - -@router.post("/", response_model=dict) -async def create_signal(signal: SignalCreate, db: AsyncSession = Depends(get_db)): - new_signal = Signal( - source=signal.source, - content=signal.content, - impact_score=signal.impact_score, - ) - db.add(new_signal) - await db.commit() - await db.refresh(new_signal) - return {"id": str(new_signal.id), "status": "received"} - - -@router.post("/ingest", response_model=dict) -async def ingest_signal( - signal: SignalCreate, - background_tasks: BackgroundTasks, - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - """接入外部信号,按优先级处理""" - initial_priority = signal.priority or classify_signal_priority(signal.impact_score) - - new_signal = Signal( - source=signal.source, - content=signal.content, - impact_score=signal.impact_score, - priority=initial_priority.value, - ) - db.add(new_signal) - await db.commit() - await db.refresh(new_signal) - - if initial_priority == SignalPriority.LOW: - return { - "id": str(new_signal.id), - "priority": initial_priority.value, - "status": "recorded", - "message": "Low priority signal recorded without processing", - } - - async def process_signal_internal(): - try: - relevant_context = await llm_service.find_relevant_context( - signal_content=signal.content, - db_session=db, - top_k=3, - min_similarity=0.3, - tenant_id=tenant_id, - ) - - strategy_result = await llm_service.evaluate_with_voting_async( - signal_content=signal.content, - relevant_context=relevant_context, - impact_score=signal.impact_score, - db=db, - tenant_id=tenant_id, - ) - except RuntimeError: - if initial_priority == SignalPriority.CRITICAL: - raise HTTPException(status_code=403, detail="预算超限,AI 调用被阻止(hard limit)") - return { - "id": str(new_signal.id), - "priority": initial_priority.value, - "status": "blocked", - "processing_mode": "background", - "message": "预算超限,AI 调用被阻止(hard limit)", - } - - is_strategic = strategy_result.get("is_strategic", False) - decision_proposal = strategy_result.get("decision_proposal", {}) - tasks = strategy_result.get("tasks", []) - confidence = strategy_result.get("confidence", 0.0) - - final_priority = classify_signal_priority(signal.impact_score, confidence) - new_signal.priority = final_priority.value - - sync_result = None - notification_sent = False - - if final_priority in [SignalPriority.CRITICAL, SignalPriority.HIGH]: - if is_strategic and tasks: - sync_result = await plane_service.sync_tasks( - tasks, is_urgent=(final_priority == SignalPriority.CRITICAL) - ) - default_chat_id = "oc_1234567890" - await lark_service.send_strategic_alert( - default_chat_id, signal.content, decision_proposal, tasks - ) - notification_sent = True - new_signal.impact_score = 1.0 - - analysis_data = { - "decision_proposal": decision_proposal, - "competitiveness_radar": strategy_result.get("competitiveness_radar"), - "detected_tech_stacks": strategy_result.get("detected_tech_stacks", []), - "related_knowledge_nodes": strategy_result.get("related_knowledge_nodes", []), - "rag_context_used": len(relevant_context) if relevant_context else 0, - "rag_context_sources": [ctx["id"] for ctx in (relevant_context or [])], - } - - if "_voting" in strategy_result: - voting_info = strategy_result["_voting"] - analysis_data["voting"] = { - "enabled": voting_info.get("enabled"), - "models_used": voting_info.get("models_used"), - "strategic_votes": voting_info.get("strategic_votes"), - "total_votes": voting_info.get("total_votes"), - "unanimous": voting_info.get("unanimous"), - } - - new_signal.strategic_analysis = json.dumps(analysis_data) - new_signal.tasks_generated = json.dumps(tasks) - new_signal.processed = True - - await db.commit() - await db.refresh(new_signal) - - return { - "id": str(new_signal.id), - "priority": final_priority.value, - "is_strategic": is_strategic, - "confidence": confidence, - "decision_proposal": decision_proposal, - "rag_context_used": len(relevant_context) if relevant_context else 0, - "voting": strategy_result.get("_voting"), - "tasks_generated": len(tasks), - "sync_result": sync_result, - "notification_sent": notification_sent, - } - - if initial_priority == SignalPriority.CRITICAL: - result = await process_signal_internal() - result["processing_mode"] = "synchronous" - return result - else: - background_tasks.add_task(process_signal_internal) - return { - "id": str(new_signal.id), - "priority": initial_priority.value, - "status": "queued", - "processing_mode": "background", - "message": f"{initial_priority.value.upper()} priority signal queued for processing", - } - - -@router.post("/fetch", response_model=dict) -async def trigger_signal_fetch( - background_tasks: BackgroundTasks, - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - """手动触发抓取并分析信号""" - - def _stop_ollama(): - import subprocess - import signal - import os - - killed = False - try: - result = subprocess.run( - ["pgrep", "-f", "ollama"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - for pid in result.stdout.strip().split("\n"): - if pid: - try: - os.kill(int(pid), signal.SIGTERM) - killed = True - except ProcessLookupError: - pass - except Exception as e: - print(f"[AI] pgrep failed: {e}") - - if not killed: - try: - result = subprocess.run( - ["pkill", "-9", "-f", "ollama"], - capture_output=True, - timeout=5, - ) - killed = result.returncode == 0 - except Exception as e: - print(f"[AI] pkill failed: {e}") - - if killed: - print("[AI] Ollama stopped - analysis complete") - - raw_stories = await fetch_hn_stories(limit=30) - new_signals_count = 0 - processed_count = 0 - - for story in raw_stories: - result = await db.execute(select(Signal).where(Signal.content == story["content"])) - if result.scalars().first(): - continue - - impact = analyze_impact(story["content"]) - - if impact > 0.4: - initial_priority = classify_signal_priority(impact) - - new_signal = Signal( - source=story["source"], - content=story["content"], - impact_score=impact, - priority=initial_priority.value, - ) - db.add(new_signal) - await db.commit() - await db.refresh(new_signal) - new_signals_count += 1 - - if initial_priority == SignalPriority.LOW: - continue - - async def process_fetched(): - nonlocal processed_count - try: - relevant_context = await llm_service.find_relevant_context( - signal_content=story["content"], - db_session=db, - top_k=3, - min_similarity=0.3, - tenant_id=tenant_id, - ) - strategy_result = await llm_service.evaluate_with_voting_async( - signal_content=story["content"], - relevant_context=relevant_context, - impact_score=impact, - db=db, - tenant_id=tenant_id, - ) - except RuntimeError: - return - is_strategic = strategy_result.get("is_strategic", False) - decision_proposal = strategy_result.get("decision_proposal", {}) - confidence = strategy_result.get("confidence", 0.0) - final_priority = classify_signal_priority(impact, confidence) - new_signal.priority = final_priority.value - - analysis_data = { - "decision_proposal": decision_proposal, - "competitiveness_radar": strategy_result.get("competitiveness_radar"), - "detected_tech_stacks": strategy_result.get("detected_tech_stacks", []), - "related_knowledge_nodes": strategy_result.get( - "related_knowledge_nodes", [] - ), - "rag_context_used": len(relevant_context) if relevant_context else 0, - "rag_context_sources": [ctx["id"] for ctx in (relevant_context or [])], - "confidence": confidence, - "is_strategic": is_strategic, - } - if "_voting" in strategy_result: - vi = strategy_result["_voting"] - analysis_data["voting"] = { - "enabled": vi.get("enabled"), - "models_used": vi.get("models_used"), - "strategic_votes": vi.get("strategic_votes"), - "total_votes": vi.get("total_votes"), - "unanimous": vi.get("unanimous"), - } - new_signal.strategic_analysis = json.dumps(analysis_data) - new_signal.tasks_generated = json.dumps(strategy_result.get("tasks", [])) - new_signal.processed = True - await db.commit() - processed_count += 1 - - if initial_priority in [SignalPriority.MEDIUM, SignalPriority.HIGH]: - background_tasks.add_task(process_fetched) - else: - await process_fetched() - - _stop_ollama() - - return { - "status": "success", - "new_signals_count": new_signals_count, - "processed_count": processed_count, - "message": f"Fetched {new_signals_count} signals, {processed_count} analyzed", - } - - -@router.get("/{signal_id}/projects") -async def get_signal_projects( - signal_id: str, - db: AsyncSession = Depends(get_db), -): - """获取信号关联的项目""" - try: - sid = int(signal_id) - except (ValueError, TypeError): - raise HTTPException(status_code=400, detail="Invalid signal_id") - - signal_result = await db.execute(select(Signal).where(Signal.id == sid)) - if not signal_result.scalar_one_or_none(): - raise HTTPException(status_code=404, detail="Signal not found") - - result = await db.execute( - select(Project, ProjectSignalLink) - .join(ProjectSignalLink, Project.id == ProjectSignalLink.project_id) - .where(ProjectSignalLink.signal_id == sid) - ) - rows = result.all() - - return [ - { - "id": str(p.id), - "title": p.title, - "status": p.status, - "assigned_role": p.assigned_role, - "link_type": link.link_type, - "relevance_score": link.relevance_score, - } - for p, link in rows - ] - - -@router.post("/{signal_id}/feedback", response_model=SignalFeedbackResponse) -async def submit_signal_feedback( - signal_id: int, - feedback: SignalFeedbackCreate, - db: AsyncSession = Depends(get_db), -): - """提交信号反馈""" - result = await db.execute(select(Signal).where(Signal.id == signal_id)) - signal = result.scalar_one_or_none() - if not signal: - raise HTTPException(status_code=404, detail="Signal not found") - - ai_predicted_strategic = False - ai_confidence = 0.0 - if signal.strategic_analysis: - try: - analysis = json.loads(signal.strategic_analysis) - ai_predicted_strategic = analysis.get("is_strategic", signal.impact_score >= 0.9) - ai_confidence = analysis.get("confidence", 0.0) - except Exception: - ai_predicted_strategic = signal.impact_score >= 0.9 - - new_feedback = SignalFeedback( - signal_id=signal_id, - was_strategic=feedback.was_strategic, - reason=feedback.reason, - user_id=feedback.user_id, - ai_predicted_strategic=ai_predicted_strategic, - ai_confidence=ai_confidence, - ) - db.add(new_feedback) - await db.commit() - await db.refresh(new_feedback) - - is_correct = ai_predicted_strategic == feedback.was_strategic - - return SignalFeedbackResponse( - id=new_feedback.id, - signal_id=new_feedback.signal_id, - was_strategic=new_feedback.was_strategic, - ai_predicted_strategic=new_feedback.ai_predicted_strategic, - ai_confidence=new_feedback.ai_confidence, - is_correct=is_correct, - ) - - -@router.get("/{signal_id}/feedbacks", response_model=List[SignalFeedbackResponse]) -async def get_signal_feedbacks( - signal_id: int, - db: AsyncSession = Depends(get_db), -): - """获取某信号的全部反馈""" - result = await db.execute( - select(SignalFeedback) - .where(SignalFeedback.signal_id == signal_id) - .order_by(SignalFeedback.created_at.desc()) - ) - feedbacks = result.scalars().all() - - return [ - SignalFeedbackResponse( - id=f.id, - signal_id=f.signal_id, - was_strategic=f.was_strategic, - ai_predicted_strategic=f.ai_predicted_strategic, - ai_confidence=f.ai_confidence or 0.0, - is_correct=f.ai_predicted_strategic == f.was_strategic, - ) - for f in feedbacks - ] +__all__ = ["router"] diff --git a/backend/app/api/signals_api/__init__.py b/backend/app/api/signals_api/__init__.py new file mode 100644 index 00000000..803a15ad --- /dev/null +++ b/backend/app/api/signals_api/__init__.py @@ -0,0 +1,16 @@ +"""Router assembly for signal APIs.""" + +from __future__ import annotations + +from fastapi import APIRouter + +from .feedback import router as feedback_router +from .ingest_fetch import router as ingest_fetch_router +from .list_create import router as list_create_router + +router = APIRouter(prefix="/signals", tags=["signals"]) +router.include_router(list_create_router) +router.include_router(ingest_fetch_router) +router.include_router(feedback_router) + +__all__ = ["router"] diff --git a/backend/app/api/signals_api/feedback.py b/backend/app/api/signals_api/feedback.py new file mode 100644 index 00000000..55a3a35b --- /dev/null +++ b/backend/app/api/signals_api/feedback.py @@ -0,0 +1,88 @@ +"""Signal feedback endpoints.""" + +from __future__ import annotations + +import json +from typing import List + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from ...database import get_db +from ...models import Signal, SignalFeedback +from ...schemas import SignalFeedbackCreate, SignalFeedbackResponse + +router = APIRouter() + + +@router.post("/{signal_id}/feedback", response_model=SignalFeedbackResponse) +async def submit_signal_feedback( + signal_id: int, + feedback: SignalFeedbackCreate, + db: AsyncSession = Depends(get_db), +): + """提交信号反馈""" + result = await db.execute(select(Signal).where(Signal.id == signal_id)) + signal = result.scalar_one_or_none() + if not signal: + raise HTTPException(status_code=404, detail="Signal not found") + + ai_predicted_strategic = False + ai_confidence = 0.0 + if signal.strategic_analysis: + try: + analysis = json.loads(signal.strategic_analysis) + ai_predicted_strategic = analysis.get("is_strategic", signal.impact_score >= 0.9) + ai_confidence = analysis.get("confidence", 0.0) + except Exception: + ai_predicted_strategic = signal.impact_score >= 0.9 + + new_feedback = SignalFeedback( + signal_id=signal_id, + was_strategic=feedback.was_strategic, + reason=feedback.reason, + user_id=feedback.user_id, + ai_predicted_strategic=ai_predicted_strategic, + ai_confidence=ai_confidence, + ) + db.add(new_feedback) + await db.commit() + await db.refresh(new_feedback) + + is_correct = ai_predicted_strategic == feedback.was_strategic + + return SignalFeedbackResponse( + id=new_feedback.id, + signal_id=new_feedback.signal_id, + was_strategic=new_feedback.was_strategic, + ai_predicted_strategic=new_feedback.ai_predicted_strategic, + ai_confidence=new_feedback.ai_confidence, + is_correct=is_correct, + ) + + +@router.get("/{signal_id}/feedbacks", response_model=List[SignalFeedbackResponse]) +async def get_signal_feedbacks( + signal_id: int, + db: AsyncSession = Depends(get_db), +): + """获取某信号的全部反馈""" + result = await db.execute( + select(SignalFeedback) + .where(SignalFeedback.signal_id == signal_id) + .order_by(SignalFeedback.created_at.desc()) + ) + feedbacks = result.scalars().all() + + return [ + SignalFeedbackResponse( + id=feedback.id, + signal_id=feedback.signal_id, + was_strategic=feedback.was_strategic, + ai_predicted_strategic=feedback.ai_predicted_strategic, + ai_confidence=feedback.ai_confidence or 0.0, + is_correct=feedback.ai_predicted_strategic == feedback.was_strategic, + ) + for feedback in feedbacks + ] diff --git a/backend/app/api/signals_api/ingest_fetch.py b/backend/app/api/signals_api/ingest_fetch.py new file mode 100644 index 00000000..71d5ae31 --- /dev/null +++ b/backend/app/api/signals_api/ingest_fetch.py @@ -0,0 +1,297 @@ +"""Signal ingest/fetch endpoints.""" + +from __future__ import annotations + +import json + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from ...database import get_db +from ...dependencies.tenant import get_tenant_id +from ...models import Signal +from ...schemas import SignalCreate, SignalPriority, classify_signal_priority +from ...services.analyzer import analyze_impact +from ...services.fetcher import fetch_hn_stories +from ...services.lark import LarkService +from ...services.llm import llm_service +from ...services.plane import plane_service + +router = APIRouter() +lark_service = LarkService() + + +@router.post("/ingest", response_model=dict) +async def ingest_signal( + signal: SignalCreate, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + """接入外部信号,按优先级处理""" + initial_priority = signal.priority or classify_signal_priority(signal.impact_score) + + new_signal = Signal( + source=signal.source, + content=signal.content, + impact_score=signal.impact_score, + priority=initial_priority.value, + ) + db.add(new_signal) + await db.commit() + await db.refresh(new_signal) + + if initial_priority == SignalPriority.LOW: + return { + "id": str(new_signal.id), + "priority": initial_priority.value, + "status": "recorded", + "message": "Low priority signal recorded without processing", + } + + async def process_signal_internal(): + try: + relevant_context = await llm_service.find_relevant_context( + signal_content=signal.content, + db_session=db, + top_k=3, + min_similarity=0.3, + tenant_id=tenant_id, + ) + + strategy_result = await llm_service.evaluate_with_voting_async( + signal_content=signal.content, + relevant_context=relevant_context, + impact_score=signal.impact_score, + db=db, + tenant_id=tenant_id, + ) + except RuntimeError: + if initial_priority == SignalPriority.CRITICAL: + raise HTTPException(status_code=403, detail="预算超限,AI 调用被阻止(hard limit)") + return { + "id": str(new_signal.id), + "priority": initial_priority.value, + "status": "blocked", + "processing_mode": "background", + "message": "预算超限,AI 调用被阻止(hard limit)", + } + + is_strategic = strategy_result.get("is_strategic", False) + decision_proposal = strategy_result.get("decision_proposal", {}) + tasks = strategy_result.get("tasks", []) + confidence = strategy_result.get("confidence", 0.0) + + final_priority = classify_signal_priority(signal.impact_score, confidence) + new_signal.priority = final_priority.value + + sync_result = None + notification_sent = False + + if final_priority in [SignalPriority.CRITICAL, SignalPriority.HIGH]: + if is_strategic and tasks: + sync_result = await plane_service.sync_tasks( + tasks, + is_urgent=(final_priority == SignalPriority.CRITICAL), + ) + default_chat_id = "oc_1234567890" + await lark_service.send_strategic_alert( + default_chat_id, + signal.content, + decision_proposal, + tasks, + ) + notification_sent = True + new_signal.impact_score = 1.0 + + analysis_data = { + "decision_proposal": decision_proposal, + "competitiveness_radar": strategy_result.get("competitiveness_radar"), + "detected_tech_stacks": strategy_result.get("detected_tech_stacks", []), + "related_knowledge_nodes": strategy_result.get("related_knowledge_nodes", []), + "rag_context_used": len(relevant_context) if relevant_context else 0, + "rag_context_sources": [ctx["id"] for ctx in (relevant_context or [])], + } + + if "_voting" in strategy_result: + voting_info = strategy_result["_voting"] + analysis_data["voting"] = { + "enabled": voting_info.get("enabled"), + "models_used": voting_info.get("models_used"), + "strategic_votes": voting_info.get("strategic_votes"), + "total_votes": voting_info.get("total_votes"), + "unanimous": voting_info.get("unanimous"), + } + + new_signal.strategic_analysis = json.dumps(analysis_data) + new_signal.tasks_generated = json.dumps(tasks) + new_signal.processed = True + + await db.commit() + await db.refresh(new_signal) + + return { + "id": str(new_signal.id), + "priority": final_priority.value, + "is_strategic": is_strategic, + "confidence": confidence, + "decision_proposal": decision_proposal, + "rag_context_used": len(relevant_context) if relevant_context else 0, + "voting": strategy_result.get("_voting"), + "tasks_generated": len(tasks), + "sync_result": sync_result, + "notification_sent": notification_sent, + } + + if initial_priority == SignalPriority.CRITICAL: + result = await process_signal_internal() + result["processing_mode"] = "synchronous" + return result + + background_tasks.add_task(process_signal_internal) + return { + "id": str(new_signal.id), + "priority": initial_priority.value, + "status": "queued", + "processing_mode": "background", + "message": f"{initial_priority.value.upper()} priority signal queued for processing", + } + + +@router.post("/fetch", response_model=dict) +async def trigger_signal_fetch( + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + """手动触发抓取并分析信号""" + + def _stop_ollama(): + import os + import signal + import subprocess + + killed = False + try: + result = subprocess.run( + ["pgrep", "-f", "ollama"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + for pid in result.stdout.strip().split("\n"): + if pid: + try: + os.kill(int(pid), signal.SIGTERM) + killed = True + except ProcessLookupError: + pass + except Exception as exc: + print(f"[AI] pgrep failed: {exc}") + + if not killed: + try: + result = subprocess.run( + ["pkill", "-9", "-f", "ollama"], + capture_output=True, + timeout=5, + ) + killed = result.returncode == 0 + except Exception as exc: + print(f"[AI] pkill failed: {exc}") + + if killed: + print("[AI] Ollama stopped - analysis complete") + + raw_stories = await fetch_hn_stories(limit=30) + new_signals_count = 0 + processed_count = 0 + + for story in raw_stories: + result = await db.execute(select(Signal).where(Signal.content == story["content"])) + if result.scalars().first(): + continue + + impact = analyze_impact(story["content"]) + + if impact > 0.4: + initial_priority = classify_signal_priority(impact) + + new_signal = Signal( + source=story["source"], + content=story["content"], + impact_score=impact, + priority=initial_priority.value, + ) + db.add(new_signal) + await db.commit() + await db.refresh(new_signal) + new_signals_count += 1 + + if initial_priority == SignalPriority.LOW: + continue + + async def process_fetched(): + nonlocal processed_count + try: + relevant_context = await llm_service.find_relevant_context( + signal_content=story["content"], + db_session=db, + top_k=3, + min_similarity=0.3, + tenant_id=tenant_id, + ) + strategy_result = await llm_service.evaluate_with_voting_async( + signal_content=story["content"], + relevant_context=relevant_context, + impact_score=impact, + db=db, + tenant_id=tenant_id, + ) + except RuntimeError: + return + is_strategic = strategy_result.get("is_strategic", False) + decision_proposal = strategy_result.get("decision_proposal", {}) + confidence = strategy_result.get("confidence", 0.0) + final_priority = classify_signal_priority(impact, confidence) + new_signal.priority = final_priority.value + + analysis_data = { + "decision_proposal": decision_proposal, + "competitiveness_radar": strategy_result.get("competitiveness_radar"), + "detected_tech_stacks": strategy_result.get("detected_tech_stacks", []), + "related_knowledge_nodes": strategy_result.get("related_knowledge_nodes", []), + "rag_context_used": len(relevant_context) if relevant_context else 0, + "rag_context_sources": [ctx["id"] for ctx in (relevant_context or [])], + "confidence": confidence, + "is_strategic": is_strategic, + } + if "_voting" in strategy_result: + vi = strategy_result["_voting"] + analysis_data["voting"] = { + "enabled": vi.get("enabled"), + "models_used": vi.get("models_used"), + "strategic_votes": vi.get("strategic_votes"), + "total_votes": vi.get("total_votes"), + "unanimous": vi.get("unanimous"), + } + new_signal.strategic_analysis = json.dumps(analysis_data) + new_signal.tasks_generated = json.dumps(strategy_result.get("tasks", [])) + new_signal.processed = True + await db.commit() + processed_count += 1 + + if initial_priority in [SignalPriority.MEDIUM, SignalPriority.HIGH]: + background_tasks.add_task(process_fetched) + else: + await process_fetched() + + _stop_ollama() + + return { + "status": "success", + "new_signals_count": new_signals_count, + "processed_count": processed_count, + "message": f"Fetched {new_signals_count} signals, {processed_count} analyzed", + } diff --git a/backend/app/api/signals_api/list_create.py b/backend/app/api/signals_api/list_create.py new file mode 100644 index 00000000..901eca74 --- /dev/null +++ b/backend/app/api/signals_api/list_create.py @@ -0,0 +1,106 @@ +"""Signal list/create/project-link endpoints.""" + +from __future__ import annotations + +import json +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from ...database import get_db +from ...models import Project, ProjectSignalLink, Signal +from ...schemas import SignalCreate + +router = APIRouter() + + +@router.get("/") +async def list_signals( + roles: Optional[str] = Query(None, description="逗号分隔的角色列表,如 'frontend,backend'"), + db: AsyncSession = Depends(get_db), +): + """获取信号列表,支持按角色过滤""" + result = await db.execute(select(Signal).order_by(Signal.impact_score.desc())) + signals = result.scalars().all() + + if roles: + role_list = [r.strip() for r in roles.split(",")] + filtered_signals = [] + for signal in signals: + tasks = [] + if signal.tasks_generated: + try: + tasks = json.loads(signal.tasks_generated) + except Exception: + tasks = [] + + filtered_tasks = [task for task in tasks if task.get("type") in role_list] + + if filtered_tasks: + signal_dict = { + "id": str(signal.id), + "source": signal.source, + "content": signal.content, + "impact_score": signal.impact_score, + "priority": signal.priority, + "processed": signal.processed, + "strategic_analysis": signal.strategic_analysis, + "tasks_generated": signal.tasks_generated, + "tasks_filtered": filtered_tasks, + "created_at": signal.created_at.isoformat() if signal.created_at else None, + } + filtered_signals.append(signal_dict) + + return filtered_signals + + return signals + + +@router.post("/", response_model=dict) +async def create_signal(signal: SignalCreate, db: AsyncSession = Depends(get_db)): + new_signal = Signal( + source=signal.source, + content=signal.content, + impact_score=signal.impact_score, + ) + db.add(new_signal) + await db.commit() + await db.refresh(new_signal) + return {"id": str(new_signal.id), "status": "received"} + + +@router.get("/{signal_id}/projects") +async def get_signal_projects( + signal_id: str, + db: AsyncSession = Depends(get_db), +): + """获取信号关联的项目""" + try: + sid = int(signal_id) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail="Invalid signal_id") + + signal_result = await db.execute(select(Signal).where(Signal.id == sid)) + if not signal_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Signal not found") + + result = await db.execute( + select(Project, ProjectSignalLink) + .join(ProjectSignalLink, Project.id == ProjectSignalLink.project_id) + .where(ProjectSignalLink.signal_id == sid) + ) + rows = result.all() + + return [ + { + "id": str(project.id), + "title": project.title, + "status": project.status, + "assigned_role": project.assigned_role, + "link_type": link.link_type, + "relevance_score": link.relevance_score, + } + for project, link in rows + ] diff --git a/backend/app/api/skill_tags.py b/backend/app/api/skill_tags.py index 241b6d84..713713b2 100644 --- a/backend/app/api/skill_tags.py +++ b/backend/app/api/skill_tags.py @@ -2,73 +2,31 @@ from __future__ import annotations -from datetime import datetime -from typing import Optional - from fastapi import APIRouter, Depends, HTTPException, Query, status -from pydantic import BaseModel, Field from sqlalchemy import and_, delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from ..database import get_db from ..dependencies import get_current_user, get_tenant_id, require_roles from ..models import SkillTag, User, UserSkillBinding -from ..services.ai_pmo_kernel_service import AIPMOKernelService from ..services import skill_service +from ..services.ai_pmo_kernel_service import AIPMOKernelService from ..services.auth import has_any_role - from ..utils.timezone import now_cn_naive +from .skill_tags_contracts import ( + SkillTagCreateRequest, + SkillTagUpdateRequest, + UserSkillCreateRequest, + UserSkillUpdateRequest, + to_tag_payload, +) + router = APIRouter(tags=["skill-tags"]) -class SkillTagCreateRequest(BaseModel): - name: str - slug: str - category: Optional[str] = None - description: Optional[str] = None - sort_order: int = 0 - - -class SkillTagUpdateRequest(BaseModel): - name: Optional[str] = None - category: Optional[str] = None - description: Optional[str] = None - sort_order: Optional[int] = None - - -class UserSkillCreateRequest(BaseModel): - skill_tag_id: int - level: str = "beginner" - proficiency: int = Field(default=50, ge=0, le=100) - quality_score: float = 0.0 - efficiency_score: float = 0.0 - - -class UserSkillUpdateRequest(BaseModel): - level: Optional[str] = None - proficiency: Optional[int] = Field(default=None, ge=0, le=100) - quality_score: Optional[float] = None - efficiency_score: Optional[float] = None - - -def _to_tag_payload(tag: SkillTag) -> dict: - return { - "id": int(tag.id), - "tenant_id": int(tag.tenant_id), - "name": tag.name, - "slug": tag.slug, - "category": tag.category, - "description": tag.description, - "is_system": bool(tag.is_system), - "sort_order": int(tag.sort_order or 0), - "created_at": tag.created_at.isoformat() if tag.created_at else None, - "updated_at": tag.updated_at.isoformat() if tag.updated_at else None, - } - - @router.get("/skill-tags") async def list_skill_tags( - category: Optional[str] = Query(None), + category: str | None = Query(None), tenant_id: int = Depends(get_tenant_id), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), @@ -87,9 +45,9 @@ async def list_skill_tags( ) count_map = {int(skill_id): int(total) for skill_id, total in count_rows.all()} items = [] - for t in tags: - payload = _to_tag_payload(t) - payload["linked_user_count"] = count_map.get(int(t.id), 0) + for tag in tags: + payload = to_tag_payload(tag) + payload["linked_user_count"] = count_map.get(int(tag.id), 0) items.append(payload) return {"items": items, "total": len(items)} @@ -114,7 +72,7 @@ async def create_skill_tag( ) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) - return _to_tag_payload(tag) + return to_tag_payload(tag) @router.put("/skill-tags/{tag_id}") @@ -130,7 +88,6 @@ async def update_skill_tag( if not tag: raise HTTPException(status_code=404, detail="Skill tag not found") - # 系统标签仅允许改分类,禁止改名。 if tag.is_system and body.name is not None and body.name != tag.name: raise HTTPException(status_code=400, detail="System tag name cannot be changed") @@ -145,7 +102,7 @@ async def update_skill_tag( tag.updated_at = now_cn_naive() await db.commit() await db.refresh(tag) - return _to_tag_payload(tag) + return to_tag_payload(tag) @router.delete("/skill-tags/{tag_id}") @@ -258,10 +215,7 @@ async def update_user_skill( if is_self and not is_manager: if body.quality_score is not None or body.efficiency_score is not None: - raise HTTPException( - status_code=403, - detail="Members can only update own proficiency", - ) + raise HTTPException(status_code=403, detail="Members can only update own proficiency") row = ( await db.execute( diff --git a/backend/app/api/skill_tags_contracts.py b/backend/app/api/skill_tags_contracts.py new file mode 100644 index 00000000..937e09e0 --- /dev/null +++ b/backend/app/api/skill_tags_contracts.py @@ -0,0 +1,54 @@ +"""Request contracts and serializers for skill-tags API.""" + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, Field + +from ..models import SkillTag + + +class SkillTagCreateRequest(BaseModel): + name: str + slug: str + category: Optional[str] = None + description: Optional[str] = None + sort_order: int = 0 + + +class SkillTagUpdateRequest(BaseModel): + name: Optional[str] = None + category: Optional[str] = None + description: Optional[str] = None + sort_order: Optional[int] = None + + +class UserSkillCreateRequest(BaseModel): + skill_tag_id: int + level: str = "beginner" + proficiency: int = Field(default=50, ge=0, le=100) + quality_score: float = 0.0 + efficiency_score: float = 0.0 + + +class UserSkillUpdateRequest(BaseModel): + level: Optional[str] = None + proficiency: Optional[int] = Field(default=None, ge=0, le=100) + quality_score: Optional[float] = None + efficiency_score: Optional[float] = None + + +def to_tag_payload(tag: SkillTag) -> dict: + return { + "id": int(tag.id), + "tenant_id": int(tag.tenant_id), + "name": tag.name, + "slug": tag.slug, + "category": tag.category, + "description": tag.description, + "is_system": bool(tag.is_system), + "sort_order": int(tag.sort_order or 0), + "created_at": tag.created_at.isoformat() if tag.created_at else None, + "updated_at": tag.updated_at.isoformat() if tag.updated_at else None, + } diff --git a/backend/app/api/sstc.py b/backend/app/api/sstc.py index 9501166e..67ca293a 100644 --- a/backend/app/api/sstc.py +++ b/backend/app/api/sstc.py @@ -1,3991 +1,5 @@ -""" -SSTC 唯一事实链路 API - -覆盖模块: -- 项目概览/链路导航/风险预警 -- 信号源 -- 需求宪章(含对齐) -- 任务拆解与执行 -- 发布验证 -- 复盘沉淀 -""" - from __future__ import annotations -import asyncio -import json -import re -from datetime import datetime, timedelta, timezone -from io import BytesIO -from pathlib import Path -from typing import Any, Optional -from uuid import uuid4 +from .sstc_api import router -from fastapi import APIRouter, BackgroundTasks, Body, Depends, File, HTTPException, Query, UploadFile -from fastapi.responses import JSONResponse, StreamingResponse -from pydantic import BaseModel -from sqlalchemy import and_, delete, select -from sqlalchemy.ext.asyncio import AsyncSession - -from ..core.response import error_response, success_response -from ..database import get_db -from ..models import ( - AICallLog, - AlignmentRecord, - Integration, - ModelRequirementBinding, - ModelVersion, - ProjectSignalLink, - Signal, - Task, - TruthNode, - User, - UserProject, -) -from ..schemas.sstc import ( - AlignmentConfirmRequest, - AlignmentStartRequest, - CharterUpdateRequest, - ReleaseVerificationItemPatchRequest, - ReleaseCreateRequest, - ReleaseStatusPatchRequest, - ReviewArchiveRequest, - ReviewContentPatchRequest, - ReviewCreateRequest, - ReviewImprovementToTaskRequest, - WorkloadOptimizeRequest, - TaskStatusPatchRequest, -) -from ..services.llm import llm_service -from ..services.ai_service import AIService -from ..services.ai_pmo_kernel_service import AIPMOKernelService -from ..services.operation_log_service import log_operation -from ..services.auth import has_any_role -from ..services.skill_service import recommend_personnel -from ..services import skill_score_service -from ..services.sstc_service import ( - ALIGNED_STATUSES, - DONE_STATUSES, - RELEASE_VERIFIED_STATUSES, - SSTCService, -) -from ..services import workload_service -from ..services.budget_enforcement_service import BudgetEnforcementService -from ..services.feishu_integration import get_feishu_service -from ..services.adaptive_rag_service import AdaptiveRAGService -from ..services.context_cache import context_cache_service -from ..services.task_persistence import build_task_create_draft, persist_task -from ..dependencies import get_current_user, require_project_member_or_manager -from ..utils.title import compact_title - - -from ..utils.timezone import CN_TZ, now_cn_aware, now_cn_naive -router = APIRouter(tags=["sstc"]) -_FEISHU_PLACEHOLDER_RE = re.compile(r"^飞书用户\(([^)]+)\)$") -_SIGNAL_PREFIX_RE = re.compile(r"^\[(.*?)\]\s*:\s*") -_GENERIC_SIGNAL_SOURCE_TITLE_RE = re.compile( - r"(加入到信号源|加入信号源|新增(?:一个)?信号源|创建(?:一个)?信号源|添加(?:一个)?信号源)" -) - -DEFAULT_SIGNAL_AUTO_ENTER_THRESHOLD = 0.80 -DEFAULT_PROJECT_AUTO_CREATE_THRESHOLD = 0.85 -DEFAULT_PROJECT_UNCERTAIN_THRESHOLD = 0.60 - -_SIGNAL_STATUS_CODE_PENDING = "pending" -_SIGNAL_STATUS_CODE_LINKED = "linked" -_SIGNAL_STATUS_CODE_CLOSED = "closed" -_SIGNAL_STATUS_LABEL_BY_CODE = { - _SIGNAL_STATUS_CODE_PENDING: "待处理", - _SIGNAL_STATUS_CODE_LINKED: "已关联宪章", - _SIGNAL_STATUS_CODE_CLOSED: "已关闭", -} -_SIGNAL_STATUS_FILTER_ALIAS = { - "pending": _SIGNAL_STATUS_CODE_PENDING, - "待处理": _SIGNAL_STATUS_CODE_PENDING, - "linked": _SIGNAL_STATUS_CODE_LINKED, - "已关联宪章": _SIGNAL_STATUS_CODE_LINKED, - "closed": _SIGNAL_STATUS_CODE_CLOSED, - "archived": _SIGNAL_STATUS_CODE_CLOSED, - "已关闭": _SIGNAL_STATUS_CODE_CLOSED, - "已归档": _SIGNAL_STATUS_CODE_CLOSED, -} -_SIGNAL_LOCK_REASON = "信号已关联宪章,禁止重复新建" -_BEIJING_UTC_OFFSET = timedelta(hours=8) -_ALLOWED_ATTACHMENT_TYPES = {"image", "file", "media", "document"} -_SIGNAL_ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024 -_SIGNAL_ATTACHMENT_ALLOWED_EXTENSIONS = { - ".jpg": "image", - ".jpeg": "image", - ".png": "image", - ".gif": "image", - ".pdf": "document", - ".doc": "document", - ".docx": "document", - ".zip": "file", -} -_SIGNAL_ATTACHMENT_ALLOWED_MIME_TYPES = { - "image/jpeg", - "image/png", - "image/gif", - "application/pdf", - "application/msword", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "application/zip", - "application/x-zip-compressed", - "multipart/x-zip", - "application/octet-stream", -} - -_CHARTER_REQUIRED_FIELDS: dict[str, tuple[str, ...]] = { - "module_1": ("problem_statement", "goal_statement", "kpis"), - "module_2": ("in_scope", "out_of_scope", "alignment_qa"), - "module_3": ("primary_model", "fallback_model", "capability_table", "dependency_map"), - "module_4": ("milestones", "role_effort_estimates", "capability_owner_assignment"), - "module_5": ("risk_register", "trigger_rules", "execution_rule"), - "module_6": ("attachment_metadata", "attachment_summary", "search_tags"), -} - -RELEASE_STATUS_TRANSITIONS: dict[str, set[str]] = { - "pending": {"releasing", "online"}, - "releasing": {"online", "rolled_back"}, - "online": {"rolled_back"}, - "rolled_back": {"releasing"}, - "verified": set(), -} - - -def _sync_pmflow_rag_async( - *, - tenant_id: int, - record_type: str, - record_id: int, - project_id: int, - title: str, - content: dict[str, Any] | str | None, -) -> None: - try: - if isinstance(content, str): - payload = content - else: - payload = json.dumps(content or {}, ensure_ascii=False) - asyncio.create_task( - AdaptiveRAGService.upsert_pmflow_record_async( - tenant_id=tenant_id, - record_type=record_type, - record_id=record_id, - project_id=project_id, - title=title, - content=payload, - ) - ) - except Exception: - return - - -def _parse_project_id(project_id: str) -> int: - try: - return int(project_id) - except ValueError as exc: - raise HTTPException(status_code=400, detail="Invalid project_id") from exc - - -def _parse_node_id(node_id: str, label: str) -> int: - try: - return int(node_id) - except ValueError as exc: - raise HTTPException(status_code=400, detail=f"Invalid {label}") from exc - - -def _as_dict(value: Any) -> dict: - if isinstance(value, dict): - return value - if isinstance(value, str): - import json - try: - parsed = json.loads(value) - return parsed if isinstance(parsed, dict) else {"text": value} - except (TypeError, ValueError): - return {"text": value} - return {} - - -def _signal_ai_analysis(signal: Signal) -> dict: - payload = _as_dict(signal.strategic_analysis) - ai_payload = payload.get("ai_analysis") - return ai_payload if isinstance(ai_payload, dict) else {} - - -def _signal_project_scope(signal: Signal) -> Optional[str]: - scope = str(_signal_ai_analysis(signal).get("requirement_project_scope") or "").strip().lower() - if scope in {"new_project", "existing_project", "pending_classification", "non_requirement"}: - return scope - return None - - -def _signal_display_type(signal: Signal) -> str: - scope = _signal_project_scope(signal) - if scope: - return scope - return "pending_classification" - - -def _signal_source_context(signal: Signal) -> dict: - payload = _as_dict(signal.strategic_analysis) - source_context = payload.get("source_context") - return source_context if isinstance(source_context, dict) else {} - - -def _normalize_planner_status(value: Any) -> str: - raw = str(value or "").strip().lower() - if raw in {"pending", "completed", "failed"}: - return raw - return "pending" - - -def _safe_int(value: Any) -> Optional[int]: - try: - return int(str(value).strip()) - except (TypeError, ValueError, AttributeError): - return None - - -def _signal_attachment_storage_root() -> Path: - root = Path(__file__).resolve().parents[2] / "storage" / "signal_attachments" - root.mkdir(parents=True, exist_ok=True) - return root - - -def _build_signal_attachment_download_url(project_id: int, signal_id: int, attachment_id: str) -> str: - return f"/projects/{project_id}/signals/{signal_id}/attachments/{attachment_id}/download" - - -def _resolve_local_attachment_path(file_key: str) -> Optional[Path]: - raw = str(file_key or "").strip() - if not raw.startswith("local://"): - return None - relative = raw.removeprefix("local://").strip().lstrip("/") - if not relative: - return None - storage_root = _signal_attachment_storage_root().resolve() - candidate = (storage_root / relative).resolve() - try: - candidate.relative_to(storage_root) - except ValueError: - return None - return candidate - - -def _error_json(status_code: int, message: str) -> JSONResponse: - return JSONResponse(status_code=status_code, content=error_response(status_code, message)) - - -def _normalize_attachment_type(value: Any) -> str: - raw = str(value or "").strip().lower() - alias = { - "img": "image", - "picture": "image", - "attachment": "file", - "doc": "document", - "docs": "document", - } - mapped = alias.get(raw, raw) - return mapped if mapped in _ALLOWED_ATTACHMENT_TYPES else "file" - - -def _normalize_signal_attachment(item: Any, *, index: int) -> Optional[dict[str, Any]]: - if not isinstance(item, dict): - return None - attachment_type = _normalize_attachment_type(item.get("attachment_type") or item.get("type")) - attachment_id = ( - str(item.get("attachment_id") or item.get("id") or item.get("file_key") or item.get("image_key") or item.get("media_id") or "") - .strip() - ) - if not attachment_id: - attachment_id = f"attachment-{index + 1}" - file_name = ( - str(item.get("file_name") or item.get("name") or item.get("title") or "").strip() - or f"{attachment_type}-{attachment_id}" - ) - file_size = _safe_int(item.get("file_size") or item.get("size")) - mime_type = str(item.get("mime_type") or item.get("mime") or "").strip() or None - occurred_at = str(item.get("occurred_at") or "").strip() or None - download_url = str(item.get("download_url") or item.get("url") or "").strip() or None - file_key = str(item.get("file_key") or "").strip() or None - image_key = str(item.get("image_key") or "").strip() or None - media_id = str(item.get("media_id") or "").strip() or None - source = str(item.get("source") or "").strip() or None - if not any([file_key, image_key, media_id, download_url]): - return None - return { - "id": attachment_id, - "attachment_id": attachment_id, - "attachment_type": attachment_type, - "file_name": file_name, - "mime_type": mime_type, - "file_size": file_size, - "file_key": file_key, - "image_key": image_key, - "media_id": media_id, - "source": source, - "occurred_at": occurred_at, - "download_url": download_url, - } - - -def _extract_source_attachments(source_context: dict[str, Any]) -> list[dict[str, Any]]: - raw_items = source_context.get("attachments") - if not isinstance(raw_items, list): - return [] - items: list[dict[str, Any]] = [] - seen_ids: set[str] = set() - for idx, raw in enumerate(raw_items): - normalized = _normalize_signal_attachment(raw, index=idx) - if normalized is None: - continue - aid = str(normalized.get("attachment_id") or "").strip() - if not aid or aid in seen_ids: - continue - seen_ids.add(aid) - items.append(normalized) - return items - - -def _extract_signal_attachments(signal: Signal) -> list[dict[str, Any]]: - source_context = _signal_source_context(signal) - return _extract_source_attachments(source_context) - - -def _normalize_signal_status_code( - *, - linked_state: Optional[str], - signal_status: Optional[str], - processed: bool, -) -> str: - if linked_state in {_SIGNAL_STATUS_CODE_LINKED, _SIGNAL_STATUS_CODE_CLOSED}: - return linked_state - raw = str(signal_status or "").strip().lower() - if "关联" in raw or raw == _SIGNAL_STATUS_CODE_LINKED: - return _SIGNAL_STATUS_CODE_LINKED - if "关闭" in raw or "归档" in raw or raw in {"closed", "archived"}: - return _SIGNAL_STATUS_CODE_CLOSED - if processed: - return _SIGNAL_STATUS_CODE_CLOSED - return _SIGNAL_STATUS_CODE_PENDING - - -def _signal_status_label_from_code(status_code: str, fallback_status: Optional[str] = None) -> str: - fallback = str(fallback_status or "").strip() - normalized_code = str(status_code or "").strip().lower() - if ( - normalized_code == _SIGNAL_STATUS_CODE_PENDING - and fallback - and all(token not in fallback for token in ["关联", "关闭", "归档"]) - ): - return fallback - label = _SIGNAL_STATUS_LABEL_BY_CODE.get(normalized_code) - if label: - return label - return fallback or _SIGNAL_STATUS_LABEL_BY_CODE[_SIGNAL_STATUS_CODE_PENDING] - - -def _extract_charter_related_signal_ids(charter: TruthNode) -> list[int]: - payload = _as_dict(charter.content) - signal_ids: list[int] = [] - raw_ids = payload.get("signal_ids") - if isinstance(raw_ids, list): - for raw_id in raw_ids: - sid = _safe_int(raw_id) - if sid is not None: - signal_ids.append(sid) - charter_signal_id = _safe_int(charter.signal_id) - if charter_signal_id is not None: - signal_ids.append(charter_signal_id) - return sorted(set(signal_ids)) - - -def _collect_locked_signal_conflicts( - *, - charters: list[TruthNode], - candidate_signal_ids: list[int], -) -> tuple[list[int], dict[int, list[dict[str, Any]]]]: - candidate_set = set(int(sid) for sid in candidate_signal_ids) - conflicts: dict[int, list[dict[str, Any]]] = {} - for charter in charters: - charter_signal_ids = _extract_charter_related_signal_ids(charter) - if not charter_signal_ids: - continue - for sid in charter_signal_ids: - if sid not in candidate_set: - continue - conflicts.setdefault(sid, []).append( - { - "charter_id": int(charter.id), - "charter_title": str(charter.title or "").strip() or f"宪章#{int(charter.id)}", - } - ) - conflict_ids = sorted(conflicts.keys()) - return conflict_ids, conflicts - - -def _parse_beijing_day_start(raw: str, *, label: str) -> datetime: - text = str(raw or "").strip() - if not text: - raise HTTPException(status_code=400, detail=f"{label} 不能为空") - if "T" in text: - text = text.split("T")[0] - try: - local_day = datetime.strptime(text, "%Y-%m-%d") - except ValueError as exc: - raise HTTPException(status_code=400, detail=f"Invalid {label}") from exc - return local_day - _BEIJING_UTC_OFFSET - - -def _build_signal_created_at_range( - *, - created_from: Optional[str], - created_to: Optional[str], -) -> tuple[Optional[datetime], Optional[datetime]]: - start_utc = _parse_beijing_day_start(created_from, label="created_from") if created_from else None - end_utc = None - if created_to: - end_day_start_utc = _parse_beijing_day_start(created_to, label="created_to") - end_utc = end_day_start_utc + timedelta(days=1) - if start_utc and end_utc and start_utc >= end_utc: - raise HTTPException(status_code=400, detail="created_from 不能晚于 created_to") - return start_utc, end_utc - - -def _normalize_signal_status_filter(value: Optional[str]) -> Optional[str]: - raw = str(value or "").strip() - if not raw: - return None - return _SIGNAL_STATUS_FILTER_ALIAS.get(raw.lower(), _SIGNAL_STATUS_FILTER_ALIAS.get(raw, raw.lower())) - - -def _match_signal_filters( - *, - row: dict[str, Any], - status_filter: Optional[str], - keyword: Optional[str], - source: Optional[str], - priority: Optional[str], - created_from_utc: Optional[datetime], - created_to_utc: Optional[datetime], - created_at_dt: Optional[datetime], -) -> bool: - if status_filter: - if str(row.get("status_code") or "").strip().lower() != str(status_filter).strip().lower(): - return False - - keyword_text = str(keyword or "").strip().lower() - if keyword_text: - haystack = " ".join( - [ - str(row.get("id") or ""), - str(row.get("title") or ""), - str(row.get("content") or ""), - str(row.get("proposer_name") or ""), - str(row.get("charter_name") or ""), - ] - ).lower() - if keyword_text not in haystack: - return False - - source_text = str(source or "").strip().lower() - if source_text and source_text != "all": - row_source = str(row.get("source") or "").strip().lower() - if row_source != source_text: - return False - - priority_text = str(priority or "").strip().lower() - if priority_text and priority_text != "all": - row_priority = str(row.get("priority") or "").strip().lower() - if row_priority != priority_text: - return False - - if created_from_utc or created_to_utc: - if created_at_dt is None: - return False - if created_from_utc and created_at_dt < created_from_utc: - return False - if created_to_utc and created_at_dt >= created_to_utc: - return False - - return True - - -def _strip_signal_sender_prefix(content: str) -> str: - text = str(content or "") - return _SIGNAL_PREFIX_RE.sub("", text, count=1).strip() or text - - -def _extract_sender_from_content(content: str) -> Optional[str]: - text = str(content or "") - matched = _SIGNAL_PREFIX_RE.match(text) - if not matched: - return None - sender = (matched.group(1) or "").strip() - return sender or None - - -def _signal_title_text(signal: Signal) -> str: - for candidate in ( - str(signal.title or "").strip(), - str(signal.summary or "").strip(), - _strip_signal_sender_prefix(str(signal.content or "")), - ): - compacted = compact_title(candidate, max_len=80) - if not compacted: - continue - normalized = re.sub(r"\s+", "", compacted).lower() - if _GENERIC_SIGNAL_SOURCE_TITLE_RE.search(normalized): - specific_hints = ("chat_id", "http", "https", "飞书", "企业微信", "github", "gitlab", "jira", "webhook", "项目", "群聊", "repo") - if not any(hint in normalized for hint in specific_hints): - return "信号源接入请求(待补充参数)" - return compacted - return "" - - -def _signal_proposer_name(signal: Signal) -> str: - source_context = _signal_source_context(signal) - sender = str(source_context.get("sender") or "").strip() - if sender: - return sender - sender_from_content = _extract_sender_from_content(str(signal.content or "")) - return sender_from_content or "未知用户" - - -def _replace_signal_sender_in_content(content: str, sender_name: str) -> str: - text = str(content or "") - if not text: - return text - if _SIGNAL_PREFIX_RE.match(text): - return _SIGNAL_PREFIX_RE.sub(f"[{sender_name}]: ", text, count=1) - return f"[{sender_name}]: {text}" - - -def _safe_threshold(value: Any, default: float) -> float: - try: - number = float(value) - except (TypeError, ValueError): - number = float(default) - return min(1.0, max(0.0, number)) - - -def _parse_json_object_text(raw: str) -> dict[str, Any]: - text = str(raw or "").strip() - if not text: - return {} - if text.startswith("```"): - text = text.strip("`") - if text.lower().startswith("json"): - text = text[4:].strip() - try: - parsed = json.loads(text) - return parsed if isinstance(parsed, dict) else {} - except Exception: - return {} - - -def _seed_text_snapshot(seed: dict[str, Any]) -> str: - pieces: list[str] = [] - for value in seed.values(): - if isinstance(value, str): - pieces.append(value) - elif isinstance(value, (list, tuple)): - pieces.extend([str(item) for item in value if item is not None]) - return "\n".join([part for part in pieces if str(part).strip()]).strip() - - -def _needs_seed_localize_zh(seed: dict[str, Any]) -> bool: - text = _seed_text_snapshot(seed) - if not text: - return False - zh_chars = len(re.findall(r"[\u4e00-\u9fff]", text)) - latin_chars = len(re.findall(r"[A-Za-z]", text)) - return zh_chars < 20 and latin_chars > 40 - - -async def _localize_seed_to_zh( - *, - db: AsyncSession, - tenant_id: int, - project_id: int, - seed: dict[str, Any], -) -> dict[str, Any]: - if not seed: - return seed - prompt = ( - "将下列 JSON 草稿的自然语言内容翻译为简体中文。" - "要求:仅返回 JSON;键名与结构保持不变;模型名/专有技术名词可保留英文。\n\n" - f"{json.dumps(seed, ensure_ascii=False)}" - ) - try: - raw = await llm_service.generate( - prompt, - function_name="sstc_charter_localize_zh", - db=db, - tenant_id=tenant_id, - project_id=project_id, - ) - localized = _parse_json_object_text(raw) - return localized if localized else seed - except Exception: - return seed - - -def _to_string_list(value: Any) -> list[str]: - if value is None: - return [] - if isinstance(value, str): - lines = [line.strip(" -\t") for line in value.replace("\r", "").split("\n")] - return [line for line in lines if line] - if isinstance(value, (list, tuple)): - output: list[str] = [] - for item in value: - text = str(item or "").strip(" -\t") - if text: - output.append(text) - return output - text = str(value).strip() - return [text] if text else [] - - -def _to_bullet_text(items: list[str]) -> str: - cleaned = [str(item or "").strip() for item in items if str(item or "").strip()] - if not cleaned: - return "" - return "\n".join([f"- {item}" for item in cleaned]) - - -def _extract_charter_signal_ids(content: dict[str, Any], charter_signal_id: Optional[int] = None) -> list[int]: - signal_ids: list[int] = [] - raw_ids = content.get("signal_ids") - if isinstance(raw_ids, list): - for sid in raw_ids: - try: - signal_ids.append(int(sid)) - except Exception: - continue - if charter_signal_id: - try: - cid = int(charter_signal_id) - if cid not in signal_ids: - signal_ids.append(cid) - except Exception: - pass - return sorted(set(signal_ids)) - - -def _build_signal_context_text( - signal_rows: list[Signal], - extra_text: str = "", - raw_text: str = "", -) -> str: - parts: list[str] = [] - for idx, signal in enumerate(signal_rows, start=1): - title = _signal_title_text(signal) or f"信号{idx}" - parts.append( - "\n".join( - [ - f"[信号{idx}] 标题:{title}", - f"来源:{signal.source or '-'}", - f"优先级:{signal.priority or '-'}", - f"内容:{str(signal.content or '').strip()}", - ] - ).strip() - ) - extra_blocks: list[str] = [] - for block in (extra_text, raw_text): - text = str(block or "").strip() - if text and text not in extra_blocks: - extra_blocks.append(text) - for text in extra_blocks: - parts.append(f"[补充上下文]\n{text}") - return "\n\n".join([part for part in parts if part]).strip() - - -def _has_non_empty_module_data(content: dict[str, Any]) -> bool: - modules = content.get("modules") - if not isinstance(modules, dict): - return False - for module in modules.values(): - if not isinstance(module, dict): - continue - for value in module.values(): - if str(value or "").strip(): - return True - return False - - -def _has_complete_module_data(content: dict[str, Any]) -> bool: - modules = content.get("modules") - if not isinstance(modules, dict): - return False - for module_key, required_fields in _CHARTER_REQUIRED_FIELDS.items(): - module_payload = modules.get(module_key) - if not isinstance(module_payload, dict): - return False - for field_key in required_fields: - if not str(module_payload.get(field_key) or "").strip(): - return False - return True - - -def _merge_prefill_content( - existing: dict[str, Any], incoming: dict[str, Any], *, overwrite_existing: bool -) -> dict[str, Any]: - merged: dict[str, Any] = dict(existing) - for key, value in incoming.items(): - if overwrite_existing or key not in merged: - merged[key] = value - continue - current = merged.get(key) - if isinstance(current, dict) and isinstance(value, dict): - merged[key] = _merge_prefill_content( - current, value, overwrite_existing=overwrite_existing - ) - continue - if isinstance(current, list): - if not current and value: - merged[key] = value - continue - if current is None or not str(current).strip(): - merged[key] = value - return merged - - -def _default_ai_validations(module_name: str) -> str: - return _to_bullet_text( - [ - f"校验「{module_name}」与信号源证据链一致", - "校验字段可验证、可执行、可追踪", - "发现冲突时自动标注并等待人工否决/确认", - ] - ) - - -def _default_escalation_trigger() -> str: - return _to_bullet_text( - [ - "置信度低于 0.75", - "关键字段缺失或来源证据冲突", - "超过模型能力边界或预算/时限约束", - ] - ) - - -async def _generate_charter_seed( - *, - db: AsyncSession, - tenant_id: int, - project_id: int, - signal_context: str, -) -> dict[str, Any]: - if not signal_context.strip(): - return {} - - prompt = ( - "你是 AI 项目经理,请基于输入信号生成可落地的宪章草稿。" - "所有自然语言字段必须使用简体中文,禁止英文句子;模型名称或技术名词可保留英文。" - "只输出 JSON,不要 markdown。\n" - "字段:\n" - "{\n" - ' "summary": "一句话问题摘要",\n' - ' "background": "背景与动机",\n' - ' "objectives": ["目标1","目标2"],\n' - ' "scope": "范围说明",\n' - ' "in_scope": ["范围内事项1","范围内事项2"],\n' - ' "out_of_scope": ["范围外事项1","范围外事项2"],\n' - ' "success_metrics": ["指标1","指标2"],\n' - ' "risks": ["风险1","风险2"],\n' - ' "dependencies": ["依赖1","依赖2"],\n' - ' "primary_model": "模型名",\n' - ' "fallback_model": "备用模型名"\n' - "}\n\n" - f"信号输入:\n{signal_context[:6000]}" - ) - try: - raw = await llm_service.generate( - prompt, - function_name="sstc_charter_prefill", - db=db, - tenant_id=tenant_id, - project_id=project_id, - ) - parsed = _parse_json_object_text(raw) - if parsed: - if _needs_seed_localize_zh(parsed): - return await _localize_seed_to_zh( - db=db, tenant_id=tenant_id, project_id=project_id, seed=parsed - ) - return parsed - except Exception: - pass - - try: - ai_seed = await AIService().generate_charter_from_signal( - signal_context, - tenant_id=tenant_id, - db=db, - ) - if isinstance(ai_seed, dict): - if _needs_seed_localize_zh(ai_seed): - return await _localize_seed_to_zh( - db=db, tenant_id=tenant_id, project_id=project_id, seed=ai_seed - ) - return ai_seed - return {} - except Exception: - return {} - - -async def _build_ai_first_charter_content( - *, - db: AsyncSession, - tenant_id: int, - project_id: int, - signal_rows: list[Signal], - signal_ids: list[int], - raw_text: str = "", -) -> dict[str, Any]: - signal_context = _build_signal_context_text(signal_rows, raw_text=raw_text) - seed = await _generate_charter_seed( - db=db, - tenant_id=tenant_id, - project_id=project_id, - signal_context=signal_context, - ) - - summary = str(seed.get("summary") or "").strip() - background = str(seed.get("background") or "").strip() - objectives = _to_string_list(seed.get("objectives")) - scope = str(seed.get("scope") or "").strip() - in_scope_items = _to_string_list(seed.get("in_scope")) - out_of_scope_items = _to_string_list(seed.get("out_of_scope")) - success_metrics = _to_string_list(seed.get("success_metrics")) - risks = _to_string_list(seed.get("risks")) - dependencies = _to_string_list(seed.get("dependencies")) - primary_model = str(seed.get("primary_model") or "").strip() - fallback_model = str(seed.get("fallback_model") or "").strip() - - if not summary: - summary = "目标用户在当前流程中存在效率损耗,需要通过 AI 驱动的流程优化提升交付质量与速度。" - if not background: - background = signal_context[:500] or "基于已绑定信号源自动生成。" - if not objectives: - objectives = ["缩短端到端交付周期", "提升关键路径质量稳定性", "降低跨角色协同成本"] - if not success_metrics: - success_metrics = ["需求到上线周期缩短 >= 30%", "关键任务按期交付率 >= 90%", "线上回滚率 <= 3%"] - if not in_scope_items: - in_scope_items = ["需求澄清与宪章生成自动化", "任务拆解与负责人推荐", "对齐状态自动追踪与留痕"] - if not out_of_scope_items: - out_of_scope_items = ["替代团队组织架构决策", "绕过人工否决机制自动上线", "非项目相关流程改造"] - if not risks: - risks = ["需求波动导致范围蔓延", "模型能力边界与输入复杂度不匹配", "关键成员负载过高导致延期"] - if not dependencies: - dependencies = ["FastAPI", "React", "LLM 推理服务", "项目知识库/日志系统"] - if not primary_model: - primary_model = "待 AI 评估后绑定" - if not fallback_model: - fallback_model = "同供应商低成本模型" - - source_refs = [f"signal:{sid}" for sid in signal_ids] - now_iso = now_cn_aware().isoformat() - validations_m1 = _default_ai_validations("模块1:触发与目标") - validations_m2 = _default_ai_validations("模块2:范围与对齐") - validations_m3 = _default_ai_validations("模块3:模型与依赖") - validations_m4 = _default_ai_validations("模块4:计划与资源") - validations_m5 = _default_ai_validations("模块5:风险与兜底") - validations_m6 = _default_ai_validations("模块6:附件与治理") - escalation = _default_escalation_trigger() - - alignment_qa = _to_bullet_text( - [ - f"核心用户与痛点:{summary}", - "上线时间与硬约束:由里程碑计划自动回填并持续校验", - f"明确不做项:{'; '.join(out_of_scope_items[:3])}", - f"关键依赖:{'; '.join(dependencies[:4])}", - f"核心风险与缓解:{'; '.join(risks[:3])}", - ] - ) - - modules = { - "module_1": { - "problem_statement": summary, - "goal_statement": _to_bullet_text(objectives), - "kpis": _to_bullet_text(success_metrics), - "ai_validations": validations_m1, - "escalation_trigger": escalation, - }, - "module_2": { - "in_scope": _to_bullet_text(in_scope_items), - "out_of_scope": _to_bullet_text(out_of_scope_items), - "alignment_qa": alignment_qa, - "ai_validations": validations_m2, - "escalation_trigger": escalation, - }, - "module_3": { - "primary_model": primary_model, - "fallback_model": fallback_model, - "capability_table": _to_bullet_text( - [ - "上下文窗口/并发/延迟由模型能力中心实时读取", - "按 P50/P90 工作负载自动估算 token 与成本", - "超边界输入自动触发替代模型建议", - ] - ), - "dependency_map": _to_bullet_text(dependencies), - "business_view": "以业务术语展示能力上限、成本区间与上线风险。", - "engineering_view": "以工程参数展示 context window、QPS、成本与错误预算。", - "ai_validations": validations_m3, - "escalation_trigger": escalation, - }, - "module_4": { - "milestones": _to_bullet_text( - [ - "需求冻结 -> 技术评审 -> 开发完成 -> QA 验收 -> 发布上线", - "关键依赖阻塞超过阈值自动重排计划", - ] - ), - "role_effort_estimates": _to_bullet_text( - [ - "产品/PM:需求澄清与验收口径", - "前后端工程:核心功能开发与联调", - "AI 工程:模型评估、提示词与推理链路优化", - ] - ), - "capability_owner_assignment": "负责人由能力图谱自动推荐并可人工否决。", - "buffer_policy": "默认 15% 进度缓冲,关键路径超阈值触发自动预警。", - "ai_validations": validations_m4, - "escalation_trigger": escalation, - }, - "module_5": { - "risk_register": _to_bullet_text(risks), - "trigger_rules": _to_bullet_text( - [ - "进度偏差 > 15%", - "关键接口错误率连续超过阈值", - "模型调用成本超预算预警线", - ] - ), - "execution_rule": _to_bullet_text( - [ - "触发后优先执行预设 fallback", - "自动通知责任人并写入审计日志", - "连续失败时升级到人工决策", - ] - ), - "ai_validations": validations_m5, - "escalation_trigger": escalation, - }, - "module_6": { - "attachment_metadata": _to_bullet_text( - [ - "原始信号记录", - "需求讨论记录", - "设计与评审文档", - "发布与复盘材料", - ] - ), - "attachment_summary": "AI 自动提炼附件决策结论并关联到对应模块字段。", - "search_tags": _to_bullet_text(["需求宪章", "AI First", "SSTC", "对齐记录"]), - "ai_validations": validations_m6, - "governance_rule": "附件仅作证据引用,核心事实字段以模块1-5为准。", - }, - } - - return { - "schema_version": "charter_ai_first_v1", - "auto_generated_by_ai": True, - "metadata": { - "status": "draft", - "confidence_score": 0.82 if seed else 0.72, - "last_validated_at": now_iso, - "evidence_refs": source_refs, - "escalation_reason": None, - }, - "modules": modules, - "signal_ids": signal_ids, - "summary": summary, - "background": background, - "objectives": objectives, - "scope": scope, - "success_metrics": success_metrics, - "core_metrics": success_metrics, - "rationale": summary, - "decision": _to_bullet_text(objectives), - "in_scope": in_scope_items, - "out_of_scope": out_of_scope_items, - "risks": risks, - "raw_source_text": raw_text.strip() or None, - } - - -def _extract_feishu_routing_config(integration: Optional[Integration]) -> dict[str, Any]: - config: dict[str, Any] = {} - if integration is not None: - if isinstance(integration.config, dict): - config = integration.config - elif isinstance(integration.config, str): - try: - parsed = json.loads(integration.config) - if isinstance(parsed, dict): - config = parsed - except (TypeError, ValueError): - config = {} - - cold_start_enabled = bool(config.get("cold_start_enabled")) - signal_threshold = _safe_threshold( - config.get("signal_auto_enter_threshold"), - DEFAULT_SIGNAL_AUTO_ENTER_THRESHOLD, - ) - project_threshold = _safe_threshold( - config.get("project_auto_create_threshold"), - DEFAULT_PROJECT_AUTO_CREATE_THRESHOLD, - ) - uncertain_threshold = min( - project_threshold, - _safe_threshold( - config.get("project_uncertain_threshold"), - DEFAULT_PROJECT_UNCERTAIN_THRESHOLD, - ), - ) - return { - "raw": config, - "cold_start_enabled": cold_start_enabled, - "signal_auto_enter_threshold": signal_threshold, - "project_auto_create_threshold": project_threshold, - "project_uncertain_threshold": uncertain_threshold, - } - - -async def _backfill_feishu_sender_names( - db: AsyncSession, - *, - tenant_id: int, - signals: list[Signal], -) -> None: - pending: dict[int, dict[str, str]] = {} - for signal in signals: - if str(signal.source or "").lower() != "feishu": - continue - source_context = _signal_source_context(signal) - open_id = str(source_context.get("sender_open_id") or "").strip() - sender = _signal_proposer_name(signal).strip() - if not open_id or not sender: - continue - if _FEISHU_PLACEHOLDER_RE.match(sender): - pending[int(signal.id)] = { - "open_id": open_id, - "current_sender": sender, - } - if not pending: - return - - service = get_feishu_service(db) - await service.load_config(tenant_id=tenant_id) - if not service.app_id or not service.app_secret: - return - - resolved_name_by_open_id: dict[str, str] = {} - for open_id in sorted({meta["open_id"] for meta in pending.values()})[:20]: - try: - remote_name = await service._fetch_user_name_by_open_id(open_id) # noqa: SLF001 - except Exception: - remote_name = None - normalized = (remote_name or "").strip() - if not normalized or _FEISHU_PLACEHOLDER_RE.match(normalized): - continue - resolved_name_by_open_id[open_id] = normalized - - if not resolved_name_by_open_id: - return - - changed = False - for signal in signals: - meta = pending.get(int(signal.id)) - if not meta: - continue - sender_name = resolved_name_by_open_id.get(meta["open_id"]) - if not sender_name: - continue - payload = _as_dict(signal.strategic_analysis) - source_context = payload.get("source_context") - if not isinstance(source_context, dict): - source_context = {} - source_context["sender"] = sender_name - source_context["sender_open_id"] = meta["open_id"] - payload["source_context"] = source_context - signal.strategic_analysis = json.dumps(payload, ensure_ascii=False) - signal.content = _replace_signal_sender_in_content(str(signal.content or ""), sender_name) - changed = True - - if changed: - await db.commit() - - -def _parse_due_date(value: Optional[str]) -> Optional[datetime]: - if not value: - return None - try: - return datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None) - except ValueError as exc: - raise HTTPException(status_code=400, detail="Invalid due_date") from exc - - -async def _ensure_project( - svc: SSTCService, - project_id: int, -): - project = await svc.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail="Project not found") - return project - - -async def _ensure_charter( - db: AsyncSession, - *, - tenant_id: int, - project_id: int, - charter_id: int, -) -> TruthNode: - result = await db.execute( - select(TruthNode).where( - and_( - TruthNode.id == charter_id, - TruthNode.project_id == project_id, - TruthNode.tenant_id == tenant_id, - TruthNode.node_type == "charter", - ) - ) - ) - charter = result.scalar_one_or_none() - if not charter: - raise HTTPException(status_code=404, detail="Charter not found") - return charter - - -class AIRoutingConfigUpdateRequest(BaseModel): - cold_start_enabled: Optional[bool] = None - signal_auto_enter_threshold: Optional[float] = None - project_auto_create_threshold: Optional[float] = None - project_uncertain_threshold: Optional[float] = None - - -def _routing_payload(data: dict[str, Any]) -> dict[str, Any]: - return { - "cold_start_enabled": bool(data["cold_start_enabled"]), - "signal_auto_enter_threshold": round(float(data["signal_auto_enter_threshold"]), 2), - "project_auto_create_threshold": round(float(data["project_auto_create_threshold"]), 2), - "project_uncertain_threshold": round(float(data["project_uncertain_threshold"]), 2), - "tips": { - "cold_start_enabled": "开启后,所有飞书消息进入待处理,不会自动入库或自动建项。", - "signal_auto_enter_threshold": "建议范围 0.75-0.90,数值越高越保守。", - "project_auto_create_threshold": "建议范围 0.80-0.95,数值越高越严格。", - "project_uncertain_threshold": "低于该值视为低置信度,进入人工待处理。", - }, - } - - -@router.get("/projects/{project_id}/ai-routing-config") -async def get_ai_routing_config( - project_id: str, - current_user: User = Depends(require_project_member_or_manager), - db: AsyncSession = Depends(get_db), -): - tenant_id = int(current_user.tenant_id) - pid = _parse_project_id(project_id) - svc = SSTCService(db, tenant_id=tenant_id) - await _ensure_project(svc, pid) - - integration = ( - await db.execute( - select(Integration).where( - and_( - Integration.tenant_id == tenant_id, - Integration.type == "feishu", - ) - ) - ) - ).scalar_one_or_none() - return success_response(_routing_payload(_extract_feishu_routing_config(integration))) - - -@router.put("/projects/{project_id}/ai-routing-config") -async def update_ai_routing_config( - project_id: str, - request: AIRoutingConfigUpdateRequest, - current_user: User = Depends(require_project_member_or_manager), - db: AsyncSession = Depends(get_db), -): - tenant_id = int(current_user.tenant_id) - pid = _parse_project_id(project_id) - svc = SSTCService(db, tenant_id=tenant_id) - await _ensure_project(svc, pid) - - integration = ( - await db.execute( - select(Integration).where( - and_( - Integration.tenant_id == tenant_id, - Integration.type == "feishu", - ) - ) - ) - ).scalar_one_or_none() - if integration is None: - integration = Integration( - tenant_id=tenant_id, - type="feishu", - config={}, - is_active=True, - ) - db.add(integration) - await db.flush() - - current = _extract_feishu_routing_config(integration) - config = dict(current["raw"]) - - if request.cold_start_enabled is not None: - enabled = bool(request.cold_start_enabled) - config["cold_start_enabled"] = enabled - - if request.signal_auto_enter_threshold is not None: - config["signal_auto_enter_threshold"] = _safe_threshold( - request.signal_auto_enter_threshold, - current["signal_auto_enter_threshold"], - ) - - if request.project_auto_create_threshold is not None: - config["project_auto_create_threshold"] = _safe_threshold( - request.project_auto_create_threshold, - current["project_auto_create_threshold"], - ) - - if request.project_uncertain_threshold is not None: - config["project_uncertain_threshold"] = _safe_threshold( - request.project_uncertain_threshold, - current["project_uncertain_threshold"], - ) - - project_threshold = _safe_threshold( - config.get("project_auto_create_threshold"), - current["project_auto_create_threshold"], - ) - uncertain_threshold = min( - project_threshold, - _safe_threshold( - config.get("project_uncertain_threshold"), - current["project_uncertain_threshold"], - ), - ) - config["project_auto_create_threshold"] = project_threshold - config["project_uncertain_threshold"] = uncertain_threshold - - integration.config = config - await db.commit() - await db.refresh(integration) - return success_response(_routing_payload(_extract_feishu_routing_config(integration))) - - -async def _ensure_task( - db: AsyncSession, - *, - tenant_id: int, - project_id: int, - task_id: int, -) -> Task: - result = await db.execute( - select(Task).where( - and_( - Task.id == task_id, - Task.project_id == project_id, - Task.tenant_id == tenant_id, - ) - ) - ) - task = result.scalar_one_or_none() - if not task: - raise HTTPException(status_code=404, detail="Task not found") - return task - - -async def _ensure_release( - db: AsyncSession, - *, - tenant_id: int, - project_id: int, - release_node_id: int, -) -> TruthNode: - result = await db.execute( - select(TruthNode).where( - and_( - TruthNode.id == release_node_id, - TruthNode.project_id == project_id, - TruthNode.tenant_id == tenant_id, - TruthNode.node_type == "release", - ) - ) - ) - release_node = result.scalar_one_or_none() - if not release_node: - raise HTTPException(status_code=404, detail="Release plan not found") - return release_node - - -async def _ensure_review( - db: AsyncSession, - *, - tenant_id: int, - project_id: int, - review_node_id: int, -) -> TruthNode: - result = await db.execute( - select(TruthNode).where( - and_( - TruthNode.id == review_node_id, - TruthNode.project_id == project_id, - TruthNode.tenant_id == tenant_id, - TruthNode.node_type == "review", - ) - ) - ) - review_node = result.scalar_one_or_none() - if not review_node: - raise HTTPException(status_code=404, detail="Review not found") - return review_node - - -def _normalize_review_content(raw: dict[str, Any] | None) -> dict[str, Any]: - payload = dict(raw or {}) - summary = str(payload.get("summary") or "").strip() - goals = payload.get("goals") - if not isinstance(goals, list): - goals = [] - what_went_well = payload.get("what_went_well") - if not isinstance(what_went_well, list): - what_went_well = [] - root_causes = payload.get("root_causes") - if not isinstance(root_causes, list): - root_causes = [] - improvements = payload.get("improvements") - if not isinstance(improvements, list): - improvements = [] - - normalized_improvements: list[dict[str, Any]] = [] - def _optional_int(value: Any) -> int | None: - if value in (None, ""): - return None - try: - return int(value) - except (TypeError, ValueError): - return None - - for idx, item in enumerate(improvements): - if isinstance(item, str): - title = item.strip() - if not title: - continue - normalized_improvements.append( - { - "id": f"imp-{idx+1}", - "title": title, - "status": "pending", - "owner_id": None, - "due_date": None, - "task_id": None, - } - ) - continue - if not isinstance(item, dict): - continue - title = str(item.get("title") or "").strip() - if not title: - continue - normalized_improvements.append( - { - "id": str(item.get("id") or f"imp-{idx+1}"), - "title": title, - "status": str(item.get("status") or "pending"), - "owner_id": _optional_int(item.get("owner_id")), - "due_date": item.get("due_date"), - "task_id": _optional_int(item.get("task_id")), - "description": str(item.get("description") or "").strip() or None, - } - ) - - return { - "summary": summary, - "goals": [str(x).strip() for x in goals if str(x).strip()], - "what_went_well": [str(x).strip() for x in what_went_well if str(x).strip()], - "root_causes": [str(x).strip() for x in root_causes if str(x).strip()], - "improvements": normalized_improvements, - "last_updated_at": now_cn_aware().isoformat(), - } - - -async def _project_task_metrics( - db: AsyncSession, - *, - tenant_id: int, - project_id: int, -) -> dict[str, int]: - result = await db.execute( - select(Task.status).where( - and_( - Task.project_id == project_id, - Task.tenant_id == tenant_id, - ) - ) - ) - statuses = [str(row[0] or "").lower() for row in result.all()] - total = len(statuses) - done = len([s for s in statuses if s in DONE_STATUSES]) - return {"total": total, "done": done} - - -async def _build_governance_snapshot( - db: AsyncSession, - *, - tenant_id: int, - project_id: int, - charter_id: int | None = None, -) -> dict[str, Any]: - charter: TruthNode | None = None - if charter_id: - charter = ( - await db.execute( - select(TruthNode).where( - and_( - TruthNode.id == charter_id, - TruthNode.project_id == project_id, - TruthNode.tenant_id == tenant_id, - TruthNode.node_type == "charter", - ) - ) - ) - ).scalar_one_or_none() - if charter is None: - charter = ( - await db.execute( - select(TruthNode) - .where( - and_( - TruthNode.project_id == project_id, - TruthNode.tenant_id == tenant_id, - TruthNode.node_type == "charter", - ) - ) - .order_by(TruthNode.created_at.desc()) - .limit(1) - ) - ).scalar_one_or_none() - - confidence_score = None - confidence_risk = False - charter_id_value = int(charter.id) if charter else None - if charter: - charter_content = _as_dict(charter.content) - metadata = charter_content.get("metadata") - if isinstance(metadata, dict): - raw_score = metadata.get("confidence_score") - try: - confidence_score = float(raw_score) - except (TypeError, ValueError): - confidence_score = None - confidence_risk = confidence_score is not None and confidence_score < 0.8 - - binding = None - if charter_id_value: - binding = ( - await db.execute( - select(ModelRequirementBinding, ModelVersion) - .join( - ModelVersion, - ModelVersion.id == ModelRequirementBinding.model_version_id, - isouter=True, - ) - .where( - and_( - ModelRequirementBinding.tenant_id == tenant_id, - ModelRequirementBinding.truth_node_id == charter_id_value, - ) - ) - .order_by(ModelRequirementBinding.created_at.desc()) - .limit(1) - ) - ).first() - - model_block = False - model_info: dict[str, Any] = { - "bound": False, - "compatibility_score": None, - "model_version_id": None, - "model_name": None, - "provider": None, - } - est_cost = 0.01 - if binding: - binding_row, model_row = binding - score = int(binding_row.compatibility_score or 0) - model_block = score < 70 - model_info = { - "bound": True, - "compatibility_score": score, - "model_version_id": int(binding_row.model_version_id), - "model_name": getattr(model_row, "name", None), - "provider": getattr(model_row, "provider", None), - } - try: - est_cost = max(float(binding_row.estimated_cost or 0.01), 0.01) - except (TypeError, ValueError): - est_cost = 0.01 - - budget_service = BudgetEnforcementService(db) - budget_check = await budget_service.check_budget_before_ai_call( - tenant_id=tenant_id, - estimated_cost_usd=est_cost, - project_id=project_id, - ) - - budget_block = not bool(budget_check.get("allowed", True)) - intervention_required = confidence_risk or model_block or budget_block - blockers: list[str] = [] - warnings: list[str] = [] - if confidence_risk: - warnings.append("宪章 AI 置信度低于 0.80,建议补充关键事实后再推进。") - if model_block: - blockers.append("模型匹配分低于 70,禁止自动推进发布验证。") - elif not model_info["bound"]: - warnings.append("当前宪章未绑定模型版本,建议先完成模型能力矩阵校验。") - if budget_block: - blockers.append(f"预算管控阻断:{budget_check.get('reason') or 'budget_blocked'}") - elif str(budget_check.get("reason") or "") == "within_budget": - usage = budget_check.get("usage_percent") - if isinstance(usage, (int, float)) and float(usage) >= 80: - warnings.append("预算已接近阈值,建议执行成本优化方案。") - - return { - "project_id": project_id, - "charter_id": charter_id_value, - "intervention_required": intervention_required, - "blockers": blockers, - "warnings": warnings, - "confidence": { - "score": confidence_score, - "risk": confidence_risk, - }, - "model_binding": model_info, - "budget": budget_check, - } - - -async def _assert_release_transition_allowed( - db: AsyncSession, - *, - tenant_id: int, - project_id: int, - release_node: TruthNode, - target_status: str, -) -> None: - current = str(release_node.status or "pending").lower() - target = str(target_status or "").lower() - if target == "verified": - raise HTTPException(status_code=409, detail="请通过验证完成接口标记为已验证") - if target not in RELEASE_STATUS_TRANSITIONS.get(current, set()): - raise HTTPException( - status_code=409, - detail=f"非法状态流转:{current} -> {target}", - ) - - content = _as_dict(release_node.content) - if target in {"releasing", "online"}: - task_ids = [int(tid) for tid in (content.get("task_ids") or []) if str(tid).isdigit()] - if not task_ids: - raise HTTPException(status_code=409, detail="发布计划未绑定任务") - task_result = await db.execute( - select(Task.id, Task.status).where( - and_( - Task.tenant_id == tenant_id, - Task.project_id == project_id, - Task.id.in_(task_ids), - ) - ) - ) - task_rows = list(task_result.all()) - if len(task_rows) != len(task_ids): - raise HTTPException(status_code=409, detail="发布计划存在无效任务绑定") - not_done = [row for row in task_rows if str(row[1] or "").lower() not in DONE_STATUSES] - if not_done: - raise HTTPException(status_code=409, detail="存在未完成任务,禁止进入发布流程") - - governance = await _build_governance_snapshot( - db, - tenant_id=tenant_id, - project_id=project_id, - charter_id=int(content.get("charter_id") or 0) or None, - ) - if governance.get("blockers"): - raise HTTPException( - status_code=409, - detail=";".join([str(item) for item in governance.get("blockers", [])]), - ) - - verification_items = content.get("verification_items") - if not isinstance(verification_items, list) or len(verification_items) == 0: - raise HTTPException(status_code=409, detail="请先生成发布验证清单") - -def _assert_charter_aligned(charter: TruthNode) -> None: - if (charter.status or "").lower() not in ALIGNED_STATUSES: - raise HTTPException( - status_code=409, - detail="宪章未对齐,任务环节未解锁", - ) - - -def _is_manager(user: User) -> bool: - return has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]) - - -def _require_manager(user: User) -> None: - if not _is_manager(user): - raise HTTPException(status_code=403, detail="仅项目负责人或管理员可执行该操作") - - -@router.get("/projects/{project_id}/sstc/overview") -async def get_board_overview( - project_id: str, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - pid = _parse_project_id(project_id) - svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) - await _ensure_project(svc, pid) - overview = await svc.build_overview(pid) - stages = await svc.build_stages(pid) - risks = await svc.build_risks(pid) - return success_response( - { - "overview": overview, - "stages": stages, - "risks": risks, - } - ) - - -@router.get("/projects/{project_id}/sstc/stages") -async def get_stages( - project_id: str, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - pid = _parse_project_id(project_id) - svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) - await _ensure_project(svc, pid) - return success_response(await svc.build_stages(pid)) - - -@router.get("/projects/{project_id}/sstc/risks") -async def get_risks( - project_id: str, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - pid = _parse_project_id(project_id) - svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) - await _ensure_project(svc, pid) - return success_response(await svc.build_risks(pid)) - - -@router.get("/projects/{project_id}/sstc/governance") -async def get_governance_snapshot( - project_id: str, - charter_id: Optional[int] = Query(default=None), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - pid = _parse_project_id(project_id) - tenant_id = int(current_user.tenant_id) - svc = SSTCService(db, tenant_id=tenant_id) - await _ensure_project(svc, pid) - data = await _build_governance_snapshot( - db, - tenant_id=tenant_id, - project_id=pid, - charter_id=charter_id, - ) - return success_response(data) - - -@router.get("/projects/{project_id}/sstc-chain") -async def get_sstc_chain( - project_id: str, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - pid = _parse_project_id(project_id) - svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) - await _ensure_project(svc, pid) - return success_response(await svc.build_chain(pid)) - - -@router.get("/projects/{project_id}/signals") -async def list_signals( - project_id: str, - status: Optional[str] = Query(default=None, description="pending/linked/closed/archived"), - keyword: Optional[str] = Query(default=None, description="按信号标题/内容/ID/提报人/关联宪章搜索"), - source: Optional[str] = Query(default=None, description="来源过滤"), - priority: Optional[str] = Query(default=None, description="优先级过滤"), - created_from: Optional[str] = Query(default=None, description="创建时间起始(北京时间 YYYY-MM-DD)"), - created_to: Optional[str] = Query(default=None, description="创建时间结束(北京时间 YYYY-MM-DD)"), - limit: int = Query(default=10, ge=1, le=100), - offset: int = Query(default=0, ge=0), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - pid = _parse_project_id(project_id) - tenant_id = int(current_user.tenant_id) - svc = SSTCService(db, tenant_id=tenant_id) - project = await _ensure_project(svc, pid) - project_creator_name = "-" - if project and getattr(project, "owner_id", None): - owner = ( - await db.execute( - select(User.name).where( - and_( - User.tenant_id == tenant_id, - User.id == int(project.owner_id), - ) - ).limit(1) - ) - ).scalars().first() - if owner: - project_creator_name = str(owner) - signals = await svc.get_signals(pid) - await _backfill_feishu_sender_names( - db, - tenant_id=tenant_id, - signals=signals, - ) - charters = await svc.get_charters(pid) - linked_signal_states: dict[int, str] = {} - linked_signal_charter_name: dict[int, str] = {} - for c in charters: - signal_ids = _extract_charter_related_signal_ids(c) - charter_status = (c.status or "").lower() - state = ( - _SIGNAL_STATUS_CODE_CLOSED - if charter_status in {"aligned", "active", "online", "archived", "已对齐", "已上线", "已归档"} - else _SIGNAL_STATUS_CODE_LINKED - ) - for sid in signal_ids: - if linked_signal_states.get(sid) == _SIGNAL_STATUS_CODE_CLOSED: - continue - linked_signal_states[sid] = state - linked_signal_charter_name[sid] = c.title - normalized_status_filter = _normalize_signal_status_filter(status) - created_from_utc, created_to_utc = _build_signal_created_at_range( - created_from=created_from, - created_to=created_to, - ) - rows = [] - for s in signals: - strategic_payload = _as_dict(s.strategic_analysis) - ai_analysis = _signal_ai_analysis(s) - source_context = _signal_source_context(s) - project_scope = _signal_project_scope(s) - proposer_name = _signal_proposer_name(s) - title_text = _signal_title_text(s) - normalized_status = str(s.status or "").strip() - sid = int(s.id) - status_code = _normalize_signal_status_code( - linked_state=linked_signal_states.get(sid), - signal_status=normalized_status, - processed=bool(s.processed), - ) - row_status = _signal_status_label_from_code(status_code, normalized_status) - locked_by_charter = sid in linked_signal_states or ("关联" in normalized_status) - can_create_charter = not locked_by_charter - charter_lock_reason = None if can_create_charter else _SIGNAL_LOCK_REASON - row = { - "id": str(s.id), - "title": title_text[:80] or "未命名信号", - "content": s.content, - "priority": s.priority, - "signal_type": _signal_display_type(s), - "source": s.source, - "project_creator_name": project_creator_name, - "proposer_name": proposer_name, - "proposer_open_id": source_context.get("sender_open_id"), - "project_scope": project_scope, - "scope_reason": ai_analysis.get("reason"), - "target_project_hints": ai_analysis.get("target_project_hints", []), - "status": row_status, - "status_code": status_code, - "can_create_charter": can_create_charter, - "charter_lock_reason": charter_lock_reason, - "charter_name": linked_signal_charter_name.get(sid), - "created_at": s.created_at.isoformat() if s.created_at else None, - "attachments": _extract_signal_attachments(s), - "strategic_analysis": strategic_payload, - "planner_status": _normalize_planner_status(getattr(s, "planner_status", None)), - "planner_analyzed_at": ( - s.planner_analyzed_at.isoformat() if getattr(s, "planner_analyzed_at", None) else None - ), - "planner_assignee_user_id": ( - str(s.planner_assignee_user_id) if getattr(s, "planner_assignee_user_id", None) is not None else None - ), - } - created_at_dt = s.created_at - if created_at_dt is not None and created_at_dt.tzinfo is not None: - created_at_dt = created_at_dt.astimezone(CN_TZ).replace(tzinfo=None) - if not _match_signal_filters( - row=row, - status_filter=normalized_status_filter, - keyword=keyword, - source=source, - priority=priority, - created_from_utc=created_from_utc, - created_to_utc=created_to_utc, - created_at_dt=created_at_dt, - ): - continue - rows.append(row) - total = len(rows) - paged_rows = rows[offset : offset + limit] - return success_response( - { - "items": paged_rows, - "total": total, - "limit": limit, - "offset": offset, - "has_more": (offset + len(paged_rows)) < total, - } - ) - - -@router.post("/projects/{project_id}/signals/{signal_id}/attachments") -async def upload_signal_attachment( - project_id: str, - signal_id: str, - file: UploadFile | None = File(default=None), - permission_user: User = Depends(require_project_member_or_manager), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - _ = permission_user - try: - pid = _parse_project_id(project_id) - except HTTPException: - return _error_json(status_code=400, message="project_id 不合法") - try: - sid = _parse_node_id(signal_id, "signal_id") - except HTTPException: - return _error_json(status_code=400, message="signal_id 不合法") - if file is None: - return _error_json(status_code=400, message="请选择要上传的附件") - tenant_id = int(current_user.tenant_id) - svc = SSTCService(db, tenant_id=tenant_id) - - project = await svc.get_project(pid) - if project is None: - return _error_json(status_code=404, message="项目不存在") - - signals = await svc.get_signals(pid) - signal = next((item for item in signals if int(item.id) == sid), None) - if signal is None: - return _error_json(status_code=404, message="信号不存在或不属于当前项目") - - original_file_name = Path(str(file.filename or "")).name.strip() - if not original_file_name: - return _error_json(status_code=400, message="文件名不能为空") - - extension = Path(original_file_name).suffix.lower() - attachment_type = _SIGNAL_ATTACHMENT_ALLOWED_EXTENSIONS.get(extension) - if attachment_type is None: - return _error_json(status_code=400, message="仅支持 jpg/png/gif/pdf/doc/docx/zip 文件") - - mime_type = str(file.content_type or "").strip().lower() or None - if mime_type and mime_type not in _SIGNAL_ATTACHMENT_ALLOWED_MIME_TYPES: - return _error_json(status_code=400, message="文件类型不支持,请上传 jpg/png/gif/pdf/doc/docx/zip") - - try: - content = await file.read() - except Exception: - return _error_json(status_code=500, message="读取上传文件失败") - - if not content: - return _error_json(status_code=400, message="上传文件为空") - if len(content) > _SIGNAL_ATTACHMENT_MAX_BYTES: - return _error_json(status_code=400, message="单文件大小不能超过 20MB") - - attachment_id = uuid4().hex - relative_dir = Path(f"tenant-{tenant_id}") / f"project-{pid}" / f"signal-{sid}" - storage_root = _signal_attachment_storage_root() - save_dir = storage_root / relative_dir - save_dir.mkdir(parents=True, exist_ok=True) - saved_file_name = f"{attachment_id}{extension}" - saved_path = save_dir / saved_file_name - try: - saved_path.write_bytes(content) - except Exception: - return _error_json(status_code=500, message="保存附件失败,请稍后重试") - - file_key = f"local://{(relative_dir / saved_file_name).as_posix()}" - attachment_payload = { - "id": attachment_id, - "attachment_id": attachment_id, - "attachment_type": attachment_type, - "file_name": original_file_name, - "mime_type": mime_type or "application/octet-stream", - "file_size": len(content), - "file_key": file_key, - "image_key": None, - "media_id": None, - "source": "manual_upload", - "occurred_at": now_cn_aware().isoformat(), - "download_url": _build_signal_attachment_download_url(pid, sid, attachment_id), - } - - raw_payload = _as_dict(signal.strategic_analysis) - source_context = raw_payload.get("source_context") - if not isinstance(source_context, dict): - source_context = {} - attachments = source_context.get("attachments") - if not isinstance(attachments, list): - attachments = [] - attachments.append(attachment_payload) - source_context["attachments"] = attachments - raw_payload["source_context"] = source_context - signal.strategic_analysis = json.dumps(raw_payload, ensure_ascii=False) - - try: - await db.commit() - await db.refresh(signal) - except Exception: - await db.rollback() - try: - saved_path.unlink() - except Exception: - pass - return _error_json(status_code=500, message="上传附件失败,请稍后重试") - - return success_response(attachment_payload) - - -@router.get("/projects/{project_id}/signals/{signal_id}/attachments/{attachment_id}/download") -async def download_signal_attachment( - project_id: str, - signal_id: str, - attachment_id: str, - permission_user: User = Depends(require_project_member_or_manager), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - _ = permission_user - pid = _parse_project_id(project_id) - sid = _parse_node_id(signal_id, "signal_id") - tenant_id = int(current_user.tenant_id) - svc = SSTCService(db, tenant_id=tenant_id) - await _ensure_project(svc, pid) - signals = await svc.get_signals(pid) - signal = next((item for item in signals if int(item.id) == sid), None) - if signal is None: - raise HTTPException(status_code=404, detail="Signal not found") - attachments = _extract_signal_attachments(signal) - target = next( - ( - item - for item in attachments - if str(item.get("attachment_id") or "").strip() == attachment_id - or str(item.get("id") or "").strip() == attachment_id - ), - None, - ) - if target is None: - raise HTTPException(status_code=404, detail="Attachment not found") - - local_path = _resolve_local_attachment_path(str(target.get("file_key") or "")) - if local_path is not None: - if not local_path.exists() or not local_path.is_file(): - raise HTTPException(status_code=404, detail="附件文件不存在") - try: - content = local_path.read_bytes() - except Exception as exc: - raise HTTPException(status_code=500, detail=f"附件读取失败: {str(exc)}") from exc - if not content: - raise HTTPException(status_code=500, detail="附件内容为空") - file_name = str(target.get("file_name") or f"attachment-{attachment_id}").strip().replace('"', "'") - media_type = str(target.get("mime_type") or "application/octet-stream") - return StreamingResponse( - BytesIO(content), - media_type=media_type, - headers={"Content-Disposition": f'attachment; filename="{file_name}"'}, - ) - - feishu_service = get_feishu_service(db) - await feishu_service.load_config(tenant_id) - if not feishu_service.is_configured(): - raise HTTPException(status_code=503, detail="飞书集成未配置,暂无法下载附件") - - try: - payload = await feishu_service.download_attachment_bytes(target) - except ValueError as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc - except Exception as exc: - raise HTTPException(status_code=500, detail=f"附件下载失败: {str(exc)}") from exc - - file_name = str(payload.get("file_name") or target.get("file_name") or f"attachment-{attachment_id}").strip() - file_name = file_name.replace('"', "'") - media_type = str(payload.get("content_type") or target.get("mime_type") or "application/octet-stream") - content = payload.get("content") - if not isinstance(content, (bytes, bytearray)): - raise HTTPException(status_code=500, detail="附件内容为空") - return StreamingResponse( - BytesIO(bytes(content)), - media_type=media_type, - headers={"Content-Disposition": f'attachment; filename="{file_name}"'}, - ) - - -@router.post("/projects/{project_id}/signals/{signal_id}/archive") -async def archive_signal( - project_id: str, - signal_id: str, - permission_user: User = Depends(require_project_member_or_manager), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - _ = permission_user - pid = _parse_project_id(project_id) - sid = _parse_node_id(signal_id, "signal_id") - svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) - await _ensure_project(svc, pid) - result = await db.execute( - select(Signal).where( - and_(Signal.id == sid, Signal.tenant_id == int(current_user.tenant_id)) - ) - ) - signal = result.scalar_one_or_none() - if not signal: - raise HTTPException(status_code=404, detail="Signal not found") - before = {"processed": signal.processed} - signal.processed = True - signal.status = "已关闭" - await log_operation( - db, - tenant_id=int(current_user.tenant_id), - user_id=int(current_user.id), - project_id=pid, - entity_type="signal", - entity_id=str(sid), - action="archive", - before_data=before, - after_data={"processed": True}, - ) - await db.commit() - return success_response({"id": str(signal.id), "status": "已关闭"}) - - -class CharterCreateRequest(BaseModel): - title: str - content: dict[str, Any] | None = None - signal_ids: list[int] - skip_ai_prefill: bool = False - - -class CharterAIPrefillRequest(BaseModel): - force: bool = False - raw_text: Optional[str] = None - - -@router.get("/projects/{project_id}/charters") -async def list_charters( - project_id: str, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - pid = _parse_project_id(project_id) - svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) - await _ensure_project(svc, pid) - charters = await svc.get_charters(pid) - payload = [ - { - "id": str(c.id), - "title": c.title, - "content": _as_dict(c.content), - "signal_ids": _as_dict(c.content).get("signal_ids", []), - "status": c.status, - "version": c.version, - "parent_id": str(c.parent_id) if c.parent_id else None, - "created_at": c.created_at.isoformat() if c.created_at else None, - } - for c in charters - ] - return success_response(payload) - - -@router.post("/projects/{project_id}/charters") -async def create_charter( - project_id: str, - body: CharterCreateRequest, - permission_user: User = Depends(require_project_member_or_manager), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - _ = permission_user - _require_manager(current_user) - if not body.signal_ids: - raise HTTPException(status_code=400, detail="宪章必须关联至少一个信号") - pid = _parse_project_id(project_id) - tenant_id = int(current_user.tenant_id) - svc = SSTCService(db, tenant_id=tenant_id) - await _ensure_project(svc, pid) - - link_result = await db.execute( - select(ProjectSignalLink.signal_id).where( - and_( - ProjectSignalLink.project_id == pid, - ProjectSignalLink.signal_id.in_(body.signal_ids), - ProjectSignalLink.tenant_id == tenant_id, - ) - ) - ) - valid_signal_ids = {int(v) for v in link_result.scalars().all()} - if set(body.signal_ids) - valid_signal_ids: - raise HTTPException(status_code=400, detail="部分信号未关联到当前项目") - - signal_ids = sorted(set(body.signal_ids)) - existing_charters = await svc.get_charters(pid) - conflict_signal_ids, conflict_charters = _collect_locked_signal_conflicts( - charters=existing_charters, - candidate_signal_ids=signal_ids, - ) - if conflict_signal_ids: - raise HTTPException( - status_code=409, - detail={ - "message": _SIGNAL_LOCK_REASON, - "conflict_signal_ids": conflict_signal_ids, - "conflict_charters": conflict_charters, - }, - ) - signal_rows = ( - await db.execute( - select(Signal).where( - and_( - Signal.tenant_id == tenant_id, - Signal.id.in_(signal_ids), - ) - ) - ) - ).scalars().all() - - content = dict(body.content or {}) - raw_text = str(content.get("raw_source_text") or "").strip() - if not body.skip_ai_prefill and not _has_non_empty_module_data(content): - ai_prefill = await _build_ai_first_charter_content( - db=db, - tenant_id=tenant_id, - project_id=pid, - signal_rows=list(signal_rows), - signal_ids=signal_ids, - raw_text=raw_text, - ) - content.update(ai_prefill) - content["signal_ids"] = signal_ids - primary_signal_id = content["signal_ids"][0] if content["signal_ids"] else None - charter = TruthNode( - node_type="charter", - parent_id=None, - project_id=pid, - signal_id=primary_signal_id, - created_by=int(current_user.id), - title=compact_title(body.title, max_len=24), - content=content, - version=1, - status="draft", - tenant_id=tenant_id, - ) - db.add(charter) - await log_operation( - db, - tenant_id=tenant_id, - user_id=int(current_user.id), - project_id=pid, - entity_type="charter", - entity_id="new", - action="create", - after_data={"title": charter.title, "signal_ids": content["signal_ids"]}, - ) - - for signal in signal_rows: - signal.status = "已关联宪章" - signal.processed = False - - await db.commit() - await db.refresh(charter) - _sync_pmflow_rag_async( - tenant_id=tenant_id, - record_type="charter", - record_id=int(charter.id), - project_id=pid, - title=charter.title, - content=_as_dict(charter.content), - ) - return success_response( - { - "id": str(charter.id), - "title": charter.title, - "content": _as_dict(charter.content), - "signal_ids": _as_dict(charter.content).get("signal_ids", []), - "status": charter.status, - "version": charter.version, - } - ) - - -@router.get("/projects/{project_id}/charters/{charter_id}") -async def get_charter_detail( - project_id: str, - charter_id: str, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - pid = _parse_project_id(project_id) - cid = _parse_node_id(charter_id, "charter_id") - tenant_id = int(current_user.tenant_id) - svc = SSTCService(db, tenant_id=tenant_id) - await _ensure_project(svc, pid) - charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) - - content = _as_dict(charter.content) - signal_ids = [sid for sid in content.get("signal_ids", []) if isinstance(sid, int)] - signal_rows = [] - if signal_ids: - signal_result = await db.execute( - select(Signal).where( - and_(Signal.id.in_(signal_ids), Signal.tenant_id == tenant_id) - ) - ) - signal_rows = list(signal_result.scalars().all()) - await _backfill_feishu_sender_names( - db, - tenant_id=tenant_id, - signals=signal_rows, - ) - - task_result = await db.execute( - select(Task).where( - and_( - Task.truth_node_id == cid, - Task.project_id == pid, - Task.tenant_id == tenant_id, - ) - ) - ) - tasks = list(task_result.scalars().all()) - - align_result = await db.execute( - select(AlignmentRecord).where( - and_( - AlignmentRecord.truth_node_id == cid, - AlignmentRecord.tenant_id == tenant_id, - ) - ) - ) - alignments = list(align_result.scalars().all()) - source_signal = None - if charter.signal_id: - for signal in signal_rows: - if int(signal.id) == int(charter.signal_id): - source_signal = signal - break - if source_signal is None and signal_rows: - source_signal = signal_rows[0] - - return success_response( - { - "id": str(charter.id), - "title": charter.title, - "content": content, - "signal_ids": signal_ids, - "signals": [ - { - "id": str(s.id), - "source": s.source, - "signal_type": _signal_display_type(s), - "project_scope": _signal_project_scope(s), - "proposer_name": _signal_proposer_name(s), - "title": _signal_title_text(s)[:80] or "未命名信号", - "content": s.content, - "priority": s.priority, - "impact_score": s.impact_score, - "attachments": _extract_signal_attachments(s), - "strategic_analysis": _as_dict(s.strategic_analysis), - "planner_status": _normalize_planner_status(getattr(s, "planner_status", None)), - "planner_analyzed_at": ( - s.planner_analyzed_at.isoformat() if getattr(s, "planner_analyzed_at", None) else None - ), - "planner_assignee_user_id": ( - str(s.planner_assignee_user_id) if getattr(s, "planner_assignee_user_id", None) is not None else None - ), - } - for s in signal_rows - ], - "source_signal": ( - { - "id": str(source_signal.id), - "title": _signal_title_text(source_signal)[:80] or "未命名信号", - "signal_type": _signal_display_type(source_signal), - "priority": source_signal.priority, - "content": source_signal.content, - "attachments": _extract_signal_attachments(source_signal), - "strategic_analysis": _as_dict(source_signal.strategic_analysis), - "planner_status": _normalize_planner_status(getattr(source_signal, "planner_status", None)), - "planner_analyzed_at": ( - source_signal.planner_analyzed_at.isoformat() - if getattr(source_signal, "planner_analyzed_at", None) - else None - ), - "planner_assignee_user_id": ( - str(source_signal.planner_assignee_user_id) - if getattr(source_signal, "planner_assignee_user_id", None) is not None - else None - ), - } - if source_signal - else None - ), - "tasks": [ - { - "id": str(t.id), - "title": t.title, - "description": t.description, - "status": t.status, - "priority": t.priority, - "estimated_hours": float(t.estimated_hours) if t.estimated_hours else None, - "assignee_id": str(t.assignee_id) if t.assignee_id else None, - "due_date": t.due_date.isoformat() if t.due_date else None, - } - for t in tasks - ], - "alignment_records": [ - { - "user_id": str(r.user_id), - "score": r.alignment_score, - "confirmed": bool(r.is_confirmed), - "comments": r.comments, - } - for r in alignments - ], - "status": charter.status, - "version": charter.version, - } - ) - - -@router.patch("/projects/{project_id}/charters/{charter_id}") -async def update_charter( - project_id: str, - charter_id: str, - body: CharterUpdateRequest, - permission_user: User = Depends(require_project_member_or_manager), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - _ = permission_user - _require_manager(current_user) - pid = _parse_project_id(project_id) - cid = _parse_node_id(charter_id, "charter_id") - tenant_id = int(current_user.tenant_id) - await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) - charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) - - before = { - "title": charter.title, - "content": _as_dict(charter.content), - "version": charter.version, - "status": charter.status, - } - aligned = (charter.status or "").lower() in ALIGNED_STATUSES - if aligned and body.force_new_version: - new_node = TruthNode( - node_type="charter", - parent_id=int(charter.id), - project_id=pid, - created_by=int(current_user.id), - title=compact_title((body.title or charter.title), max_len=24), - content=body.content if body.content is not None else _as_dict(charter.content), - version=int(charter.version or 1) + 1, - status="draft", - tenant_id=tenant_id, - ) - db.add(new_node) - await log_operation( - db, - tenant_id=tenant_id, - user_id=int(current_user.id), - project_id=pid, - entity_type="charter", - entity_id=str(charter.id), - action="new_version", - before_data=before, - after_data={"new_charter_id": "pending"}, - ) - await db.commit() - await db.refresh(new_node) - await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=pid) - _sync_pmflow_rag_async( - tenant_id=tenant_id, - record_type="charter", - record_id=int(new_node.id), - project_id=pid, - title=new_node.title, - content=_as_dict(new_node.content), - ) - return success_response( - { - "id": str(new_node.id), - "version": new_node.version, - "status": new_node.status, - "title": new_node.title, - "content": _as_dict(new_node.content), - "new_version_created": True, - } - ) - - if body.title is not None: - charter.title = compact_title(body.title, max_len=24) - if body.content is not None: - charter.content = body.content - charter.status = "draft" - await log_operation( - db, - tenant_id=tenant_id, - user_id=int(current_user.id), - project_id=pid, - entity_type="charter", - entity_id=str(charter.id), - action="update", - before_data=before, - after_data={ - "title": charter.title, - "content": _as_dict(charter.content), - "version": charter.version, - "status": charter.status, - }, - ) - await db.commit() - await db.refresh(charter) - await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=pid) - _sync_pmflow_rag_async( - tenant_id=tenant_id, - record_type="charter", - record_id=int(charter.id), - project_id=pid, - title=charter.title, - content=_as_dict(charter.content), - ) - return success_response( - { - "id": str(charter.id), - "title": charter.title, - "content": _as_dict(charter.content), - "status": charter.status, - "version": charter.version, - "new_version_created": False, - } - ) - - -@router.post("/projects/{project_id}/charters/{charter_id}/ai-prefill") -async def ai_prefill_charter( - project_id: str, - charter_id: str, - body: CharterAIPrefillRequest = Body(default_factory=CharterAIPrefillRequest), - permission_user: User = Depends(require_project_member_or_manager), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - _ = permission_user - pid = _parse_project_id(project_id) - cid = _parse_node_id(charter_id, "charter_id") - tenant_id = int(current_user.tenant_id) - - await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) - charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) - before_content = _as_dict(charter.content) - before_status = str(charter.status or "") - - if not body.force and _has_complete_module_data(before_content): - return success_response( - { - "id": str(charter.id), - "title": charter.title, - "content": before_content, - "status": charter.status, - "version": charter.version, - "prefilled": False, - "skipped": True, - } - ) - - signal_ids = _extract_charter_signal_ids(before_content, charter.signal_id) - signal_rows: list[Signal] = [] - if signal_ids: - signal_rows = ( - await db.execute( - select(Signal).where( - and_( - Signal.tenant_id == tenant_id, - Signal.id.in_(signal_ids), - ) - ) - ) - ).scalars().all() - - raw_text = str(body.raw_text or before_content.get("raw_source_text") or "").strip() - if not signal_rows and not raw_text: - raise HTTPException(status_code=400, detail="未找到可用于预填的信号源或原始文本") - - generated = await _build_ai_first_charter_content( - db=db, - tenant_id=tenant_id, - project_id=pid, - signal_rows=signal_rows, - signal_ids=signal_ids, - raw_text=raw_text, - ) - - merged = _merge_prefill_content( - before_content, generated, overwrite_existing=bool(body.force) - ) - merged["signal_ids"] = signal_ids - if raw_text: - merged["raw_source_text"] = raw_text - charter.content = merged - charter.status = "draft" - - await log_operation( - db, - tenant_id=tenant_id, - user_id=int(current_user.id), - project_id=pid, - entity_type="charter", - entity_id=str(cid), - action="ai_prefill", - before_data={"content": before_content, "status": before_status}, - after_data={"content": merged, "status": "draft"}, - ) - - await db.commit() - await db.refresh(charter) - _sync_pmflow_rag_async( - tenant_id=tenant_id, - record_type="charter", - record_id=int(charter.id), - project_id=pid, - title=charter.title, - content=_as_dict(charter.content), - ) - return success_response( - { - "id": str(charter.id), - "title": charter.title, - "content": _as_dict(charter.content), - "status": charter.status, - "version": charter.version, - "prefilled": True, - "skipped": False, - } - ) - - -@router.post("/projects/{project_id}/charters/{charter_id}/alignment/start") -async def start_alignment( - project_id: str, - charter_id: str, - body: AlignmentStartRequest, - permission_user: User = Depends(require_project_member_or_manager), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - _ = permission_user - pid = _parse_project_id(project_id) - cid = _parse_node_id(charter_id, "charter_id") - tenant_id = int(current_user.tenant_id) - await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) - charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) - before = {"status": charter.status} - charter.status = "aligning" - await log_operation( - db, - tenant_id=tenant_id, - user_id=int(current_user.id), - project_id=pid, - entity_type="charter", - entity_id=str(cid), - action="start_alignment", - before_data=before, - after_data={"status": charter.status, "note": body.note}, - ) - await db.commit() - return success_response({"id": str(cid), "status": charter.status}) - - -@router.post("/projects/{project_id}/charters/{charter_id}/alignment/confirm") -async def confirm_alignment( - project_id: str, - charter_id: str, - body: AlignmentConfirmRequest, - permission_user: User = Depends(require_project_member_or_manager), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - _ = permission_user - pid = _parse_project_id(project_id) - cid = _parse_node_id(charter_id, "charter_id") - tenant_id = int(current_user.tenant_id) - svc = SSTCService(db, tenant_id=tenant_id) - await _ensure_project(svc, pid) - charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) - - row_result = await db.execute( - select(AlignmentRecord).where( - and_( - AlignmentRecord.truth_node_id == cid, - AlignmentRecord.user_id == int(current_user.id), - AlignmentRecord.tenant_id == tenant_id, - ) - ) - ) - row = row_result.scalar_one_or_none() - if row is None: - row = AlignmentRecord( - truth_node_id=cid, - user_id=int(current_user.id), - alignment_score=body.score, - is_confirmed=body.confirmed, - comments=body.comments, - tenant_id=tenant_id, - ) - db.add(row) - else: - row.alignment_score = body.score - row.is_confirmed = body.confirmed - row.comments = body.comments - - member_ids = await svc.get_project_member_ids(pid) - confirmed_map: set[int] = set() - rows_result = await db.execute( - select(AlignmentRecord).where( - and_( - AlignmentRecord.truth_node_id == cid, - AlignmentRecord.tenant_id == tenant_id, - AlignmentRecord.is_confirmed.is_(True), - ) - ) - ) - for r in rows_result.scalars().all(): - confirmed_map.add(int(r.user_id)) - all_confirmed = set(member_ids).issubset(confirmed_map) if member_ids else False - if all_confirmed: - charter.status = "aligned" - elif (charter.status or "").lower() not in {"aligned"}: - charter.status = "aligning" - - if all_confirmed: - content = _as_dict(charter.content) - signal_ids = [sid for sid in content.get("signal_ids", []) if isinstance(sid, int)] - if charter.signal_id and int(charter.signal_id) not in signal_ids: - signal_ids.append(int(charter.signal_id)) - if signal_ids: - signal_rows = ( - await db.execute( - select(Signal).where( - and_( - Signal.tenant_id == tenant_id, - Signal.id.in_(signal_ids), - ) - ) - ) - ).scalars().all() - for signal in signal_rows: - signal.status = "已关闭" - signal.processed = True - - await log_operation( - db, - tenant_id=tenant_id, - user_id=int(current_user.id), - project_id=pid, - entity_type="alignment", - entity_id=f"{cid}:{current_user.id}", - action="confirm", - after_data={ - "score": body.score, - "confirmed": body.confirmed, - "all_confirmed": all_confirmed, - "charter_status": charter.status, - }, - ) - await db.commit() - return success_response( - { - "charter_id": str(cid), - "charter_status": charter.status, - "all_confirmed": all_confirmed, - "confirmed_count": len(confirmed_map), - "member_count": len(member_ids), - } - ) - - -class TaskCreateRequest(BaseModel): - title: str - description: Optional[str] = None - required_skill_tag_ids: Optional[list[int]] = None - priority: str = "medium" - estimated_hours: Optional[float] = None - due_date: Optional[str] = None - - -class TaskUpdateRequest(BaseModel): - title: Optional[str] = None - description: Optional[str] = None - assignee_id: Optional[int] = None - required_skill_tag_ids: Optional[list[int]] = None - priority: Optional[str] = None - estimated_hours: Optional[float] = None - due_date: Optional[str] = None - - -@router.post("/projects/{project_id}/charters/{charter_id}/tasks") -async def create_task( - project_id: str, - charter_id: str, - body: TaskCreateRequest, - permission_user: User = Depends(require_project_member_or_manager), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - _ = permission_user - _require_manager(current_user) - pid = _parse_project_id(project_id) - cid = _parse_node_id(charter_id, "charter_id") - tenant_id = int(current_user.tenant_id) - await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) - charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) - _assert_charter_aligned(charter) - - due_date = _parse_due_date(body.due_date) - - task = await persist_task( - db, - tenant_id=tenant_id, - truth_node_id=cid, - project_id=pid, - draft=build_task_create_draft( - title=body.title, - description=body.description, - required_skill_tag_ids=body.required_skill_tag_ids or [], - priority=body.priority, - status="todo", - estimated_hours=body.estimated_hours, - due_date=due_date, - ), - actor_user_id=int(current_user.id), - action="task.create", - remark="sstc.create_task", - commit=False, - refresh_workload=False, - ) - await db.commit() - await db.refresh(task) - if task.assignee_id is not None: - await workload_service.refresh_user_assigned_hours_from_tasks( - db, - tenant_id=tenant_id, - user_id=int(task.assignee_id), - ) - _sync_pmflow_rag_async( - tenant_id=tenant_id, - record_type="task", - record_id=int(task.id), - project_id=pid, - title=task.title, - content=task.description or "", - ) - return success_response( - { - "id": str(task.id), - "title": task.title, - "status": task.status, - "priority": task.priority, - "charter_id": str(cid), - } - ) - - -@router.post("/projects/{project_id}/charters/{charter_id}/tasks/ai-breakdown") -async def ai_breakdown_tasks( - project_id: str, - charter_id: str, - permission_user: User = Depends(require_project_member_or_manager), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - _ = permission_user - _require_manager(current_user) - pid = _parse_project_id(project_id) - cid = _parse_node_id(charter_id, "charter_id") - tenant_id = int(current_user.tenant_id) - await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) - charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) - _assert_charter_aligned(charter) - - prompt = f"""请基于以下需求宪章拆解 5-8 条任务,返回 JSON 数组: -[ - {{"task_name":"", "type":"backend/frontend/ai/devops", "estimated_hours": 4.0, "priority":"P0/P1/P2"}} -] - -宪章标题:{charter.title} -宪章内容:{_as_dict(charter.content)} -""" - try: - rows = await llm_service.generate_task_breakdown_async( - prompt, - db=db, - tenant_id=tenant_id, - project_id=pid, - ) - except RuntimeError as exc: - raise HTTPException(status_code=403, detail=str(exc)) from exc - except Exception as exc: - raise HTTPException(status_code=500, detail=f"AI 拆解失败: {exc}") from exc - - created = [] - prompt_tokens = max(len(prompt) // 4, 1) - completion_tokens = 0 - for item in rows or []: - title = compact_title((item.get("task_name") or item.get("title") or ""), max_len=32) - if not title: - continue - priority = str(item.get("priority") or "P1").lower() - mapped_priority = "high" if priority == "p0" else "medium" if priority == "p1" else "low" - task = await persist_task( - db, - tenant_id=tenant_id, - truth_node_id=cid, - project_id=pid, - draft=build_task_create_draft( - title=title, - description=item.get("description"), - required_skill_tag_ids=[], - priority=mapped_priority, - status="todo", - estimated_hours=float(item.get("estimated_hours") or 0) or None, - ), - actor_user_id=int(current_user.id), - action="task.create", - remark="sstc.ai_breakdown", - commit=False, - refresh_workload=False, - ) - created.append(task) - completion_tokens += max(len(title) // 2, 1) - - db.add( - AICallLog( - tenant_id=tenant_id, - user_id=int(current_user.id), - function_name="sstc_ai_breakdown", - prompt=prompt, - response=f"created_count={len(created)}", - tokens_used=prompt_tokens + completion_tokens, - cost=((prompt_tokens * 0.00015 + completion_tokens * 0.0006) / 1000), - status="success", - ) - ) - await log_operation( - db, - tenant_id=tenant_id, - user_id=int(current_user.id), - project_id=pid, - entity_type="task", - entity_id=str(cid), - action="ai_breakdown", - after_data={"created_count": len(created)}, - ) - await db.commit() - for task in created: - _sync_pmflow_rag_async( - tenant_id=tenant_id, - record_type="task", - record_id=int(task.id), - project_id=pid, - title=task.title, - content=task.description or "", - ) - await context_cache_service.invalidate_system_context(tenant_id=tenant_id) - return success_response( - { - "created_count": len(created), - "items": [ - {"id": str(t.id), "title": t.title, "priority": t.priority, "status": t.status} - for t in created - ], - } - ) - - -@router.get("/projects/{project_id}/tasks/board") -async def get_task_board( - project_id: str, - assignee_id: Optional[int] = Query(default=None), - priority: Optional[str] = Query(default=None), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - pid = _parse_project_id(project_id) - tenant_id = int(current_user.tenant_id) - await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) - result = await db.execute( - select(Task).where( - and_( - Task.project_id == pid, - Task.tenant_id == tenant_id, - ) - ) - ) - tasks = list(result.scalars().all()) - if assignee_id is not None: - tasks = [t for t in tasks if t.assignee_id == assignee_id] - if priority: - tasks = [t for t in tasks if (t.priority or "").lower() == priority.lower()] - - rows = [] - for t in tasks: - risk_count = 0 - if (t.status or "").lower() == "blocked": - risk_count += 1 - if t.due_date and t.due_date < now_cn_aware().replace(tzinfo=None) and (t.status or "").lower() not in DONE_STATUSES: - risk_count += 1 - rows.append( - { - "id": str(t.id), - "title": t.title, - "description": t.description, - "status": t.status, - "priority": t.priority, - "assignee_id": str(t.assignee_id) if t.assignee_id else None, - "due_date": t.due_date.isoformat() if t.due_date else None, - "estimated_hours": float(t.estimated_hours) if t.estimated_hours else None, - "risk_count": risk_count, - "truth_node_id": str(t.truth_node_id), - } - ) - return success_response(rows) - - -@router.patch("/projects/{project_id}/tasks/{task_id}/status") -async def patch_task_status( - project_id: str, - task_id: str, - body: TaskStatusPatchRequest, - background_tasks: BackgroundTasks, - permission_user: User = Depends(require_project_member_or_manager), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - _ = permission_user - pid = _parse_project_id(project_id) - tid = _parse_node_id(task_id, "task_id") - tenant_id = int(current_user.tenant_id) - await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) - task = await _ensure_task(db, tenant_id=tenant_id, project_id=pid, task_id=tid) - if not _is_manager(current_user) and int(task.assignee_id or 0) != int(current_user.id): - raise HTTPException(status_code=403, detail="仅负责人或任务执行人可更新状态") - - before_status = (task.status or "").lower() - before = {"status": task.status} - task.status = body.status - if body.status in DONE_STATUSES: - task.completed_at = now_cn_naive() - await log_operation( - db, - tenant_id=tenant_id, - user_id=int(current_user.id), - project_id=pid, - entity_type="task", - entity_id=str(tid), - action="status_change", - before_data=before, - after_data={"status": body.status}, - ) - if task.assignee_id: - await workload_service.refresh_user_assigned_hours_from_tasks( - db, - tenant_id=tenant_id, - user_id=int(task.assignee_id), - ) - else: - await db.commit() - await context_cache_service.invalidate_system_context(tenant_id=tenant_id) - if task.assignee_id: - await context_cache_service.invalidate_user_context( - tenant_id=tenant_id, - user_id=int(task.assignee_id), - ) - became_done = (body.status or "").lower() in DONE_STATUSES and before_status not in DONE_STATUSES - if became_done and task.assignee_id: - background_tasks.add_task( - skill_score_service.process_task_completion, - task_id=int(task.id), - tenant_id=tenant_id, - operator_user_id=int(current_user.id), - ) - _sync_pmflow_rag_async( - tenant_id=tenant_id, - record_type="task", - record_id=int(task.id), - project_id=pid, - title=task.title, - content=(task.description or "") + f"\n状态: {task.status}", - ) - - return success_response({"id": str(task.id), "status": task.status}) - - -@router.patch("/projects/{project_id}/tasks/{task_id}") -async def patch_task( - project_id: str, - task_id: str, - body: TaskUpdateRequest, - permission_user: User = Depends(require_project_member_or_manager), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - _ = permission_user - _require_manager(current_user) - pid = _parse_project_id(project_id) - tid = _parse_node_id(task_id, "task_id") - tenant_id = int(current_user.tenant_id) - await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) - task = await _ensure_task(db, tenant_id=tenant_id, project_id=pid, task_id=tid) - - before = { - "title": task.title, - "description": task.description, - "assignee_id": task.assignee_id, - "priority": task.priority, - "estimated_hours": float(task.estimated_hours) if task.estimated_hours is not None else None, - "due_date": task.due_date.isoformat() if task.due_date else None, - } - - old_assignee_id = int(task.assignee_id) if task.assignee_id else None - if body.title is not None: - task.title = compact_title(body.title, max_len=32) - if body.description is not None: - task.description = body.description - if body.assignee_id is not None: - task.assignee_id = body.assignee_id - if body.required_skill_tag_ids is not None: - task.required_skill_tag_ids = body.required_skill_tag_ids - if body.priority is not None: - task.priority = body.priority - if body.estimated_hours is not None: - task.estimated_hours = body.estimated_hours - if body.due_date is not None: - task.due_date = _parse_due_date(body.due_date) - - await log_operation( - db, - tenant_id=tenant_id, - user_id=int(current_user.id), - project_id=pid, - entity_type="task", - entity_id=str(tid), - action="update", - before_data=before, - after_data={ - "title": task.title, - "description": task.description, - "assignee_id": task.assignee_id, - "priority": task.priority, - "estimated_hours": float(task.estimated_hours) if task.estimated_hours is not None else None, - "due_date": task.due_date.isoformat() if task.due_date else None, - }, - ) - - touched_user_ids: set[int] = set() - if old_assignee_id: - touched_user_ids.add(old_assignee_id) - if task.assignee_id: - touched_user_ids.add(int(task.assignee_id)) - - if touched_user_ids: - for uid in touched_user_ids: - await workload_service.refresh_user_assigned_hours_from_tasks( - db, - tenant_id=tenant_id, - user_id=uid, - ) - else: - await db.commit() - await context_cache_service.invalidate_system_context(tenant_id=tenant_id) - for uid in touched_user_ids: - await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=uid) - _sync_pmflow_rag_async( - tenant_id=tenant_id, - record_type="task", - record_id=int(task.id), - project_id=pid, - title=task.title, - content=task.description or "", - ) - - return success_response({"id": str(task.id), "status": task.status}) - - -@router.delete("/projects/{project_id}/tasks/{task_id}") -async def delete_task( - project_id: str, - task_id: str, - permission_user: User = Depends(require_project_member_or_manager), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - _ = permission_user - _require_manager(current_user) - pid = _parse_project_id(project_id) - tid = _parse_node_id(task_id, "task_id") - tenant_id = int(current_user.tenant_id) - await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) - task = await _ensure_task(db, tenant_id=tenant_id, project_id=pid, task_id=tid) - assignee_id = int(task.assignee_id) if task.assignee_id else None - - await log_operation( - db, - tenant_id=tenant_id, - user_id=int(current_user.id), - project_id=pid, - entity_type="task", - entity_id=str(tid), - action="delete", - before_data={ - "assignee_id": assignee_id, - "estimated_hours": float(task.estimated_hours) if task.estimated_hours else None, - "status": task.status, - }, - ) - await db.execute( - delete(Task).where( - and_( - Task.id == tid, - Task.project_id == pid, - Task.tenant_id == tenant_id, - ) - ) - ) - - if assignee_id: - await workload_service.refresh_user_assigned_hours_from_tasks( - db, - tenant_id=tenant_id, - user_id=assignee_id, - ) - else: - await db.commit() - await context_cache_service.invalidate_system_context(tenant_id=tenant_id) - if assignee_id: - await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=assignee_id) - - return success_response({"id": str(tid), "deleted": True}) - - -@router.post("/projects/{project_id}/tasks/{task_id}/recommend-assignee") -async def recommend_assignee_for_task( - project_id: str, - task_id: str, - permission_user: User = Depends(require_project_member_or_manager), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - _ = permission_user - pid = _parse_project_id(project_id) - tid = _parse_node_id(task_id, "task_id") - tenant_id = int(current_user.tenant_id) - await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) - task = await _ensure_task(db, tenant_id=tenant_id, project_id=pid, task_id=tid) - - required_skills = [str(x) for x in (task.required_skill_tag_ids or [])] - recommendations = await recommend_personnel( - db=db, - tenant_id=tenant_id, - required_skills=required_skills, - task_description=task.description or task.title, - task_id=str(task.id), - limit=5, - ) - - project_user_ids_result = await db.execute( - select(UserProject.user_id).where( - and_( - UserProject.tenant_id == tenant_id, - UserProject.project_id == pid, - ) - ) - ) - project_user_ids = {int(uid) for uid in project_user_ids_result.scalars().all()} - - active_users_result = await db.execute( - select(User.id).where( - and_( - User.tenant_id == tenant_id, - User.status == "active", - User.is_active.is_(True), - ) - ) - ) - active_user_ids = {int(uid) for uid in active_users_result.scalars().all()} - - filtered = [] - for item in recommendations.get("recommendations", []): - try: - uid = int(item.get("user_id")) - except (TypeError, ValueError): - continue - if uid in project_user_ids and uid in active_user_ids: - filtered.append(item) - - recommendations["recommendations"] = filtered - recommendations["total_count"] = len(filtered) - return success_response(recommendations) - - -@router.post("/projects/{project_id}/tasks/optimize-workload") -async def optimize_task_workload( - project_id: str, - body: WorkloadOptimizeRequest = Body(default_factory=WorkloadOptimizeRequest), - permission_user: User = Depends(require_project_member_or_manager), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - _ = permission_user - _require_manager(current_user) - pid = _parse_project_id(project_id) - tenant_id = int(current_user.tenant_id) - await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) - proposals = await workload_service.build_ai_optimize_preview( - db, - tenant_id=tenant_id, - project_id=pid, - max_moves=body.max_moves, - unassigned_only=body.mode == "unassigned", - ) - if not proposals: - return success_response( - { - "updated": 0, - "message": "no tasks to optimize", - "mode": body.mode, - } - ) - - optimize_result = await workload_service.apply_ai_optimize_plan( - db, - tenant_id=tenant_id, - proposals=proposals, - ) - updated = int(optimize_result.get("applied_count") or 0) - affected_users = int(optimize_result.get("affected_users") or 0) - - await log_operation( - db, - tenant_id=tenant_id, - user_id=int(current_user.id), - project_id=pid, - entity_type="task", - entity_id=str(pid), - action="optimize_workload", - after_data={ - "updated": updated, - "affected_users": affected_users, - "proposals": len(proposals), - "mode": body.mode, - }, - ) - await db.commit() - return success_response( - { - "updated": updated, - "affected_users": affected_users, - "proposals": len(proposals), - "mode": body.mode, - } - ) - - -@router.get("/projects/{project_id}/releases") -async def list_releases( - project_id: str, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - pid = _parse_project_id(project_id) - svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) - await _ensure_project(svc, pid) - releases = await svc.get_releases(pid) - return success_response( - [ - { - "id": str(r.id), - "title": r.title, - "status": r.status, - "content": _as_dict(r.content), - "version": r.version, - "created_at": r.created_at.isoformat() if r.created_at else None, - } - for r in releases - ] - ) - - -@router.post("/projects/{project_id}/releases") -async def create_release_plan( - project_id: str, - body: ReleaseCreateRequest, - permission_user: User = Depends(require_project_member_or_manager), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - _ = permission_user - _require_manager(current_user) - pid = _parse_project_id(project_id) - tenant_id = int(current_user.tenant_id) - svc = SSTCService(db, tenant_id=tenant_id) - await _ensure_project(svc, pid) - - charter = await _ensure_charter( - db, - tenant_id=tenant_id, - project_id=pid, - charter_id=int(body.charter_id), - ) - - if not body.task_ids: - raise HTTPException(status_code=400, detail="发布计划必须绑定至少一个任务") - - task_result = await db.execute( - select(Task).where( - and_( - Task.id.in_(body.task_ids), - Task.project_id == pid, - Task.tenant_id == tenant_id, - ) - ) - ) - tasks = list(task_result.scalars().all()) - if len(tasks) != len(body.task_ids): - raise HTTPException(status_code=400, detail="部分任务不存在") - not_done = [t for t in tasks if (t.status or "").lower() not in DONE_STATUSES] - if not_done: - raise HTTPException(status_code=409, detail="发布计划仅允许绑定已完成任务") - - governance = await _build_governance_snapshot( - db, - tenant_id=tenant_id, - project_id=pid, - charter_id=int(charter.id), - ) - content = { - "charter_id": int(charter.id), - "task_ids": [int(t.id) for t in tasks], - "planned_at": body.planned_at, - "verification_items": [], - "verification_done": False, - "verification_progress": {"done": 0, "total": 0}, - "governance_snapshot": governance, - } - release_node = TruthNode( - tenant_id=tenant_id, - node_type="release", - parent_id=int(charter.id), - project_id=pid, - created_by=int(current_user.id), - title=compact_title(body.title, max_len=24), - content=content, - version=1, - status="pending", - ) - db.add(release_node) - await log_operation( - db, - tenant_id=tenant_id, - user_id=int(current_user.id), - project_id=pid, - entity_type="release", - entity_id="new", - action="create", - after_data={"title": release_node.title, "task_ids": body.task_ids}, - ) - await db.commit() - await db.refresh(release_node) - return success_response( - { - "id": str(release_node.id), - "title": release_node.title, - "status": release_node.status, - "content": _as_dict(release_node.content), - } - ) - - -@router.patch("/projects/{project_id}/releases/{release_node_id}/status") -async def patch_release_status( - project_id: str, - release_node_id: str, - body: ReleaseStatusPatchRequest, - permission_user: User = Depends(require_project_member_or_manager), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - _ = permission_user - _require_manager(current_user) - pid = _parse_project_id(project_id) - rid = _parse_node_id(release_node_id, "release_node_id") - tenant_id = int(current_user.tenant_id) - await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) - release_node = await _ensure_release( - db, tenant_id=tenant_id, project_id=pid, release_node_id=rid - ) - await _assert_release_transition_allowed( - db, - tenant_id=tenant_id, - project_id=pid, - release_node=release_node, - target_status=body.status, - ) - before = {"status": release_node.status} - release_node.status = body.status - content = _as_dict(release_node.content) - content["status_note"] = body.note - content["last_status_changed_at"] = now_cn_aware().isoformat() - release_node.content = content - await log_operation( - db, - tenant_id=tenant_id, - user_id=int(current_user.id), - project_id=pid, - entity_type="release", - entity_id=str(rid), - action="status_change", - before_data=before, - after_data={"status": body.status, "note": body.note}, - ) - await db.commit() - return success_response({"id": str(release_node.id), "status": release_node.status}) - - -@router.post("/projects/{project_id}/releases/{release_node_id}/verification/generate") -async def generate_release_verification( - project_id: str, - release_node_id: str, - permission_user: User = Depends(require_project_member_or_manager), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - _ = permission_user - _require_manager(current_user) - pid = _parse_project_id(project_id) - rid = _parse_node_id(release_node_id, "release_node_id") - tenant_id = int(current_user.tenant_id) - await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) - release_node = await _ensure_release( - db, tenant_id=tenant_id, project_id=pid, release_node_id=rid - ) - - content = _as_dict(release_node.content) - charter_id = int(content.get("charter_id") or 0) - charter = await _ensure_charter( - db, - tenant_id=tenant_id, - project_id=pid, - charter_id=charter_id, - ) - charter_content = _as_dict(charter.content) - success_metrics = charter_content.get("success_metrics") or charter_content.get("core_metrics") or [] - if not success_metrics: - success_metrics = ["功能验收通过", "核心流程无阻塞", "关键性能指标达标"] - - items = [ - { - "id": f"check-{i+1}", - "title": str(metric), - "expected": "达到预期", - "status": "pending", - "required": True, - "source": "charter_success_metric", - "verified_by": None, - "verified_at": None, - "note": None, - } - for i, metric in enumerate(success_metrics) - ] - content["verification_items"] = items - content["verification_progress"] = {"done": 0, "total": len(items)} - content["verification_done"] = False - release_node.content = content - await db.commit() - return success_response({"release_id": str(release_node.id), "verification_items": items}) - - -@router.patch("/projects/{project_id}/releases/{release_node_id}/verification/items/{item_id}") -async def patch_release_verification_item( - project_id: str, - release_node_id: str, - item_id: str, - body: ReleaseVerificationItemPatchRequest, - permission_user: User = Depends(require_project_member_or_manager), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - _ = permission_user - _require_manager(current_user) - pid = _parse_project_id(project_id) - rid = _parse_node_id(release_node_id, "release_node_id") - tenant_id = int(current_user.tenant_id) - await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) - release_node = await _ensure_release( - db, tenant_id=tenant_id, project_id=pid, release_node_id=rid - ) - content = _as_dict(release_node.content) - items = content.get("verification_items") - if not isinstance(items, list) or len(items) == 0: - raise HTTPException(status_code=404, detail="验证清单不存在,请先生成") - - target = None - for item in items: - if str(item.get("id")) == item_id: - target = item - break - if target is None: - raise HTTPException(status_code=404, detail="验证项不存在") - - target["status"] = body.status - if body.status == "done": - target["verified_by"] = str(current_user.id) - target["verified_at"] = now_cn_aware().isoformat() - else: - target["verified_by"] = None - target["verified_at"] = None - target["note"] = body.note - - done_count = len([item for item in items if str(item.get("status") or "").lower() == "done"]) - content["verification_items"] = items - content["verification_progress"] = {"done": done_count, "total": len(items)} - content["verification_done"] = done_count == len(items) and len(items) > 0 - release_node.content = content - await db.commit() - return success_response( - { - "release_id": str(release_node.id), - "item_id": item_id, - "verification_progress": content["verification_progress"], - "verification_done": content["verification_done"], - } - ) - - -@router.post("/projects/{project_id}/releases/{release_node_id}/verification/complete") -async def complete_release_verification( - project_id: str, - release_node_id: str, - permission_user: User = Depends(require_project_member_or_manager), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - _ = permission_user - _require_manager(current_user) - pid = _parse_project_id(project_id) - rid = _parse_node_id(release_node_id, "release_node_id") - tenant_id = int(current_user.tenant_id) - await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) - release_node = await _ensure_release( - db, tenant_id=tenant_id, project_id=pid, release_node_id=rid - ) - status_raw = str(release_node.status or "").lower() - if status_raw not in {"online", "releasing"}: - raise HTTPException(status_code=409, detail="仅上线中的发布计划可完成验证") - content = _as_dict(release_node.content) - items = content.get("verification_items", []) - if not isinstance(items, list) or len(items) == 0: - raise HTTPException(status_code=409, detail="验证清单为空,请先生成并完成验证项") - incomplete = [item for item in items if str(item.get("status") or "").lower() != "done"] - if incomplete: - raise HTTPException(status_code=409, detail="仍有未完成验证项,请先逐项完成") - now_str = now_cn_aware().isoformat() - content["verification_items"] = items - content["verification_done"] = True - content["verification_progress"] = {"done": len(items), "total": len(items)} - content["verified_at"] = now_str - release_node.content = content - release_node.status = "verified" - await db.commit() - return success_response({"id": str(release_node.id), "status": release_node.status}) - - -@router.get("/projects/{project_id}/reviews") -async def list_reviews( - project_id: str, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - pid = _parse_project_id(project_id) - svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) - await _ensure_project(svc, pid) - reviews = await svc.get_reviews(pid) - return success_response( - [ - { - "id": str(r.id), - "title": r.title, - "status": r.status, - "content": _as_dict(r.content), - "version": r.version, - "created_at": r.created_at.isoformat() if r.created_at else None, - } - for r in reviews - ] - ) - - -@router.post("/projects/{project_id}/reviews") -async def create_review( - project_id: str, - body: ReviewCreateRequest, - permission_user: User = Depends(require_project_member_or_manager), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - _ = permission_user - _require_manager(current_user) - pid = _parse_project_id(project_id) - tenant_id = int(current_user.tenant_id) - await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) - release_node = await _ensure_release( - db, - tenant_id=tenant_id, - project_id=pid, - release_node_id=int(body.release_node_id), - ) - if (release_node.status or "").lower() not in RELEASE_VERIFIED_STATUSES: - raise HTTPException(status_code=409, detail="发布未验证,复盘环节未解锁") - - normalized_content = _normalize_review_content(body.content) - if not normalized_content.get("summary"): - normalized_content["summary"] = "AI 自动生成:请补充目标达成、核心问题与改进项。" - normalized_content["release_node_id"] = int(release_node.id) - normalized_content["charter_id"] = int(release_node.parent_id) if release_node.parent_id else None - - review_node = TruthNode( - tenant_id=tenant_id, - node_type="review", - parent_id=int(release_node.id), - project_id=pid, - created_by=int(current_user.id), - title=compact_title(body.title, max_len=24), - content=normalized_content, - version=1, - status="draft", - ) - db.add(review_node) - await log_operation( - db, - tenant_id=tenant_id, - user_id=int(current_user.id), - project_id=pid, - entity_type="review", - entity_id="new", - action="create", - after_data={"title": review_node.title, "release_node_id": body.release_node_id}, - ) - await db.commit() - await db.refresh(review_node) - _sync_pmflow_rag_async( - tenant_id=tenant_id, - record_type="review", - record_id=int(review_node.id), - project_id=pid, - title=review_node.title, - content=_as_dict(review_node.content), - ) - return success_response( - { - "id": str(review_node.id), - "title": review_node.title, - "status": review_node.status, - "content": _as_dict(review_node.content), - } - ) - - -@router.patch("/projects/{project_id}/reviews/{review_node_id}/content") -async def patch_review_content( - project_id: str, - review_node_id: str, - body: ReviewContentPatchRequest, - permission_user: User = Depends(require_project_member_or_manager), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - _ = permission_user - _require_manager(current_user) - pid = _parse_project_id(project_id) - rid = _parse_node_id(review_node_id, "review_node_id") - tenant_id = int(current_user.tenant_id) - await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) - review_node = await _ensure_review( - db, - tenant_id=tenant_id, - project_id=pid, - review_node_id=rid, - ) - - existing = _as_dict(review_node.content) - merged = dict(existing) - merged.update(body.content or {}) - normalized = _normalize_review_content(merged) - normalized["release_node_id"] = existing.get("release_node_id") - normalized["charter_id"] = existing.get("charter_id") - review_node.content = normalized - - await log_operation( - db, - tenant_id=tenant_id, - user_id=int(current_user.id), - project_id=pid, - entity_type="review", - entity_id=str(rid), - action="update_content", - after_data={ - "improvement_count": len(normalized.get("improvements") or []), - "summary_len": len(str(normalized.get("summary") or "")), - }, - ) - await db.commit() - _sync_pmflow_rag_async( - tenant_id=tenant_id, - record_type="review", - record_id=int(review_node.id), - project_id=pid, - title=review_node.title, - content=_as_dict(review_node.content), - ) - return success_response( - { - "id": str(review_node.id), - "status": review_node.status, - "content": _as_dict(review_node.content), - } - ) - - -@router.post("/projects/{project_id}/reviews/{review_node_id}/improvements/convert-task") -async def convert_review_improvement_to_task( - project_id: str, - review_node_id: str, - body: ReviewImprovementToTaskRequest, - permission_user: User = Depends(require_project_member_or_manager), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - _ = permission_user - _require_manager(current_user) - pid = _parse_project_id(project_id) - rid = _parse_node_id(review_node_id, "review_node_id") - tenant_id = int(current_user.tenant_id) - await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) - review_node = await _ensure_review( - db, - tenant_id=tenant_id, - project_id=pid, - review_node_id=rid, - ) - review_content = _normalize_review_content(_as_dict(review_node.content)) - improvements = review_content.get("improvements") or [] - if body.improvement_index >= len(improvements): - raise HTTPException(status_code=400, detail="改进项索引超出范围") - item = improvements[body.improvement_index] - if not isinstance(item, dict): - raise HTTPException(status_code=400, detail="改进项数据无效") - - release_node_id = int(review_node.parent_id or review_content.get("release_node_id") or 0) - if release_node_id <= 0: - raise HTTPException(status_code=409, detail="复盘未关联发布计划,无法生成任务") - release_node = await _ensure_release( - db, - tenant_id=tenant_id, - project_id=pid, - release_node_id=release_node_id, - ) - - release_content = _as_dict(release_node.content) - charter_id = ( - int(body.charter_id) - if body.charter_id - else int(release_content.get("charter_id") or release_node.parent_id or 0) - ) - if charter_id <= 0: - raise HTTPException(status_code=409, detail="无法定位宪章,改进项无法转任务") - charter = await _ensure_charter( - db, - tenant_id=tenant_id, - project_id=pid, - charter_id=charter_id, - ) - _assert_charter_aligned(charter) - - title = compact_title( - body.title or str(item.get("title") or "").strip() or "复盘改进项任务", - max_len=32, - ) - if not title: - raise HTTPException(status_code=400, detail="改进项标题为空") - desc_parts = [ - str(body.description or "").strip(), - str(item.get("description") or "").strip(), - f"来源复盘: {review_node.title}", - ] - description = "\n".join([part for part in desc_parts if part]) - due_date = _parse_due_date(body.due_date) - - task = await persist_task( - db, - tenant_id=tenant_id, - truth_node_id=charter_id, - project_id=pid, - draft=build_task_create_draft( - title=title, - description=description or None, - assignee_id=item.get("owner_id"), - required_skill_tag_ids=[], - status="todo", - priority=body.priority, - estimated_hours=body.estimated_hours, - due_date=due_date, - ), - actor_user_id=int(current_user.id), - action="create_from_review_improvement", - remark="sstc.review_improvement_to_task", - commit=False, - refresh_workload=False, - ) - - item["task_id"] = int(task.id) - item["status"] = "converted" - review_content["improvements"] = improvements - review_content["release_node_id"] = release_node_id - review_content["charter_id"] = charter_id - review_node.content = review_content - - await log_operation( - db, - tenant_id=tenant_id, - user_id=int(current_user.id), - project_id=pid, - entity_type="task", - entity_id=str(task.id), - action="create_from_review_improvement", - after_data={ - "review_id": rid, - "improvement_index": body.improvement_index, - "charter_id": charter_id, - }, - ) - await db.commit() - await db.refresh(task) - _sync_pmflow_rag_async( - tenant_id=tenant_id, - record_type="task", - record_id=int(task.id), - project_id=pid, - title=task.title, - content=task.description or "", - ) - _sync_pmflow_rag_async( - tenant_id=tenant_id, - record_type="review", - record_id=int(review_node.id), - project_id=pid, - title=review_node.title, - content=_as_dict(review_node.content), - ) - return success_response( - { - "task": { - "id": str(task.id), - "title": task.title, - "status": task.status, - "priority": task.priority, - "charter_id": str(charter_id), - }, - "review_id": str(review_node.id), - "improvement_index": body.improvement_index, - } - ) - - -@router.post("/projects/{project_id}/reviews/{review_node_id}/archive") -async def archive_review( - project_id: str, - review_node_id: str, - body: ReviewArchiveRequest, - permission_user: User = Depends(require_project_member_or_manager), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - _ = permission_user - _require_manager(current_user) - pid = _parse_project_id(project_id) - rid = _parse_node_id(review_node_id, "review_node_id") - tenant_id = int(current_user.tenant_id) - await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) - review_node = await _ensure_review( - db, - tenant_id=tenant_id, - project_id=pid, - review_node_id=rid, - ) - - before = {"status": review_node.status} - review_node.status = "archived" if body.archive else "draft" - content = _as_dict(review_node.content) - content["archive_note"] = body.note - content["archived_at"] = now_cn_aware().isoformat() if body.archive else None - review_node.content = content - await log_operation( - db, - tenant_id=tenant_id, - user_id=int(current_user.id), - project_id=pid, - entity_type="review", - entity_id=str(rid), - action="archive" if body.archive else "unarchive", - before_data=before, - after_data={"status": review_node.status}, - ) - await db.commit() - _sync_pmflow_rag_async( - tenant_id=tenant_id, - record_type="review", - record_id=int(review_node.id), - project_id=pid, - title=review_node.title, - content=_as_dict(review_node.content), - ) - return success_response({"id": str(review_node.id), "status": review_node.status}) +__all__ = ["router"] diff --git a/backend/app/api/sstc_api/__init__.py b/backend/app/api/sstc_api/__init__.py new file mode 100644 index 00000000..03a8e65f --- /dev/null +++ b/backend/app/api/sstc_api/__init__.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from fastapi import APIRouter + +from . import ( + ai_routing, + charters_ai_alignment, + charters_detail_update, + charters_list_create, + overview, + releases_list_create, + releases_status_generate, + releases_verification, + reviews_archive, + reviews_list_create, + reviews_patch_convert, + signals_archive, + signals_attachment, + signals_list, + tasks_ai_breakdown, + tasks_board_status, + tasks_create, + tasks_recommend_optimize, + tasks_update_delete, +) + +router = APIRouter(tags=["sstc"]) +for _module in [ + ai_routing, + overview, + signals_list, + signals_attachment, + signals_archive, + charters_list_create, + charters_detail_update, + charters_ai_alignment, + tasks_create, + tasks_ai_breakdown, + tasks_board_status, + tasks_update_delete, + tasks_recommend_optimize, + releases_list_create, + releases_status_generate, + releases_verification, + reviews_list_create, + reviews_patch_convert, + reviews_archive, +]: + router.include_router(_module.router) diff --git a/backend/app/api/sstc_api/ai_routing.py b/backend/app/api/sstc_api/ai_routing.py new file mode 100644 index 00000000..1c92e7be --- /dev/null +++ b/backend/app/api/sstc_api/ai_routing.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +from .shared import * + +from ...services.sstc.charter_seed_utils import _safe_threshold +from ...services.sstc.entity_lookup import _ensure_project +from ...services.sstc.feishu_routing import _extract_feishu_routing_config +from ...services.sstc.signal_core import _parse_project_id + +router = APIRouter(tags=["sstc"]) + +class AIRoutingConfigUpdateRequest(BaseModel): + cold_start_enabled: Optional[bool] = None + signal_auto_enter_threshold: Optional[float] = None + project_auto_create_threshold: Optional[float] = None + project_uncertain_threshold: Optional[float] = None + +def _routing_payload(data: dict[str, Any]) -> dict[str, Any]: + return { + "cold_start_enabled": bool(data["cold_start_enabled"]), + "signal_auto_enter_threshold": round(float(data["signal_auto_enter_threshold"]), 2), + "project_auto_create_threshold": round(float(data["project_auto_create_threshold"]), 2), + "project_uncertain_threshold": round(float(data["project_uncertain_threshold"]), 2), + "tips": { + "cold_start_enabled": "开启后,所有飞书消息进入待处理,不会自动入库或自动建项。", + "signal_auto_enter_threshold": "建议范围 0.75-0.90,数值越高越保守。", + "project_auto_create_threshold": "建议范围 0.80-0.95,数值越高越严格。", + "project_uncertain_threshold": "低于该值视为低置信度,进入人工待处理。", + }, + } + +@router.get("/projects/{project_id}/ai-routing-config") +async def get_ai_routing_config( + project_id: str, + current_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), +): + tenant_id = int(current_user.tenant_id) + pid = _parse_project_id(project_id) + svc = SSTCService(db, tenant_id=tenant_id) + await _ensure_project(svc, pid) + + integration = ( + await db.execute( + select(Integration).where( + and_( + Integration.tenant_id == tenant_id, + Integration.type == "feishu", + ) + ) + ) + ).scalar_one_or_none() + return success_response(_routing_payload(_extract_feishu_routing_config(integration))) + +@router.put("/projects/{project_id}/ai-routing-config") +async def update_ai_routing_config( + project_id: str, + request: AIRoutingConfigUpdateRequest, + current_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), +): + tenant_id = int(current_user.tenant_id) + pid = _parse_project_id(project_id) + svc = SSTCService(db, tenant_id=tenant_id) + await _ensure_project(svc, pid) + + integration = ( + await db.execute( + select(Integration).where( + and_( + Integration.tenant_id == tenant_id, + Integration.type == "feishu", + ) + ) + ) + ).scalar_one_or_none() + if integration is None: + integration = Integration( + tenant_id=tenant_id, + type="feishu", + config={}, + is_active=True, + ) + db.add(integration) + await db.flush() + + current = _extract_feishu_routing_config(integration) + config = dict(current["raw"]) + + if request.cold_start_enabled is not None: + enabled = bool(request.cold_start_enabled) + config["cold_start_enabled"] = enabled + + if request.signal_auto_enter_threshold is not None: + config["signal_auto_enter_threshold"] = _safe_threshold( + request.signal_auto_enter_threshold, + current["signal_auto_enter_threshold"], + ) + + if request.project_auto_create_threshold is not None: + config["project_auto_create_threshold"] = _safe_threshold( + request.project_auto_create_threshold, + current["project_auto_create_threshold"], + ) + + if request.project_uncertain_threshold is not None: + config["project_uncertain_threshold"] = _safe_threshold( + request.project_uncertain_threshold, + current["project_uncertain_threshold"], + ) + + project_threshold = _safe_threshold( + config.get("project_auto_create_threshold"), + current["project_auto_create_threshold"], + ) + uncertain_threshold = min( + project_threshold, + _safe_threshold( + config.get("project_uncertain_threshold"), + current["project_uncertain_threshold"], + ), + ) + config["project_auto_create_threshold"] = project_threshold + config["project_uncertain_threshold"] = uncertain_threshold + + integration.config = config + await db.commit() + await db.refresh(integration) + return success_response(_routing_payload(_extract_feishu_routing_config(integration))) + diff --git a/backend/app/api/sstc_api/charters_ai_alignment.py b/backend/app/api/sstc_api/charters_ai_alignment.py new file mode 100644 index 00000000..8e6dd76c --- /dev/null +++ b/backend/app/api/sstc_api/charters_ai_alignment.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +from .shared import * + +from ...services.sstc.charter_prefill_builder import _build_ai_first_charter_content +from ...services.sstc.charter_prefill_utils import _has_complete_module_data, _merge_prefill_content +from ...services.sstc.charter_seed_utils import _extract_charter_signal_ids +from ...services.sstc.entity_lookup import _ensure_charter, _ensure_project +from ...services.sstc.signal_core import _as_dict, _parse_node_id, _parse_project_id, _sync_pmflow_rag_async + +router = APIRouter(tags=["sstc"]) + +class CharterAIPrefillRequest(BaseModel): + force: bool = False + raw_text: Optional[str] = None + +@router.post("/projects/{project_id}/charters/{charter_id}/ai-prefill") +async def ai_prefill_charter( + project_id: str, + charter_id: str, + body: CharterAIPrefillRequest = Body(default_factory=CharterAIPrefillRequest), + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + pid = _parse_project_id(project_id) + cid = _parse_node_id(charter_id, "charter_id") + tenant_id = int(current_user.tenant_id) + + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) + before_content = _as_dict(charter.content) + before_status = str(charter.status or "") + + if not body.force and _has_complete_module_data(before_content): + return success_response( + { + "id": str(charter.id), + "title": charter.title, + "content": before_content, + "status": charter.status, + "version": charter.version, + "prefilled": False, + "skipped": True, + } + ) + + signal_ids = _extract_charter_signal_ids(before_content, charter.signal_id) + signal_rows: list[Signal] = [] + if signal_ids: + signal_rows = ( + await db.execute( + select(Signal).where( + and_( + Signal.tenant_id == tenant_id, + Signal.id.in_(signal_ids), + ) + ) + ) + ).scalars().all() + + raw_text = str(body.raw_text or before_content.get("raw_source_text") or "").strip() + if not signal_rows and not raw_text: + raise HTTPException(status_code=400, detail="未找到可用于预填的信号源或原始文本") + + generated = await _build_ai_first_charter_content( + db=db, + tenant_id=tenant_id, + project_id=pid, + signal_rows=signal_rows, + signal_ids=signal_ids, + raw_text=raw_text, + ) + + merged = _merge_prefill_content( + before_content, generated, overwrite_existing=bool(body.force) + ) + merged["signal_ids"] = signal_ids + if raw_text: + merged["raw_source_text"] = raw_text + charter.content = merged + charter.status = "draft" + + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="charter", + entity_id=str(cid), + action="ai_prefill", + before_data={"content": before_content, "status": before_status}, + after_data={"content": merged, "status": "draft"}, + ) + + await db.commit() + await db.refresh(charter) + _sync_pmflow_rag_async( + tenant_id=tenant_id, + record_type="charter", + record_id=int(charter.id), + project_id=pid, + title=charter.title, + content=_as_dict(charter.content), + ) + return success_response( + { + "id": str(charter.id), + "title": charter.title, + "content": _as_dict(charter.content), + "status": charter.status, + "version": charter.version, + "prefilled": True, + "skipped": False, + } + ) + +@router.post("/projects/{project_id}/charters/{charter_id}/alignment/start") +async def start_alignment( + project_id: str, + charter_id: str, + body: AlignmentStartRequest, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + pid = _parse_project_id(project_id) + cid = _parse_node_id(charter_id, "charter_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) + before = {"status": charter.status} + charter.status = "aligning" + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="charter", + entity_id=str(cid), + action="start_alignment", + before_data=before, + after_data={"status": charter.status, "note": body.note}, + ) + await db.commit() + return success_response({"id": str(cid), "status": charter.status}) + +@router.post("/projects/{project_id}/charters/{charter_id}/alignment/confirm") +async def confirm_alignment( + project_id: str, + charter_id: str, + body: AlignmentConfirmRequest, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + pid = _parse_project_id(project_id) + cid = _parse_node_id(charter_id, "charter_id") + tenant_id = int(current_user.tenant_id) + svc = SSTCService(db, tenant_id=tenant_id) + await _ensure_project(svc, pid) + charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) + + row_result = await db.execute( + select(AlignmentRecord).where( + and_( + AlignmentRecord.truth_node_id == cid, + AlignmentRecord.user_id == int(current_user.id), + AlignmentRecord.tenant_id == tenant_id, + ) + ) + ) + row = row_result.scalar_one_or_none() + if row is None: + row = AlignmentRecord( + truth_node_id=cid, + user_id=int(current_user.id), + alignment_score=body.score, + is_confirmed=body.confirmed, + comments=body.comments, + tenant_id=tenant_id, + ) + db.add(row) + else: + row.alignment_score = body.score + row.is_confirmed = body.confirmed + row.comments = body.comments + + member_ids = await svc.get_project_member_ids(pid) + confirmed_map: set[int] = set() + rows_result = await db.execute( + select(AlignmentRecord).where( + and_( + AlignmentRecord.truth_node_id == cid, + AlignmentRecord.tenant_id == tenant_id, + AlignmentRecord.is_confirmed.is_(True), + ) + ) + ) + for r in rows_result.scalars().all(): + confirmed_map.add(int(r.user_id)) + all_confirmed = set(member_ids).issubset(confirmed_map) if member_ids else False + if all_confirmed: + charter.status = "aligned" + elif (charter.status or "").lower() not in {"aligned"}: + charter.status = "aligning" + + if all_confirmed: + content = _as_dict(charter.content) + signal_ids = [sid for sid in content.get("signal_ids", []) if isinstance(sid, int)] + if charter.signal_id and int(charter.signal_id) not in signal_ids: + signal_ids.append(int(charter.signal_id)) + if signal_ids: + signal_rows = ( + await db.execute( + select(Signal).where( + and_( + Signal.tenant_id == tenant_id, + Signal.id.in_(signal_ids), + ) + ) + ) + ).scalars().all() + for signal in signal_rows: + signal.status = "已关闭" + signal.processed = True + + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="alignment", + entity_id=f"{cid}:{current_user.id}", + action="confirm", + after_data={ + "score": body.score, + "confirmed": body.confirmed, + "all_confirmed": all_confirmed, + "charter_status": charter.status, + }, + ) + await db.commit() + return success_response( + { + "charter_id": str(cid), + "charter_status": charter.status, + "all_confirmed": all_confirmed, + "confirmed_count": len(confirmed_map), + "member_count": len(member_ids), + } + ) + diff --git a/backend/app/api/sstc_api/charters_detail_update.py b/backend/app/api/sstc_api/charters_detail_update.py new file mode 100644 index 00000000..de6cca50 --- /dev/null +++ b/backend/app/api/sstc_api/charters_detail_update.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +from .shared import * + +from ...services.sstc.charter_data import _build_charter_summary_payload, _load_signal_rows, _select_source_signal, _serialize_alignment_record, _serialize_charter_signal, _serialize_charter_task, _serialize_source_signal +from ...services.sstc.entity_lookup import _ensure_charter, _ensure_project +from ...services.sstc.feishu_routing import _backfill_feishu_sender_names +from ...services.sstc.governance import _require_manager +from ...services.sstc.signal_core import _as_dict, _parse_node_id, _parse_project_id, _sync_pmflow_rag_async + +router = APIRouter(tags=["sstc"]) + +@router.get("/projects/{project_id}/charters/{charter_id}") +async def get_charter_detail( + project_id: str, + charter_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + pid = _parse_project_id(project_id) + cid = _parse_node_id(charter_id, "charter_id") + tenant_id = int(current_user.tenant_id) + svc = SSTCService(db, tenant_id=tenant_id) + await _ensure_project(svc, pid) + charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) + + content = _as_dict(charter.content) + signal_ids = [sid for sid in content.get("signal_ids", []) if isinstance(sid, int)] + signal_rows = await _load_signal_rows( + db, + tenant_id=tenant_id, + signal_ids=signal_ids, + ) + if signal_rows: + await _backfill_feishu_sender_names( + db, + tenant_id=tenant_id, + signals=signal_rows, + ) + + task_result = await db.execute( + select(Task).where( + and_( + Task.truth_node_id == cid, + Task.project_id == pid, + Task.tenant_id == tenant_id, + ) + ) + ) + tasks = list(task_result.scalars().all()) + + align_result = await db.execute( + select(AlignmentRecord).where( + and_( + AlignmentRecord.truth_node_id == cid, + AlignmentRecord.tenant_id == tenant_id, + ) + ) + ) + alignments = list(align_result.scalars().all()) + source_signal = _select_source_signal(charter, signal_rows) + + return success_response( + { + "id": str(charter.id), + "title": charter.title, + "content": content, + "signal_ids": signal_ids, + "signals": [_serialize_charter_signal(signal) for signal in signal_rows], + "source_signal": _serialize_source_signal(source_signal) if source_signal else None, + "tasks": [_serialize_charter_task(task) for task in tasks], + "alignment_records": [_serialize_alignment_record(record) for record in alignments], + "status": charter.status, + "version": charter.version, + } + ) + +@router.patch("/projects/{project_id}/charters/{charter_id}") +async def update_charter( + project_id: str, + charter_id: str, + body: CharterUpdateRequest, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + cid = _parse_node_id(charter_id, "charter_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) + + before = { + "title": charter.title, + "content": _as_dict(charter.content), + "version": charter.version, + "status": charter.status, + } + aligned = (charter.status or "").lower() in ALIGNED_STATUSES + if aligned and body.force_new_version: + new_node = TruthNode( + node_type="charter", + parent_id=int(charter.id), + project_id=pid, + created_by=int(current_user.id), + title=compact_title((body.title or charter.title), max_len=24), + content=body.content if body.content is not None else _as_dict(charter.content), + version=int(charter.version or 1) + 1, + status="draft", + tenant_id=tenant_id, + ) + db.add(new_node) + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="charter", + entity_id=str(charter.id), + action="new_version", + before_data=before, + after_data={"new_charter_id": "pending"}, + ) + await db.commit() + await db.refresh(new_node) + await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=pid) + _sync_pmflow_rag_async( + tenant_id=tenant_id, + record_type="charter", + record_id=int(new_node.id), + project_id=pid, + title=new_node.title, + content=_as_dict(new_node.content), + ) + new_payload = _build_charter_summary_payload(new_node) + new_payload["new_version_created"] = True + return success_response(new_payload) + + if body.title is not None: + charter.title = compact_title(body.title, max_len=24) + if body.content is not None: + charter.content = body.content + charter.status = "draft" + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="charter", + entity_id=str(charter.id), + action="update", + before_data=before, + after_data={ + "title": charter.title, + "content": _as_dict(charter.content), + "version": charter.version, + "status": charter.status, + }, + ) + await db.commit() + await db.refresh(charter) + await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=pid) + _sync_pmflow_rag_async( + tenant_id=tenant_id, + record_type="charter", + record_id=int(charter.id), + project_id=pid, + title=charter.title, + content=_as_dict(charter.content), + ) + update_payload = _build_charter_summary_payload(charter) + update_payload["new_version_created"] = False + return success_response(update_payload) + diff --git a/backend/app/api/sstc_api/charters_list_create.py b/backend/app/api/sstc_api/charters_list_create.py new file mode 100644 index 00000000..58798e10 --- /dev/null +++ b/backend/app/api/sstc_api/charters_list_create.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +from .shared import * + +from ...services.sstc.charter_data import _build_charter_summary_payload, _fetch_valid_project_signal_ids, _load_signal_rows +from ...services.sstc.charter_prefill_builder import _build_ai_first_charter_content +from ...services.sstc.charter_prefill_utils import _has_non_empty_module_data +from ...services.sstc.entity_lookup import _ensure_project +from ...services.sstc.governance import _require_manager +from ...services.sstc.signal_core import _as_dict, _parse_project_id, _sync_pmflow_rag_async +from ...services.sstc.signal_filters import _collect_locked_signal_conflicts + +router = APIRouter(tags=["sstc"]) + +class CharterCreateRequest(BaseModel): + title: str + content: dict[str, Any] | None = None + signal_ids: list[int] + skip_ai_prefill: bool = False + +@router.get("/projects/{project_id}/charters") +async def list_charters( + project_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + pid = _parse_project_id(project_id) + svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) + await _ensure_project(svc, pid) + charters = await svc.get_charters(pid) + payload = [ + { + "id": str(c.id), + "title": c.title, + "content": _as_dict(c.content), + "signal_ids": _as_dict(c.content).get("signal_ids", []), + "status": c.status, + "version": c.version, + "parent_id": str(c.parent_id) if c.parent_id else None, + "created_at": c.created_at.isoformat() if c.created_at else None, + } + for c in charters + ] + return success_response(payload) + +@router.post("/projects/{project_id}/charters") +async def create_charter( + project_id: str, + body: CharterCreateRequest, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + if not body.signal_ids: + raise HTTPException(status_code=400, detail="宪章必须关联至少一个信号") + pid = _parse_project_id(project_id) + tenant_id = int(current_user.tenant_id) + svc = SSTCService(db, tenant_id=tenant_id) + await _ensure_project(svc, pid) + + valid_signal_ids = await _fetch_valid_project_signal_ids( + db, + tenant_id=tenant_id, + project_id=pid, + signal_ids=body.signal_ids, + ) + if set(body.signal_ids) - valid_signal_ids: + raise HTTPException(status_code=400, detail="部分信号未关联到当前项目") + + signal_ids = sorted(set(body.signal_ids)) + existing_charters = await svc.get_charters(pid) + conflict_signal_ids, conflict_charters = _collect_locked_signal_conflicts( + charters=existing_charters, + candidate_signal_ids=signal_ids, + ) + if conflict_signal_ids: + raise HTTPException( + status_code=409, + detail={ + "message": _SIGNAL_LOCK_REASON, + "conflict_signal_ids": conflict_signal_ids, + "conflict_charters": conflict_charters, + }, + ) + signal_rows = await _load_signal_rows( + db, + tenant_id=tenant_id, + signal_ids=signal_ids, + ) + + content = dict(body.content or {}) + raw_text = str(content.get("raw_source_text") or "").strip() + if not body.skip_ai_prefill and not _has_non_empty_module_data(content): + ai_prefill = await _build_ai_first_charter_content( + db=db, + tenant_id=tenant_id, + project_id=pid, + signal_rows=list(signal_rows), + signal_ids=signal_ids, + raw_text=raw_text, + ) + content.update(ai_prefill) + content["signal_ids"] = signal_ids + primary_signal_id = content["signal_ids"][0] if content["signal_ids"] else None + charter = TruthNode( + node_type="charter", + parent_id=None, + project_id=pid, + signal_id=primary_signal_id, + created_by=int(current_user.id), + title=compact_title(body.title, max_len=24), + content=content, + version=1, + status="draft", + tenant_id=tenant_id, + ) + db.add(charter) + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="charter", + entity_id="new", + action="create", + after_data={"title": charter.title, "signal_ids": content["signal_ids"]}, + ) + + for signal in signal_rows: + signal.status = "已关联宪章" + signal.processed = False + + await db.commit() + await db.refresh(charter) + _sync_pmflow_rag_async( + tenant_id=tenant_id, + record_type="charter", + record_id=int(charter.id), + project_id=pid, + title=charter.title, + content=_as_dict(charter.content), + ) + return success_response(_build_charter_summary_payload(charter)) + diff --git a/backend/app/api/sstc_api/overview.py b/backend/app/api/sstc_api/overview.py new file mode 100644 index 00000000..f56ecc6a --- /dev/null +++ b/backend/app/api/sstc_api/overview.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from .shared import * + +from ...services.sstc.entity_lookup import _ensure_project +from ...services.sstc.governance import _build_governance_snapshot +from ...services.sstc.signal_core import _parse_project_id + +router = APIRouter(tags=["sstc"]) + +@router.get("/projects/{project_id}/sstc/overview") +async def get_board_overview( + project_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + pid = _parse_project_id(project_id) + svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) + await _ensure_project(svc, pid) + overview = await svc.build_overview(pid) + stages = await svc.build_stages(pid) + risks = await svc.build_risks(pid) + return success_response( + { + "overview": overview, + "stages": stages, + "risks": risks, + } + ) + +@router.get("/projects/{project_id}/sstc/stages") +async def get_stages( + project_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + pid = _parse_project_id(project_id) + svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) + await _ensure_project(svc, pid) + return success_response(await svc.build_stages(pid)) + +@router.get("/projects/{project_id}/sstc/risks") +async def get_risks( + project_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + pid = _parse_project_id(project_id) + svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) + await _ensure_project(svc, pid) + return success_response(await svc.build_risks(pid)) + +@router.get("/projects/{project_id}/sstc/governance") +async def get_governance_snapshot( + project_id: str, + charter_id: Optional[int] = Query(default=None), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + pid = _parse_project_id(project_id) + tenant_id = int(current_user.tenant_id) + svc = SSTCService(db, tenant_id=tenant_id) + await _ensure_project(svc, pid) + data = await _build_governance_snapshot( + db, + tenant_id=tenant_id, + project_id=pid, + charter_id=charter_id, + ) + return success_response(data) + +@router.get("/projects/{project_id}/sstc-chain") +async def get_sstc_chain( + project_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + pid = _parse_project_id(project_id) + svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) + await _ensure_project(svc, pid) + return success_response(await svc.build_chain(pid)) + diff --git a/backend/app/api/sstc_api/releases_list_create.py b/backend/app/api/sstc_api/releases_list_create.py new file mode 100644 index 00000000..4760afbb --- /dev/null +++ b/backend/app/api/sstc_api/releases_list_create.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from .shared import * + +from ...services.sstc.entity_lookup import _ensure_charter, _ensure_project +from ...services.sstc.governance import _build_governance_snapshot, _require_manager +from ...services.sstc.signal_core import _as_dict, _parse_project_id + +router = APIRouter(tags=["sstc"]) + +@router.get("/projects/{project_id}/releases") +async def list_releases( + project_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + pid = _parse_project_id(project_id) + svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) + await _ensure_project(svc, pid) + releases = await svc.get_releases(pid) + return success_response( + [ + { + "id": str(r.id), + "title": r.title, + "status": r.status, + "content": _as_dict(r.content), + "version": r.version, + "created_at": r.created_at.isoformat() if r.created_at else None, + } + for r in releases + ] + ) + +@router.post("/projects/{project_id}/releases") +async def create_release_plan( + project_id: str, + body: ReleaseCreateRequest, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + tenant_id = int(current_user.tenant_id) + svc = SSTCService(db, tenant_id=tenant_id) + await _ensure_project(svc, pid) + + charter = await _ensure_charter( + db, + tenant_id=tenant_id, + project_id=pid, + charter_id=int(body.charter_id), + ) + + if not body.task_ids: + raise HTTPException(status_code=400, detail="发布计划必须绑定至少一个任务") + + task_result = await db.execute( + select(Task).where( + and_( + Task.id.in_(body.task_ids), + Task.project_id == pid, + Task.tenant_id == tenant_id, + ) + ) + ) + tasks = list(task_result.scalars().all()) + if len(tasks) != len(body.task_ids): + raise HTTPException(status_code=400, detail="部分任务不存在") + not_done = [t for t in tasks if (t.status or "").lower() not in DONE_STATUSES] + if not_done: + raise HTTPException(status_code=409, detail="发布计划仅允许绑定已完成任务") + + governance = await _build_governance_snapshot( + db, + tenant_id=tenant_id, + project_id=pid, + charter_id=int(charter.id), + ) + content = { + "charter_id": int(charter.id), + "task_ids": [int(t.id) for t in tasks], + "planned_at": body.planned_at, + "verification_items": [], + "verification_done": False, + "verification_progress": {"done": 0, "total": 0}, + "governance_snapshot": governance, + } + release_node = TruthNode( + tenant_id=tenant_id, + node_type="release", + parent_id=int(charter.id), + project_id=pid, + created_by=int(current_user.id), + title=compact_title(body.title, max_len=24), + content=content, + version=1, + status="pending", + ) + db.add(release_node) + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="release", + entity_id="new", + action="create", + after_data={"title": release_node.title, "task_ids": body.task_ids}, + ) + await db.commit() + await db.refresh(release_node) + return success_response( + { + "id": str(release_node.id), + "title": release_node.title, + "status": release_node.status, + "content": _as_dict(release_node.content), + } + ) + diff --git a/backend/app/api/sstc_api/releases_status_generate.py b/backend/app/api/sstc_api/releases_status_generate.py new file mode 100644 index 00000000..4569578d --- /dev/null +++ b/backend/app/api/sstc_api/releases_status_generate.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from .shared import * + +from ...services.sstc.entity_lookup import _ensure_charter, _ensure_project, _ensure_release +from ...services.sstc.governance import _assert_release_transition_allowed, _require_manager +from ...services.sstc.signal_core import _as_dict, _parse_node_id, _parse_project_id + +router = APIRouter(tags=["sstc"]) + +@router.patch("/projects/{project_id}/releases/{release_node_id}/status") +async def patch_release_status( + project_id: str, + release_node_id: str, + body: ReleaseStatusPatchRequest, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + rid = _parse_node_id(release_node_id, "release_node_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + release_node = await _ensure_release( + db, tenant_id=tenant_id, project_id=pid, release_node_id=rid + ) + await _assert_release_transition_allowed( + db, + tenant_id=tenant_id, + project_id=pid, + release_node=release_node, + target_status=body.status, + ) + before = {"status": release_node.status} + release_node.status = body.status + content = _as_dict(release_node.content) + content["status_note"] = body.note + content["last_status_changed_at"] = now_cn_aware().isoformat() + release_node.content = content + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="release", + entity_id=str(rid), + action="status_change", + before_data=before, + after_data={"status": body.status, "note": body.note}, + ) + await db.commit() + return success_response({"id": str(release_node.id), "status": release_node.status}) + +@router.post("/projects/{project_id}/releases/{release_node_id}/verification/generate") +async def generate_release_verification( + project_id: str, + release_node_id: str, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + rid = _parse_node_id(release_node_id, "release_node_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + release_node = await _ensure_release( + db, tenant_id=tenant_id, project_id=pid, release_node_id=rid + ) + + content = _as_dict(release_node.content) + charter_id = int(content.get("charter_id") or 0) + charter = await _ensure_charter( + db, + tenant_id=tenant_id, + project_id=pid, + charter_id=charter_id, + ) + charter_content = _as_dict(charter.content) + success_metrics = charter_content.get("success_metrics") or charter_content.get("core_metrics") or [] + if not success_metrics: + success_metrics = ["功能验收通过", "核心流程无阻塞", "关键性能指标达标"] + + items = [ + { + "id": f"check-{i+1}", + "title": str(metric), + "expected": "达到预期", + "status": "pending", + "required": True, + "source": "charter_success_metric", + "verified_by": None, + "verified_at": None, + "note": None, + } + for i, metric in enumerate(success_metrics) + ] + content["verification_items"] = items + content["verification_progress"] = {"done": 0, "total": len(items)} + content["verification_done"] = False + release_node.content = content + await db.commit() + return success_response({"release_id": str(release_node.id), "verification_items": items}) + diff --git a/backend/app/api/sstc_api/releases_verification.py b/backend/app/api/sstc_api/releases_verification.py new file mode 100644 index 00000000..a2674f5b --- /dev/null +++ b/backend/app/api/sstc_api/releases_verification.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from .shared import * + +from ...services.sstc.entity_lookup import _ensure_project, _ensure_release +from ...services.sstc.governance import _require_manager +from ...services.sstc.signal_core import _as_dict, _parse_node_id, _parse_project_id + +router = APIRouter(tags=["sstc"]) + +@router.patch("/projects/{project_id}/releases/{release_node_id}/verification/items/{item_id}") +async def patch_release_verification_item( + project_id: str, + release_node_id: str, + item_id: str, + body: ReleaseVerificationItemPatchRequest, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + rid = _parse_node_id(release_node_id, "release_node_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + release_node = await _ensure_release( + db, tenant_id=tenant_id, project_id=pid, release_node_id=rid + ) + content = _as_dict(release_node.content) + items = content.get("verification_items") + if not isinstance(items, list) or len(items) == 0: + raise HTTPException(status_code=404, detail="验证清单不存在,请先生成") + + target = None + for item in items: + if str(item.get("id")) == item_id: + target = item + break + if target is None: + raise HTTPException(status_code=404, detail="验证项不存在") + + target["status"] = body.status + if body.status == "done": + target["verified_by"] = str(current_user.id) + target["verified_at"] = now_cn_aware().isoformat() + else: + target["verified_by"] = None + target["verified_at"] = None + target["note"] = body.note + + done_count = len([item for item in items if str(item.get("status") or "").lower() == "done"]) + content["verification_items"] = items + content["verification_progress"] = {"done": done_count, "total": len(items)} + content["verification_done"] = done_count == len(items) and len(items) > 0 + release_node.content = content + await db.commit() + return success_response( + { + "release_id": str(release_node.id), + "item_id": item_id, + "verification_progress": content["verification_progress"], + "verification_done": content["verification_done"], + } + ) + +@router.post("/projects/{project_id}/releases/{release_node_id}/verification/complete") +async def complete_release_verification( + project_id: str, + release_node_id: str, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + rid = _parse_node_id(release_node_id, "release_node_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + release_node = await _ensure_release( + db, tenant_id=tenant_id, project_id=pid, release_node_id=rid + ) + status_raw = str(release_node.status or "").lower() + if status_raw not in {"online", "releasing"}: + raise HTTPException(status_code=409, detail="仅上线中的发布计划可完成验证") + content = _as_dict(release_node.content) + items = content.get("verification_items", []) + if not isinstance(items, list) or len(items) == 0: + raise HTTPException(status_code=409, detail="验证清单为空,请先生成并完成验证项") + incomplete = [item for item in items if str(item.get("status") or "").lower() != "done"] + if incomplete: + raise HTTPException(status_code=409, detail="仍有未完成验证项,请先逐项完成") + now_str = now_cn_aware().isoformat() + content["verification_items"] = items + content["verification_done"] = True + content["verification_progress"] = {"done": len(items), "total": len(items)} + content["verified_at"] = now_str + release_node.content = content + release_node.status = "verified" + await db.commit() + return success_response({"id": str(release_node.id), "status": release_node.status}) + diff --git a/backend/app/api/sstc_api/reviews_archive.py b/backend/app/api/sstc_api/reviews_archive.py new file mode 100644 index 00000000..843e1ced --- /dev/null +++ b/backend/app/api/sstc_api/reviews_archive.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from .shared import * + +from ...services.sstc.entity_lookup import _ensure_project, _ensure_review +from ...services.sstc.governance import _require_manager +from ...services.sstc.signal_core import _as_dict, _parse_node_id, _parse_project_id, _sync_pmflow_rag_async + +router = APIRouter(tags=["sstc"]) + +@router.post("/projects/{project_id}/reviews/{review_node_id}/archive") +async def archive_review( + project_id: str, + review_node_id: str, + body: ReviewArchiveRequest, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + rid = _parse_node_id(review_node_id, "review_node_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + review_node = await _ensure_review( + db, + tenant_id=tenant_id, + project_id=pid, + review_node_id=rid, + ) + + before = {"status": review_node.status} + review_node.status = "archived" if body.archive else "draft" + content = _as_dict(review_node.content) + content["archive_note"] = body.note + content["archived_at"] = now_cn_aware().isoformat() if body.archive else None + review_node.content = content + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="review", + entity_id=str(rid), + action="archive" if body.archive else "unarchive", + before_data=before, + after_data={"status": review_node.status}, + ) + await db.commit() + _sync_pmflow_rag_async( + tenant_id=tenant_id, + record_type="review", + record_id=int(review_node.id), + project_id=pid, + title=review_node.title, + content=_as_dict(review_node.content), + ) + return success_response({"id": str(review_node.id), "status": review_node.status}) + diff --git a/backend/app/api/sstc_api/reviews_list_create.py b/backend/app/api/sstc_api/reviews_list_create.py new file mode 100644 index 00000000..6495865b --- /dev/null +++ b/backend/app/api/sstc_api/reviews_list_create.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from .shared import * + +from ...services.sstc.entity_lookup import _ensure_project, _ensure_release +from ...services.sstc.governance import _require_manager +from ...services.sstc.review_utils import _normalize_review_content +from ...services.sstc.signal_core import _as_dict, _parse_project_id, _sync_pmflow_rag_async + +router = APIRouter(tags=["sstc"]) + +@router.get("/projects/{project_id}/reviews") +async def list_reviews( + project_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + pid = _parse_project_id(project_id) + svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) + await _ensure_project(svc, pid) + reviews = await svc.get_reviews(pid) + return success_response( + [ + { + "id": str(r.id), + "title": r.title, + "status": r.status, + "content": _as_dict(r.content), + "version": r.version, + "created_at": r.created_at.isoformat() if r.created_at else None, + } + for r in reviews + ] + ) + +@router.post("/projects/{project_id}/reviews") +async def create_review( + project_id: str, + body: ReviewCreateRequest, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + release_node = await _ensure_release( + db, + tenant_id=tenant_id, + project_id=pid, + release_node_id=int(body.release_node_id), + ) + if (release_node.status or "").lower() not in RELEASE_VERIFIED_STATUSES: + raise HTTPException(status_code=409, detail="发布未验证,复盘环节未解锁") + + normalized_content = _normalize_review_content(body.content) + if not normalized_content.get("summary"): + normalized_content["summary"] = "AI 自动生成:请补充目标达成、核心问题与改进项。" + normalized_content["release_node_id"] = int(release_node.id) + normalized_content["charter_id"] = int(release_node.parent_id) if release_node.parent_id else None + + review_node = TruthNode( + tenant_id=tenant_id, + node_type="review", + parent_id=int(release_node.id), + project_id=pid, + created_by=int(current_user.id), + title=compact_title(body.title, max_len=24), + content=normalized_content, + version=1, + status="draft", + ) + db.add(review_node) + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="review", + entity_id="new", + action="create", + after_data={"title": review_node.title, "release_node_id": body.release_node_id}, + ) + await db.commit() + await db.refresh(review_node) + _sync_pmflow_rag_async( + tenant_id=tenant_id, + record_type="review", + record_id=int(review_node.id), + project_id=pid, + title=review_node.title, + content=_as_dict(review_node.content), + ) + return success_response( + { + "id": str(review_node.id), + "title": review_node.title, + "status": review_node.status, + "content": _as_dict(review_node.content), + } + ) + diff --git a/backend/app/api/sstc_api/reviews_patch_convert.py b/backend/app/api/sstc_api/reviews_patch_convert.py new file mode 100644 index 00000000..7b3b13e9 --- /dev/null +++ b/backend/app/api/sstc_api/reviews_patch_convert.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +from .shared import * + +from ...services.sstc.entity_lookup import _ensure_charter, _ensure_project, _ensure_release, _ensure_review, _parse_due_date +from ...services.sstc.governance import _assert_charter_aligned, _require_manager +from ...services.sstc.review_utils import _normalize_review_content +from ...services.sstc.signal_core import _as_dict, _parse_node_id, _parse_project_id, _sync_pmflow_rag_async + +router = APIRouter(tags=["sstc"]) + +@router.patch("/projects/{project_id}/reviews/{review_node_id}/content") +async def patch_review_content( + project_id: str, + review_node_id: str, + body: ReviewContentPatchRequest, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + rid = _parse_node_id(review_node_id, "review_node_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + review_node = await _ensure_review( + db, + tenant_id=tenant_id, + project_id=pid, + review_node_id=rid, + ) + + existing = _as_dict(review_node.content) + merged = dict(existing) + merged.update(body.content or {}) + normalized = _normalize_review_content(merged) + normalized["release_node_id"] = existing.get("release_node_id") + normalized["charter_id"] = existing.get("charter_id") + review_node.content = normalized + + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="review", + entity_id=str(rid), + action="update_content", + after_data={ + "improvement_count": len(normalized.get("improvements") or []), + "summary_len": len(str(normalized.get("summary") or "")), + }, + ) + await db.commit() + _sync_pmflow_rag_async( + tenant_id=tenant_id, + record_type="review", + record_id=int(review_node.id), + project_id=pid, + title=review_node.title, + content=_as_dict(review_node.content), + ) + return success_response( + { + "id": str(review_node.id), + "status": review_node.status, + "content": _as_dict(review_node.content), + } + ) + +@router.post("/projects/{project_id}/reviews/{review_node_id}/improvements/convert-task") +async def convert_review_improvement_to_task( + project_id: str, + review_node_id: str, + body: ReviewImprovementToTaskRequest, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + rid = _parse_node_id(review_node_id, "review_node_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + review_node = await _ensure_review( + db, + tenant_id=tenant_id, + project_id=pid, + review_node_id=rid, + ) + review_content = _normalize_review_content(_as_dict(review_node.content)) + improvements = review_content.get("improvements") or [] + if body.improvement_index >= len(improvements): + raise HTTPException(status_code=400, detail="改进项索引超出范围") + item = improvements[body.improvement_index] + if not isinstance(item, dict): + raise HTTPException(status_code=400, detail="改进项数据无效") + + release_node_id = int(review_node.parent_id or review_content.get("release_node_id") or 0) + if release_node_id <= 0: + raise HTTPException(status_code=409, detail="复盘未关联发布计划,无法生成任务") + release_node = await _ensure_release( + db, + tenant_id=tenant_id, + project_id=pid, + release_node_id=release_node_id, + ) + + release_content = _as_dict(release_node.content) + charter_id = ( + int(body.charter_id) + if body.charter_id + else int(release_content.get("charter_id") or release_node.parent_id or 0) + ) + if charter_id <= 0: + raise HTTPException(status_code=409, detail="无法定位宪章,改进项无法转任务") + charter = await _ensure_charter( + db, + tenant_id=tenant_id, + project_id=pid, + charter_id=charter_id, + ) + _assert_charter_aligned(charter) + + title = compact_title( + body.title or str(item.get("title") or "").strip() or "复盘改进项任务", + max_len=32, + ) + if not title: + raise HTTPException(status_code=400, detail="改进项标题为空") + desc_parts = [ + str(body.description or "").strip(), + str(item.get("description") or "").strip(), + f"来源复盘: {review_node.title}", + ] + description = "\n".join([part for part in desc_parts if part]) + due_date = _parse_due_date(body.due_date) + + task = await persist_task( + db, + tenant_id=tenant_id, + truth_node_id=charter_id, + project_id=pid, + draft=build_task_create_draft( + title=title, + description=description or None, + assignee_id=item.get("owner_id"), + required_skill_tag_ids=[], + status="todo", + priority=body.priority, + estimated_hours=body.estimated_hours, + due_date=due_date, + ), + actor_user_id=int(current_user.id), + action="create_from_review_improvement", + remark="sstc.review_improvement_to_task", + commit=False, + refresh_workload=False, + ) + + item["task_id"] = int(task.id) + item["status"] = "converted" + review_content["improvements"] = improvements + review_content["release_node_id"] = release_node_id + review_content["charter_id"] = charter_id + review_node.content = review_content + + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="task", + entity_id=str(task.id), + action="create_from_review_improvement", + after_data={ + "review_id": rid, + "improvement_index": body.improvement_index, + "charter_id": charter_id, + }, + ) + await db.commit() + await db.refresh(task) + _sync_pmflow_rag_async( + tenant_id=tenant_id, + record_type="task", + record_id=int(task.id), + project_id=pid, + title=task.title, + content=task.description or "", + ) + _sync_pmflow_rag_async( + tenant_id=tenant_id, + record_type="review", + record_id=int(review_node.id), + project_id=pid, + title=review_node.title, + content=_as_dict(review_node.content), + ) + return success_response( + { + "task": { + "id": str(task.id), + "title": task.title, + "status": task.status, + "priority": task.priority, + "charter_id": str(charter_id), + }, + "review_id": str(review_node.id), + "improvement_index": body.improvement_index, + } + ) + diff --git a/backend/app/api/sstc_api/shared.py b/backend/app/api/sstc_api/shared.py new file mode 100644 index 00000000..52cc8a5b --- /dev/null +++ b/backend/app/api/sstc_api/shared.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from datetime import datetime +from io import BytesIO +from typing import Any, Optional +from uuid import uuid4 + +from fastapi import APIRouter, BackgroundTasks, Body, Depends, File, HTTPException, Query, UploadFile +from fastapi.responses import StreamingResponse +from pydantic import BaseModel +from sqlalchemy import and_, delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.response import success_response +from ...database import get_db +from ...dependencies import get_current_user, require_project_member_or_manager +from ...models import ( + AICallLog, + AlignmentRecord, + Integration, + ModelRequirementBinding, + ModelVersion, + ProjectSignalLink, + Signal, + Task, + TruthNode, + User, + UserProject, +) +from ...schemas.sstc import ( + AlignmentConfirmRequest, + AlignmentStartRequest, + CharterUpdateRequest, + ReleaseCreateRequest, + ReleaseStatusPatchRequest, + ReleaseVerificationItemPatchRequest, + ReviewArchiveRequest, + ReviewContentPatchRequest, + ReviewCreateRequest, + ReviewImprovementToTaskRequest, + TaskStatusPatchRequest, + WorkloadOptimizeRequest, +) +from ...services import skill_score_service, workload_service +from ...services.ai_pmo_kernel_service import AIPMOKernelService +from ...services.ai_service import AIService +from ...services.auth import has_any_role +from ...services.budget_enforcement_service import BudgetEnforcementService +from ...services.feishu_integration import get_feishu_service +from ...services.llm import llm_service +from ...services.operation_log_service import log_operation +from ...services.skill_service import recommend_personnel +from ...services.sstc_service import ALIGNED_STATUSES, DONE_STATUSES, RELEASE_VERIFIED_STATUSES, SSTCService +from ...services.task_persistence import build_task_create_draft, persist_task +from ...utils.title import compact_title +from ...utils.timezone import CN_TZ, now_cn_aware, now_cn_naive diff --git a/backend/app/api/sstc_api/signals_archive.py b/backend/app/api/sstc_api/signals_archive.py new file mode 100644 index 00000000..09a50c15 --- /dev/null +++ b/backend/app/api/sstc_api/signals_archive.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from .shared import * + +from ...services.sstc.entity_lookup import _ensure_project +from ...services.sstc.signal_core import _parse_node_id, _parse_project_id + +router = APIRouter(tags=["sstc"]) + +@router.post("/projects/{project_id}/signals/{signal_id}/archive") +async def archive_signal( + project_id: str, + signal_id: str, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + pid = _parse_project_id(project_id) + sid = _parse_node_id(signal_id, "signal_id") + svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) + await _ensure_project(svc, pid) + result = await db.execute( + select(Signal).where( + and_(Signal.id == sid, Signal.tenant_id == int(current_user.tenant_id)) + ) + ) + signal = result.scalar_one_or_none() + if not signal: + raise HTTPException(status_code=404, detail="Signal not found") + before = {"processed": signal.processed} + signal.processed = True + signal.status = "已关闭" + await log_operation( + db, + tenant_id=int(current_user.tenant_id), + user_id=int(current_user.id), + project_id=pid, + entity_type="signal", + entity_id=str(sid), + action="archive", + before_data=before, + after_data={"processed": True}, + ) + await db.commit() + return success_response({"id": str(signal.id), "status": "已关闭"}) + diff --git a/backend/app/api/sstc_api/signals_attachment.py b/backend/app/api/sstc_api/signals_attachment.py new file mode 100644 index 00000000..03bb4570 --- /dev/null +++ b/backend/app/api/sstc_api/signals_attachment.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +from .shared import * + +from ...services.sstc.entity_lookup import _ensure_project +from ...services.sstc.signal_core import _as_dict, _build_signal_attachment_download_url, _error_json, _extract_signal_attachments, _parse_node_id, _parse_project_id, _resolve_local_attachment_path, _signal_attachment_storage_root + +router = APIRouter(tags=["sstc"]) + +@router.post("/projects/{project_id}/signals/{signal_id}/attachments") +async def upload_signal_attachment( + project_id: str, + signal_id: str, + file: UploadFile | None = File(default=None), + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + try: + pid = _parse_project_id(project_id) + except HTTPException: + return _error_json(status_code=400, message="project_id 不合法") + try: + sid = _parse_node_id(signal_id, "signal_id") + except HTTPException: + return _error_json(status_code=400, message="signal_id 不合法") + if file is None: + return _error_json(status_code=400, message="请选择要上传的附件") + tenant_id = int(current_user.tenant_id) + svc = SSTCService(db, tenant_id=tenant_id) + + project = await svc.get_project(pid) + if project is None: + return _error_json(status_code=404, message="项目不存在") + + signals = await svc.get_signals(pid) + signal = next((item for item in signals if int(item.id) == sid), None) + if signal is None: + return _error_json(status_code=404, message="信号不存在或不属于当前项目") + + original_file_name = Path(str(file.filename or "")).name.strip() + if not original_file_name: + return _error_json(status_code=400, message="文件名不能为空") + + extension = Path(original_file_name).suffix.lower() + attachment_type = _SIGNAL_ATTACHMENT_ALLOWED_EXTENSIONS.get(extension) + if attachment_type is None: + return _error_json(status_code=400, message="仅支持 jpg/png/gif/pdf/doc/docx/zip 文件") + + mime_type = str(file.content_type or "").strip().lower() or None + if mime_type and mime_type not in _SIGNAL_ATTACHMENT_ALLOWED_MIME_TYPES: + return _error_json(status_code=400, message="文件类型不支持,请上传 jpg/png/gif/pdf/doc/docx/zip") + + try: + content = await file.read() + except Exception: + return _error_json(status_code=500, message="读取上传文件失败") + + if not content: + return _error_json(status_code=400, message="上传文件为空") + if len(content) > _SIGNAL_ATTACHMENT_MAX_BYTES: + return _error_json(status_code=400, message="单文件大小不能超过 20MB") + + attachment_id = uuid4().hex + relative_dir = Path(f"tenant-{tenant_id}") / f"project-{pid}" / f"signal-{sid}" + storage_root = _signal_attachment_storage_root() + save_dir = storage_root / relative_dir + save_dir.mkdir(parents=True, exist_ok=True) + saved_file_name = f"{attachment_id}{extension}" + saved_path = save_dir / saved_file_name + try: + saved_path.write_bytes(content) + except Exception: + return _error_json(status_code=500, message="保存附件失败,请稍后重试") + + file_key = f"local://{(relative_dir / saved_file_name).as_posix()}" + attachment_payload = { + "id": attachment_id, + "attachment_id": attachment_id, + "attachment_type": attachment_type, + "file_name": original_file_name, + "mime_type": mime_type or "application/octet-stream", + "file_size": len(content), + "file_key": file_key, + "image_key": None, + "media_id": None, + "source": "manual_upload", + "occurred_at": now_cn_aware().isoformat(), + "download_url": _build_signal_attachment_download_url(pid, sid, attachment_id), + } + + raw_payload = _as_dict(signal.strategic_analysis) + source_context = raw_payload.get("source_context") + if not isinstance(source_context, dict): + source_context = {} + attachments = source_context.get("attachments") + if not isinstance(attachments, list): + attachments = [] + attachments.append(attachment_payload) + source_context["attachments"] = attachments + raw_payload["source_context"] = source_context + signal.strategic_analysis = json.dumps(raw_payload, ensure_ascii=False) + + try: + await db.commit() + await db.refresh(signal) + except Exception: + await db.rollback() + try: + saved_path.unlink() + except Exception: + pass + return _error_json(status_code=500, message="上传附件失败,请稍后重试") + + return success_response(attachment_payload) + +@router.get("/projects/{project_id}/signals/{signal_id}/attachments/{attachment_id}/download") +async def download_signal_attachment( + project_id: str, + signal_id: str, + attachment_id: str, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + pid = _parse_project_id(project_id) + sid = _parse_node_id(signal_id, "signal_id") + tenant_id = int(current_user.tenant_id) + svc = SSTCService(db, tenant_id=tenant_id) + await _ensure_project(svc, pid) + signals = await svc.get_signals(pid) + signal = next((item for item in signals if int(item.id) == sid), None) + if signal is None: + raise HTTPException(status_code=404, detail="Signal not found") + attachments = _extract_signal_attachments(signal) + target = next( + ( + item + for item in attachments + if str(item.get("attachment_id") or "").strip() == attachment_id + or str(item.get("id") or "").strip() == attachment_id + ), + None, + ) + if target is None: + raise HTTPException(status_code=404, detail="Attachment not found") + + local_path = _resolve_local_attachment_path(str(target.get("file_key") or "")) + if local_path is not None: + if not local_path.exists() or not local_path.is_file(): + raise HTTPException(status_code=404, detail="附件文件不存在") + try: + content = local_path.read_bytes() + except Exception as exc: + raise HTTPException(status_code=500, detail=f"附件读取失败: {str(exc)}") from exc + if not content: + raise HTTPException(status_code=500, detail="附件内容为空") + file_name = str(target.get("file_name") or f"attachment-{attachment_id}").strip().replace('"', "'") + media_type = str(target.get("mime_type") or "application/octet-stream") + return StreamingResponse( + BytesIO(content), + media_type=media_type, + headers={"Content-Disposition": f'attachment; filename="{file_name}"'}, + ) + + feishu_service = get_feishu_service(db) + await feishu_service.load_config(tenant_id) + if not feishu_service.is_configured(): + raise HTTPException(status_code=503, detail="飞书集成未配置,暂无法下载附件") + + try: + payload = await feishu_service.download_attachment_bytes(target) + except ValueError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=500, detail=f"附件下载失败: {str(exc)}") from exc + + file_name = str(payload.get("file_name") or target.get("file_name") or f"attachment-{attachment_id}").strip() + file_name = file_name.replace('"', "'") + media_type = str(payload.get("content_type") or target.get("mime_type") or "application/octet-stream") + content = payload.get("content") + if not isinstance(content, (bytes, bytearray)): + raise HTTPException(status_code=500, detail="附件内容为空") + return StreamingResponse( + BytesIO(bytes(content)), + media_type=media_type, + headers={"Content-Disposition": f'attachment; filename="{file_name}"'}, + ) + diff --git a/backend/app/api/sstc_api/signals_list.py b/backend/app/api/sstc_api/signals_list.py new file mode 100644 index 00000000..acc6c4de --- /dev/null +++ b/backend/app/api/sstc_api/signals_list.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from .shared import * + +from ...services.sstc.entity_lookup import _ensure_project +from ...services.sstc.feishu_routing import _backfill_feishu_sender_names +from ...services.sstc.signal_core import _as_dict, _extract_signal_attachments, _normalize_planner_status, _normalize_signal_status_code, _parse_project_id, _signal_ai_analysis, _signal_display_type, _signal_project_scope, _signal_source_context, _signal_status_label_from_code +from ...services.sstc.signal_filters import _build_signal_created_at_range, _extract_charter_related_signal_ids, _match_signal_filters, _normalize_signal_status_filter, _signal_proposer_name, _signal_title_text + +router = APIRouter(tags=["sstc"]) + +@router.get("/projects/{project_id}/signals") +async def list_signals( + project_id: str, + status: Optional[str] = Query(default=None, description="pending/linked/closed/archived"), + keyword: Optional[str] = Query(default=None, description="按信号标题/内容/ID/提报人/关联宪章搜索"), + source: Optional[str] = Query(default=None, description="来源过滤"), + priority: Optional[str] = Query(default=None, description="优先级过滤"), + created_from: Optional[str] = Query(default=None, description="创建时间起始(北京时间 YYYY-MM-DD)"), + created_to: Optional[str] = Query(default=None, description="创建时间结束(北京时间 YYYY-MM-DD)"), + limit: int = Query(default=10, ge=1, le=100), + offset: int = Query(default=0, ge=0), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + pid = _parse_project_id(project_id) + tenant_id = int(current_user.tenant_id) + svc = SSTCService(db, tenant_id=tenant_id) + project = await _ensure_project(svc, pid) + project_creator_name = "-" + if project and getattr(project, "owner_id", None): + owner = ( + await db.execute( + select(User.name).where( + and_( + User.tenant_id == tenant_id, + User.id == int(project.owner_id), + ) + ).limit(1) + ) + ).scalars().first() + if owner: + project_creator_name = str(owner) + signals = await svc.get_signals(pid) + await _backfill_feishu_sender_names( + db, + tenant_id=tenant_id, + signals=signals, + ) + charters = await svc.get_charters(pid) + linked_signal_states: dict[int, str] = {} + linked_signal_charter_name: dict[int, str] = {} + for c in charters: + signal_ids = _extract_charter_related_signal_ids(c) + charter_status = (c.status or "").lower() + state = ( + _SIGNAL_STATUS_CODE_CLOSED + if charter_status in {"aligned", "active", "online", "archived", "已对齐", "已上线", "已归档"} + else _SIGNAL_STATUS_CODE_LINKED + ) + for sid in signal_ids: + if linked_signal_states.get(sid) == _SIGNAL_STATUS_CODE_CLOSED: + continue + linked_signal_states[sid] = state + linked_signal_charter_name[sid] = c.title + normalized_status_filter = _normalize_signal_status_filter(status) + created_from_utc, created_to_utc = _build_signal_created_at_range( + created_from=created_from, + created_to=created_to, + ) + rows = [] + for s in signals: + strategic_payload = _as_dict(s.strategic_analysis) + ai_analysis = _signal_ai_analysis(s) + source_context = _signal_source_context(s) + project_scope = _signal_project_scope(s) + proposer_name = _signal_proposer_name(s) + title_text = _signal_title_text(s) + normalized_status = str(s.status or "").strip() + sid = int(s.id) + status_code = _normalize_signal_status_code( + linked_state=linked_signal_states.get(sid), + signal_status=normalized_status, + processed=bool(s.processed), + ) + row_status = _signal_status_label_from_code(status_code, normalized_status) + locked_by_charter = sid in linked_signal_states or ("关联" in normalized_status) + can_create_charter = not locked_by_charter + charter_lock_reason = None if can_create_charter else _SIGNAL_LOCK_REASON + row = { + "id": str(s.id), + "title": title_text[:80] or "未命名信号", + "content": s.content, + "priority": s.priority, + "signal_type": _signal_display_type(s), + "source": s.source, + "project_creator_name": project_creator_name, + "proposer_name": proposer_name, + "proposer_open_id": source_context.get("sender_open_id"), + "project_scope": project_scope, + "scope_reason": ai_analysis.get("reason"), + "target_project_hints": ai_analysis.get("target_project_hints", []), + "status": row_status, + "status_code": status_code, + "can_create_charter": can_create_charter, + "charter_lock_reason": charter_lock_reason, + "charter_name": linked_signal_charter_name.get(sid), + "created_at": s.created_at.isoformat() if s.created_at else None, + "attachments": _extract_signal_attachments(s), + "strategic_analysis": strategic_payload, + "planner_status": _normalize_planner_status(getattr(s, "planner_status", None)), + "planner_analyzed_at": ( + s.planner_analyzed_at.isoformat() if getattr(s, "planner_analyzed_at", None) else None + ), + "planner_assignee_user_id": ( + str(s.planner_assignee_user_id) if getattr(s, "planner_assignee_user_id", None) is not None else None + ), + } + created_at_dt = s.created_at + if created_at_dt is not None and created_at_dt.tzinfo is not None: + created_at_dt = created_at_dt.astimezone(CN_TZ).replace(tzinfo=None) + if not _match_signal_filters( + row=row, + status_filter=normalized_status_filter, + keyword=keyword, + source=source, + priority=priority, + created_from_utc=created_from_utc, + created_to_utc=created_to_utc, + created_at_dt=created_at_dt, + ): + continue + rows.append(row) + total = len(rows) + paged_rows = rows[offset : offset + limit] + return success_response( + { + "items": paged_rows, + "total": total, + "limit": limit, + "offset": offset, + "has_more": (offset + len(paged_rows)) < total, + } + ) + diff --git a/backend/app/api/sstc_api/tasks_ai_breakdown.py b/backend/app/api/sstc_api/tasks_ai_breakdown.py new file mode 100644 index 00000000..46101c4a --- /dev/null +++ b/backend/app/api/sstc_api/tasks_ai_breakdown.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from .shared import * + +from ...services.sstc.entity_lookup import _ensure_charter, _ensure_project +from ...services.sstc.governance import _assert_charter_aligned, _require_manager +from ...services.sstc.signal_core import _as_dict, _parse_node_id, _parse_project_id, _sync_pmflow_rag_async + +router = APIRouter(tags=["sstc"]) + +@router.post("/projects/{project_id}/charters/{charter_id}/tasks/ai-breakdown") +async def ai_breakdown_tasks( + project_id: str, + charter_id: str, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + cid = _parse_node_id(charter_id, "charter_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) + _assert_charter_aligned(charter) + + prompt = f"""请基于以下需求宪章拆解 5-8 条任务,返回 JSON 数组: +[ + {{"task_name":"", "type":"backend/frontend/ai/devops", "estimated_hours": 4.0, "priority":"P0/P1/P2"}} +] + +宪章标题:{charter.title} +宪章内容:{_as_dict(charter.content)} +""" + try: + rows = await llm_service.generate_task_breakdown_async( + prompt, + db=db, + tenant_id=tenant_id, + project_id=pid, + ) + except RuntimeError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=500, detail=f"AI 拆解失败: {exc}") from exc + + created = [] + prompt_tokens = max(len(prompt) // 4, 1) + completion_tokens = 0 + for item in rows or []: + title = compact_title((item.get("task_name") or item.get("title") or ""), max_len=32) + if not title: + continue + priority = str(item.get("priority") or "P1").lower() + mapped_priority = "high" if priority == "p0" else "medium" if priority == "p1" else "low" + task = await persist_task( + db, + tenant_id=tenant_id, + truth_node_id=cid, + project_id=pid, + draft=build_task_create_draft( + title=title, + description=item.get("description"), + required_skill_tag_ids=[], + priority=mapped_priority, + status="todo", + estimated_hours=float(item.get("estimated_hours") or 0) or None, + ), + actor_user_id=int(current_user.id), + action="task.create", + remark="sstc.ai_breakdown", + commit=False, + refresh_workload=False, + ) + created.append(task) + completion_tokens += max(len(title) // 2, 1) + + db.add( + AICallLog( + tenant_id=tenant_id, + user_id=int(current_user.id), + function_name="sstc_ai_breakdown", + prompt=prompt, + response=f"created_count={len(created)}", + tokens_used=prompt_tokens + completion_tokens, + cost=((prompt_tokens * 0.00015 + completion_tokens * 0.0006) / 1000), + status="success", + ) + ) + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="task", + entity_id=str(cid), + action="ai_breakdown", + after_data={"created_count": len(created)}, + ) + await db.commit() + for task in created: + _sync_pmflow_rag_async( + tenant_id=tenant_id, + record_type="task", + record_id=int(task.id), + project_id=pid, + title=task.title, + content=task.description or "", + ) + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + return success_response( + { + "created_count": len(created), + "items": [ + {"id": str(t.id), "title": t.title, "priority": t.priority, "status": t.status} + for t in created + ], + } + ) + diff --git a/backend/app/api/sstc_api/tasks_board_status.py b/backend/app/api/sstc_api/tasks_board_status.py new file mode 100644 index 00000000..c9a2e408 --- /dev/null +++ b/backend/app/api/sstc_api/tasks_board_status.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from .shared import * + +from ...services.sstc.entity_lookup import _ensure_project, _ensure_task +from ...services.sstc.governance import _is_manager +from ...services.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async + +router = APIRouter(tags=["sstc"]) + +@router.get("/projects/{project_id}/tasks/board") +async def get_task_board( + project_id: str, + assignee_id: Optional[int] = Query(default=None), + priority: Optional[str] = Query(default=None), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + pid = _parse_project_id(project_id) + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + result = await db.execute( + select(Task).where( + and_( + Task.project_id == pid, + Task.tenant_id == tenant_id, + ) + ) + ) + tasks = list(result.scalars().all()) + if assignee_id is not None: + tasks = [t for t in tasks if t.assignee_id == assignee_id] + if priority: + tasks = [t for t in tasks if (t.priority or "").lower() == priority.lower()] + + rows = [] + for t in tasks: + risk_count = 0 + if (t.status or "").lower() == "blocked": + risk_count += 1 + if t.due_date and t.due_date < now_cn_aware().replace(tzinfo=None) and (t.status or "").lower() not in DONE_STATUSES: + risk_count += 1 + rows.append( + { + "id": str(t.id), + "title": t.title, + "description": t.description, + "status": t.status, + "priority": t.priority, + "assignee_id": str(t.assignee_id) if t.assignee_id else None, + "due_date": t.due_date.isoformat() if t.due_date else None, + "estimated_hours": float(t.estimated_hours) if t.estimated_hours else None, + "risk_count": risk_count, + "truth_node_id": str(t.truth_node_id), + } + ) + return success_response(rows) + +@router.patch("/projects/{project_id}/tasks/{task_id}/status") +async def patch_task_status( + project_id: str, + task_id: str, + body: TaskStatusPatchRequest, + background_tasks: BackgroundTasks, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + pid = _parse_project_id(project_id) + tid = _parse_node_id(task_id, "task_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + task = await _ensure_task(db, tenant_id=tenant_id, project_id=pid, task_id=tid) + if not _is_manager(current_user) and int(task.assignee_id or 0) != int(current_user.id): + raise HTTPException(status_code=403, detail="仅负责人或任务执行人可更新状态") + + before_status = (task.status or "").lower() + before = {"status": task.status} + task.status = body.status + if body.status in DONE_STATUSES: + task.completed_at = now_cn_naive() + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="task", + entity_id=str(tid), + action="status_change", + before_data=before, + after_data={"status": body.status}, + ) + if task.assignee_id: + await workload_service.refresh_user_assigned_hours_from_tasks( + db, + tenant_id=tenant_id, + user_id=int(task.assignee_id), + ) + else: + await db.commit() + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + if task.assignee_id: + await context_cache_service.invalidate_user_context( + tenant_id=tenant_id, + user_id=int(task.assignee_id), + ) + became_done = (body.status or "").lower() in DONE_STATUSES and before_status not in DONE_STATUSES + if became_done and task.assignee_id: + background_tasks.add_task( + skill_score_service.process_task_completion, + task_id=int(task.id), + tenant_id=tenant_id, + operator_user_id=int(current_user.id), + ) + _sync_pmflow_rag_async( + tenant_id=tenant_id, + record_type="task", + record_id=int(task.id), + project_id=pid, + title=task.title, + content=(task.description or "") + f"\n状态: {task.status}", + ) + + return success_response({"id": str(task.id), "status": task.status}) + diff --git a/backend/app/api/sstc_api/tasks_create.py b/backend/app/api/sstc_api/tasks_create.py new file mode 100644 index 00000000..15be6cdf --- /dev/null +++ b/backend/app/api/sstc_api/tasks_create.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from .shared import * + +from ...services.sstc.entity_lookup import _ensure_charter, _ensure_project, _parse_due_date +from ...services.sstc.governance import _assert_charter_aligned, _require_manager +from ...services.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async + +router = APIRouter(tags=["sstc"]) + +class TaskCreateRequest(BaseModel): + title: str + description: Optional[str] = None + required_skill_tag_ids: Optional[list[int]] = None + priority: str = "medium" + estimated_hours: Optional[float] = None + due_date: Optional[str] = None + +@router.post("/projects/{project_id}/charters/{charter_id}/tasks") +async def create_task( + project_id: str, + charter_id: str, + body: TaskCreateRequest, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + cid = _parse_node_id(charter_id, "charter_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) + _assert_charter_aligned(charter) + + due_date = _parse_due_date(body.due_date) + + task = await persist_task( + db, + tenant_id=tenant_id, + truth_node_id=cid, + project_id=pid, + draft=build_task_create_draft( + title=body.title, + description=body.description, + required_skill_tag_ids=body.required_skill_tag_ids or [], + priority=body.priority, + status="todo", + estimated_hours=body.estimated_hours, + due_date=due_date, + ), + actor_user_id=int(current_user.id), + action="task.create", + remark="sstc.create_task", + commit=False, + refresh_workload=False, + ) + await db.commit() + await db.refresh(task) + if task.assignee_id is not None: + await workload_service.refresh_user_assigned_hours_from_tasks( + db, + tenant_id=tenant_id, + user_id=int(task.assignee_id), + ) + _sync_pmflow_rag_async( + tenant_id=tenant_id, + record_type="task", + record_id=int(task.id), + project_id=pid, + title=task.title, + content=task.description or "", + ) + return success_response( + { + "id": str(task.id), + "title": task.title, + "status": task.status, + "priority": task.priority, + "charter_id": str(cid), + } + ) + diff --git a/backend/app/api/sstc_api/tasks_recommend_optimize.py b/backend/app/api/sstc_api/tasks_recommend_optimize.py new file mode 100644 index 00000000..3339077d --- /dev/null +++ b/backend/app/api/sstc_api/tasks_recommend_optimize.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from .shared import * + +from ...services.sstc.entity_lookup import _ensure_project, _ensure_task +from ...services.sstc.governance import _require_manager +from ...services.sstc.signal_core import _parse_node_id, _parse_project_id + +router = APIRouter(tags=["sstc"]) + +@router.post("/projects/{project_id}/tasks/{task_id}/recommend-assignee") +async def recommend_assignee_for_task( + project_id: str, + task_id: str, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + pid = _parse_project_id(project_id) + tid = _parse_node_id(task_id, "task_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + task = await _ensure_task(db, tenant_id=tenant_id, project_id=pid, task_id=tid) + + required_skills = [str(x) for x in (task.required_skill_tag_ids or [])] + recommendations = await recommend_personnel( + db=db, + tenant_id=tenant_id, + required_skills=required_skills, + task_description=task.description or task.title, + task_id=str(task.id), + limit=5, + ) + + project_user_ids_result = await db.execute( + select(UserProject.user_id).where( + and_( + UserProject.tenant_id == tenant_id, + UserProject.project_id == pid, + ) + ) + ) + project_user_ids = {int(uid) for uid in project_user_ids_result.scalars().all()} + + active_users_result = await db.execute( + select(User.id).where( + and_( + User.tenant_id == tenant_id, + User.status == "active", + User.is_active.is_(True), + ) + ) + ) + active_user_ids = {int(uid) for uid in active_users_result.scalars().all()} + + filtered = [] + for item in recommendations.get("recommendations", []): + try: + uid = int(item.get("user_id")) + except (TypeError, ValueError): + continue + if uid in project_user_ids and uid in active_user_ids: + filtered.append(item) + + recommendations["recommendations"] = filtered + recommendations["total_count"] = len(filtered) + return success_response(recommendations) + +@router.post("/projects/{project_id}/tasks/optimize-workload") +async def optimize_task_workload( + project_id: str, + body: WorkloadOptimizeRequest = Body(default_factory=WorkloadOptimizeRequest), + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + proposals = await workload_service.build_ai_optimize_preview( + db, + tenant_id=tenant_id, + project_id=pid, + max_moves=body.max_moves, + unassigned_only=body.mode == "unassigned", + ) + if not proposals: + return success_response( + { + "updated": 0, + "message": "no tasks to optimize", + "mode": body.mode, + } + ) + + optimize_result = await workload_service.apply_ai_optimize_plan( + db, + tenant_id=tenant_id, + proposals=proposals, + ) + updated = int(optimize_result.get("applied_count") or 0) + affected_users = int(optimize_result.get("affected_users") or 0) + + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="task", + entity_id=str(pid), + action="optimize_workload", + after_data={ + "updated": updated, + "affected_users": affected_users, + "proposals": len(proposals), + "mode": body.mode, + }, + ) + await db.commit() + return success_response( + { + "updated": updated, + "affected_users": affected_users, + "proposals": len(proposals), + "mode": body.mode, + } + ) + diff --git a/backend/app/api/sstc_api/tasks_update_delete.py b/backend/app/api/sstc_api/tasks_update_delete.py new file mode 100644 index 00000000..31feeffb --- /dev/null +++ b/backend/app/api/sstc_api/tasks_update_delete.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from .shared import * + +from ...services.sstc.entity_lookup import _ensure_project, _ensure_task, _parse_due_date +from ...services.sstc.governance import _require_manager +from ...services.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async + +router = APIRouter(tags=["sstc"]) + +class TaskUpdateRequest(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + assignee_id: Optional[int] = None + required_skill_tag_ids: Optional[list[int]] = None + priority: Optional[str] = None + estimated_hours: Optional[float] = None + due_date: Optional[str] = None + +@router.patch("/projects/{project_id}/tasks/{task_id}") +async def patch_task( + project_id: str, + task_id: str, + body: TaskUpdateRequest, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + tid = _parse_node_id(task_id, "task_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + task = await _ensure_task(db, tenant_id=tenant_id, project_id=pid, task_id=tid) + + before = { + "title": task.title, + "description": task.description, + "assignee_id": task.assignee_id, + "priority": task.priority, + "estimated_hours": float(task.estimated_hours) if task.estimated_hours is not None else None, + "due_date": task.due_date.isoformat() if task.due_date else None, + } + + old_assignee_id = int(task.assignee_id) if task.assignee_id else None + if body.title is not None: + task.title = compact_title(body.title, max_len=32) + if body.description is not None: + task.description = body.description + if body.assignee_id is not None: + task.assignee_id = body.assignee_id + if body.required_skill_tag_ids is not None: + task.required_skill_tag_ids = body.required_skill_tag_ids + if body.priority is not None: + task.priority = body.priority + if body.estimated_hours is not None: + task.estimated_hours = body.estimated_hours + if body.due_date is not None: + task.due_date = _parse_due_date(body.due_date) + + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="task", + entity_id=str(tid), + action="update", + before_data=before, + after_data={ + "title": task.title, + "description": task.description, + "assignee_id": task.assignee_id, + "priority": task.priority, + "estimated_hours": float(task.estimated_hours) if task.estimated_hours is not None else None, + "due_date": task.due_date.isoformat() if task.due_date else None, + }, + ) + + touched_user_ids: set[int] = set() + if old_assignee_id: + touched_user_ids.add(old_assignee_id) + if task.assignee_id: + touched_user_ids.add(int(task.assignee_id)) + + if touched_user_ids: + for uid in touched_user_ids: + await workload_service.refresh_user_assigned_hours_from_tasks( + db, + tenant_id=tenant_id, + user_id=uid, + ) + else: + await db.commit() + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + for uid in touched_user_ids: + await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=uid) + _sync_pmflow_rag_async( + tenant_id=tenant_id, + record_type="task", + record_id=int(task.id), + project_id=pid, + title=task.title, + content=task.description or "", + ) + + return success_response({"id": str(task.id), "status": task.status}) + +@router.delete("/projects/{project_id}/tasks/{task_id}") +async def delete_task( + project_id: str, + task_id: str, + permission_user: User = Depends(require_project_member_or_manager), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + _ = permission_user + _require_manager(current_user) + pid = _parse_project_id(project_id) + tid = _parse_node_id(task_id, "task_id") + tenant_id = int(current_user.tenant_id) + await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + task = await _ensure_task(db, tenant_id=tenant_id, project_id=pid, task_id=tid) + assignee_id = int(task.assignee_id) if task.assignee_id else None + + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=pid, + entity_type="task", + entity_id=str(tid), + action="delete", + before_data={ + "assignee_id": assignee_id, + "estimated_hours": float(task.estimated_hours) if task.estimated_hours else None, + "status": task.status, + }, + ) + await db.execute( + delete(Task).where( + and_( + Task.id == tid, + Task.project_id == pid, + Task.tenant_id == tenant_id, + ) + ) + ) + + if assignee_id: + await workload_service.refresh_user_assigned_hours_from_tasks( + db, + tenant_id=tenant_id, + user_id=assignee_id, + ) + else: + await db.commit() + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + if assignee_id: + await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=assignee_id) + + return success_response({"id": str(tid), "deleted": True}) + diff --git a/backend/app/api/users.py b/backend/app/api/users.py index b3053d8d..27b6d0ce 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -1,1110 +1,5 @@ -"""用户管理 API。""" - from __future__ import annotations -import csv -from datetime import datetime -from io import BytesIO, StringIO -from typing import Any, Optional +from .users_api import router -from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status -from pydantic import BaseModel -from sqlalchemy import and_, delete, func, or_, select, update -from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession - -from ..database import get_db -from ..dependencies import ( - get_current_active_user, - get_current_user, - get_tenant_id, - require_roles, - require_self_or_manager, -) -from ..models import ( - SkillTag, - Task, - User, - UserProject, - UserRole, - UserSkillBinding, - UserStatus, - UserWorkload, -) -from ..schemas import ( - UserCreate, - UserResetPasswordRequest, - UserResponse, - UserStatusPatchRequest, - UserUpdate, -) -from ..services.auth import AuthService -from ..services.ai_pmo_kernel_service import AIPMOKernelService -from ..services.operation_log_service import log_operation -from ..services import skill_service - -from ..utils.timezone import now_cn_naive -router = APIRouter(prefix="/users", tags=["users"]) - -COMPLETED_TASK_STATUSES = {"done", "completed", "closed", "resolved", "archived"} -MANAGER_ROLE_SET = {"BOSS", "ADMIN"} -VALID_ROLE_VALUES = {r.value for r in UserRole} - - -class BulkDeleteUsersRequest(BaseModel): - user_ids: list[int] - - -class BulkAssignUsersRequest(BaseModel): - user_ids: list[int] - role: Optional[str] = None - project_ids: Optional[list[int]] = None - - -def _enum_value(value: Any) -> Any: - return value.value if hasattr(value, "value") else value - - -def _parse_role_values(raw: Any) -> list[str]: - if raw is None: - return [] - if isinstance(raw, (list, tuple, set)): - values = [str(_enum_value(item)).strip().upper() for item in raw] - else: - values = [token.strip().upper() for token in str(_enum_value(raw)).split(",")] - deduped: list[str] = [] - for value in values: - if value and value not in deduped: - deduped.append(value) - return deduped - - -def _normalize_role_input(raw: Any, *, allow_empty: bool = False) -> list[str]: - values = _parse_role_values(raw) - if not values and not allow_empty: - raise ValueError("role required") - invalid = [value for value in values if value not in VALID_ROLE_VALUES] - if invalid: - raise ValueError(f"Invalid role: {','.join(invalid)}") - return values - - -def _pack_roles(raw: Any) -> str: - return ",".join(_normalize_role_input(raw)) - - -def _has_any_role(raw: Any, *roles: str) -> bool: - values = set(_parse_role_values(raw)) - expected = {str(role).upper() for role in roles} - return bool(values.intersection(expected)) - - -def _build_role_filter(column: Any, roles: list[str]) -> Any: - filters = [] - for role in roles: - filters.extend( - [ - column == role, - column.like(f"{role},%"), - column.like(f"%,{role},%"), - column.like(f"%,{role}"), - ] - ) - return or_(*filters) - - -def _parse_int_list(raw: Optional[str]) -> list[int]: - if not raw: - return [] - ids: list[int] = [] - for part in raw.split(","): - token = part.strip() - if not token: - continue - try: - ids.append(int(token)) - except ValueError: - continue - return ids - - -def _translate_user_integrity_error(exc: IntegrityError) -> str: - message = str(getattr(exc, "orig", exc)).lower() - if "users.username" in message or "username" in message: - return "用户名已存在" - if "users.email" in message or "email" in message: - return "邮箱已存在" - return "用户数据冲突,请检查用户名或邮箱是否重复" - - -async def _collect_user_enrichment( - db: AsyncSession, - tenant_id: int, - users: list[User], -) -> tuple[dict[int, float], dict[int, list[str]], dict[int, list[int]]]: - if not users: - return {}, {}, {} - - user_ids = [int(u.id) for u in users] - - workloads_result = await db.execute( - select(UserWorkload).where( - and_( - UserWorkload.tenant_id == tenant_id, - UserWorkload.user_id.in_(user_ids), - ) - ) - ) - workload_map: dict[int, float] = { - int(w.user_id): float(w.workload_ratio or 0.0) - for w in workloads_result.scalars().all() - } - - skill_rows = await db.execute( - select(UserSkillBinding.user_id, SkillTag.name, UserSkillBinding.proficiency) - .join(SkillTag, SkillTag.id == UserSkillBinding.skill_tag_id) - .where( - and_( - UserSkillBinding.tenant_id == tenant_id, - UserSkillBinding.user_id.in_(user_ids), - ) - ) - .order_by(UserSkillBinding.user_id, UserSkillBinding.proficiency.desc()) - ) - top_skills: dict[int, list[str]] = {} - for uid, skill_name, _prof in skill_rows.all(): - key = int(uid) - top_skills.setdefault(key, []) - if len(top_skills[key]) < 3: - top_skills[key].append(skill_name) - - project_rows = await db.execute( - select(UserProject.user_id, UserProject.project_id).where( - and_( - UserProject.tenant_id == tenant_id, - UserProject.user_id.in_(user_ids), - ) - ) - ) - project_map: dict[int, list[int]] = {} - for uid, pid in project_rows.all(): - project_map.setdefault(int(uid), []).append(int(pid)) - - return workload_map, top_skills, project_map - - -def _serialize_user( - user: User, - workload_map: dict[int, float] | None = None, - top_skills: dict[int, list[str]] | None = None, - project_map: dict[int, list[int]] | None = None, -) -> dict: - payload = user.to_dict() - uid = int(user.id) - payload["workload_ratio"] = (workload_map or {}).get(uid, float(user.workload or 0)) - payload["top_skills"] = (top_skills or {}).get(uid, []) - payload["project_ids"] = (project_map or {}).get(uid, []) - return payload - - -async def _replace_user_projects( - db: AsyncSession, - tenant_id: int, - user_id: int, - project_ids: list[int], -) -> None: - existing_rows = await db.execute( - select(UserProject.project_id).where( - and_( - UserProject.tenant_id == tenant_id, - UserProject.user_id == user_id, - ) - ) - ) - existing_project_ids = {int(pid) for pid in existing_rows.scalars().all() if pid is not None} - await db.execute( - delete(UserProject).where( - and_( - UserProject.tenant_id == tenant_id, - UserProject.user_id == user_id, - ) - ) - ) - unique_project_ids = sorted(set(project_ids)) - for pid in unique_project_ids: - db.add(UserProject(tenant_id=tenant_id, user_id=user_id, project_id=pid)) - - # bulk delete(UserProject) 不会触发 ORM 对象事件,这里手动注册缓存失效意图。 - cache_payload = db.sync_session.info.get("_pmflow_cache_invalidation") - if not isinstance(cache_payload, dict): - cache_payload = { - "tenant_ids": set(), - "invalidate_system": False, - "invalidate_project_list": False, - "invalidate_signal_pending": False, - "user_ids": set(), - } - db.sync_session.info["_pmflow_cache_invalidation"] = cache_payload - cache_payload["tenant_ids"].add(int(tenant_id)) - cache_payload["invalidate_system"] = True - cache_payload["invalidate_project_list"] = True - cache_payload["user_ids"].add(int(user_id)) - - affected_project_ids = sorted(existing_project_ids.union({int(pid) for pid in unique_project_ids})) - for project_id in affected_project_ids: - await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=project_id) - - -async def _create_user_with_defaults( - db: AsyncSession, - tenant_id: int, - user_data: UserCreate, - actor_id: int, - *, - commit: bool, -) -> User: - username = user_data.username.strip() - if not username: - raise ValueError("用户名不能为空") - - existing_username = await db.execute( - select(User).where( - and_( - User.tenant_id == tenant_id, - User.username == username, - ) - ) - ) - if existing_username.scalar_one_or_none(): - raise ValueError("用户名已存在") - - if user_data.email: - existing_email = await db.execute( - select(User).where( - and_( - User.tenant_id == tenant_id, - User.email == user_data.email.strip(), - ) - ) - ) - if existing_email.scalar_one_or_none(): - raise ValueError("邮箱已存在") - - user = User( - tenant_id=tenant_id, - username=username, - password_hash=AuthService.hash_password(user_data.password), - name=(user_data.name or username).strip(), - email=(user_data.email or f"{username}@placeholder.local").strip(), - role=_pack_roles(user_data.role), - assigned_domains=user_data.assigned_domains or [], - workload=0, - status=UserStatus.ACTIVE.value, - is_active=True, - ) - db.add(user) - await db.flush() - - db.add( - UserWorkload( - tenant_id=tenant_id, - user_id=int(user.id), - current_assigned_hours=0.0, - max_available_hours_per_week=40.0, - workload_ratio=0.0, - ) - ) - await skill_service.init_user_skill_bindings( - db, - tenant_id=tenant_id, - user_id=int(user.id), - include_system_only=True, - commit=False, - ) - - if user_data.project_ids: - await _replace_user_projects( - db, - tenant_id=tenant_id, - user_id=int(user.id), - project_ids=user_data.project_ids, - ) - - await log_operation( - db, - tenant_id=tenant_id, - user_id=actor_id, - project_id=None, - entity_type="user", - entity_id=str(user.id), - action="create", - before_data=None, - after_data=user.to_dict(), - ) - - if commit: - await db.commit() - await db.refresh(user) - return user - - -@router.get("/me", response_model=UserResponse) -async def get_current_user_info(current_user: User = Depends(get_current_active_user)): - """当前用户信息。""" - return UserResponse(**_serialize_user(current_user)) - - -@router.get("/") -async def list_users( - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), - search: Optional[str] = Query(None, description="用户名/姓名/邮箱关键字"), - role: Optional[str] = Query(None), - status_value: Optional[str] = Query(None, alias="status"), - is_active: Optional[bool] = Query(None), - project_id: Optional[int] = Query(None), - workload_state: Optional[str] = Query(None, description="idle|normal|overloaded"), - skill_ids: Optional[str] = Query(None, description="逗号分隔技能ID"), - tenant_id: int = Depends(get_tenant_id), - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """用户分页列表(支持多维过滤)。""" - _ = current_user - skill_id_list = _parse_int_list(skill_ids) - - query = select(User).where(User.tenant_id == tenant_id) - if role: - try: - role_values = _normalize_role_input(role, allow_empty=True) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) - if role_values: - query = query.where(_build_role_filter(User.role, role_values)) - if status_value: - query = query.where(User.status == status_value) - if is_active is not None: - query = query.where(User.is_active == is_active) - if search: - q = f"%{search.strip()}%" - query = query.where( - or_( - User.username.ilike(q), - User.name.ilike(q), - User.email.ilike(q), - ) - ) - - if project_id is not None: - query = query.join( - UserProject, - and_( - UserProject.user_id == User.id, - UserProject.tenant_id == tenant_id, - ), - ).where(UserProject.project_id == project_id) - - if skill_id_list: - query = query.join( - UserSkillBinding, - and_( - UserSkillBinding.user_id == User.id, - UserSkillBinding.tenant_id == tenant_id, - ), - ).where(UserSkillBinding.skill_tag_id.in_(skill_id_list)) - - if workload_state: - query = query.outerjoin( - UserWorkload, - and_( - UserWorkload.user_id == User.id, - UserWorkload.tenant_id == tenant_id, - ), - ) - if workload_state == "idle": - query = query.where(func.coalesce(UserWorkload.workload_ratio, 0.0) < 70.0) - elif workload_state == "normal": - query = query.where( - and_( - func.coalesce(UserWorkload.workload_ratio, 0.0) >= 70.0, - func.coalesce(UserWorkload.workload_ratio, 0.0) <= 100.0, - ) - ) - elif workload_state == "overloaded": - query = query.where(func.coalesce(UserWorkload.workload_ratio, 0.0) > 100.0) - - total_result = await db.execute( - select(func.count()).select_from(query.distinct().subquery()) - ) - total = int(total_result.scalar() or 0) - - result = await db.execute( - query.distinct() - .order_by(User.updated_at.desc(), User.id.desc()) - .offset((page - 1) * page_size) - .limit(page_size) - ) - users = list(result.scalars().all()) - workload_map, top_skills, project_map = await _collect_user_enrichment(db, tenant_id, users) - - return { - "items": [ - _serialize_user( - u, - workload_map=workload_map, - top_skills=top_skills, - project_map=project_map, - ) - for u in users - ], - "page": page, - "page_size": page_size, - "total": total, - } - - -@router.get("/available") -async def get_available_staff( - role: Optional[str] = Query(None, description="按角色过滤"), - tenant_id: int = Depends(get_tenant_id), - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """获取可分配任务成员(启用 + active)。""" - _ = current_user - query = select(User).where( - and_( - User.tenant_id == tenant_id, - User.status == UserStatus.ACTIVE.value, - User.is_active == True, - ) - ) - if role: - try: - role_values = _normalize_role_input(role, allow_empty=True) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) - if role_values: - query = query.where(_build_role_filter(User.role, role_values)) - - result = await db.execute(query.order_by(User.workload.asc(), User.id.asc())) - users = result.scalars().all() - return [_serialize_user(u) for u in users] - - -@router.get("/{user_id}") -async def get_user( - user_id: int, - tenant_id: int = Depends(get_tenant_id), - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """获取单用户详情。""" - _ = current_user - result = await db.execute( - select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) - ) - user = result.scalar_one_or_none() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - workloads, skills, projects = await _collect_user_enrichment(db, tenant_id, [user]) - return _serialize_user(user, workloads, skills, projects) - - -@router.post("/") -async def create_user( - user_data: UserCreate, - tenant_id: int = Depends(get_tenant_id), - current_user: User = Depends(require_roles("BOSS", "ADMIN")), - db: AsyncSession = Depends(get_db), -): - """创建用户(自动初始化 user_workloads)。""" - try: - user = await _create_user_with_defaults( - db, - tenant_id=tenant_id, - user_data=user_data, - actor_id=int(current_user.id), - commit=True, - ) - except ValueError as exc: - await db.rollback() - raise HTTPException(status_code=400, detail=str(exc)) - except IntegrityError as exc: - await db.rollback() - raise HTTPException(status_code=400, detail=_translate_user_integrity_error(exc)) - except Exception: - await db.rollback() - raise - - workloads, skills, projects = await _collect_user_enrichment(db, tenant_id, [user]) - return _serialize_user(user, workloads, skills, projects) - - -@router.put("/{user_id}") -@router.patch("/{user_id}") -async def update_user( - user_id: int, - user_data: UserUpdate, - tenant_id: int = Depends(get_tenant_id), - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """更新用户信息。""" - result = await db.execute( - select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) - ) - user = result.scalar_one_or_none() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - current_role = _enum_value(current_user.role) - is_self = int(current_user.id) == user_id - is_manager = _has_any_role(current_role, *MANAGER_ROLE_SET) - if not is_self and not is_manager: - raise HTTPException(status_code=403, detail="No permission") - - if is_self and not is_manager: - # 普通成员仅可修改自身基础资料。 - if user_data.role is not None or user_data.status is not None or user_data.is_active is not None: - raise HTTPException(status_code=403, detail="Members can only edit own profile fields") - if user_data.project_ids is not None: - raise HTTPException(status_code=403, detail="Members cannot change project assignments") - - before_data = user.to_dict() - - if user_data.name is not None: - user.name = user_data.name.strip() - if user_data.email is not None: - user.email = user_data.email.strip() - if user_data.assigned_domains is not None: - user.assigned_domains = user_data.assigned_domains - if user_data.workload is not None: - user.workload = max(0, min(200, int(user_data.workload))) - if user_data.status is not None: - user.status = _enum_value(user_data.status) - user.is_active = user.status == UserStatus.ACTIVE.value - if user_data.is_active is not None: - user.is_active = bool(user_data.is_active) - if not user.is_active: - user.status = UserStatus.OFFLINE.value - elif user.status == UserStatus.OFFLINE.value: - user.status = UserStatus.ACTIVE.value - if user_data.role is not None and is_manager: - try: - user.role = _pack_roles(user_data.role) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) - if user_data.project_ids is not None: - await _replace_user_projects( - db, - tenant_id=tenant_id, - user_id=user_id, - project_ids=user_data.project_ids, - ) - - await log_operation( - db, - tenant_id=tenant_id, - user_id=int(current_user.id), - project_id=None, - entity_type="user", - entity_id=str(user.id), - action="update", - before_data=before_data, - after_data=user.to_dict(), - ) - try: - await db.commit() - await db.refresh(user) - except IntegrityError as exc: - await db.rollback() - raise HTTPException(status_code=400, detail=_translate_user_integrity_error(exc)) - - workloads, skills, projects = await _collect_user_enrichment(db, tenant_id, [user]) - return _serialize_user(user, workloads, skills, projects) - - -@router.post("/{user_id}/reset-password") -async def reset_password( - user_id: int, - body: UserResetPasswordRequest, - tenant_id: int = Depends(get_tenant_id), - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """重置用户密码。""" - result = await db.execute( - select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) - ) - user = result.scalar_one_or_none() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - current_role = _enum_value(current_user.role) - is_manager = _has_any_role(current_role, *MANAGER_ROLE_SET) - if not is_manager and int(current_user.id) != user_id: - raise HTTPException(status_code=403, detail="No permission") - - try: - user.password_hash = AuthService.hash_password(body.new_password) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - user.updated_at = now_cn_naive() - await log_operation( - db, - tenant_id=tenant_id, - user_id=int(current_user.id), - project_id=None, - entity_type="user", - entity_id=str(user.id), - action="reset_password", - before_data=None, - after_data={"updated_at": user.updated_at.isoformat()}, - ) - await db.commit() - return {"message": "Password reset successfully"} - - -@router.patch("/{user_id}/status") -async def patch_user_status( - user_id: int, - body: UserStatusPatchRequest, - tenant_id: int = Depends(get_tenant_id), - current_user: User = Depends(require_roles("BOSS", "ADMIN")), - db: AsyncSession = Depends(get_db), -): - """禁用/启用用户,禁用时清空任务指派并归零负载。""" - result = await db.execute( - select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) - ) - user = result.scalar_one_or_none() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - before_data = user.to_dict() - user.is_active = body.is_active - user.status = UserStatus.ACTIVE.value if body.is_active else UserStatus.OFFLINE.value - - if not body.is_active: - user.workload = 0 - await db.execute( - update(Task) - .where( - and_( - Task.tenant_id == tenant_id, - Task.assignee_id == user_id, - ) - ) - .values(assignee_id=None, updated_at=now_cn_naive()) - ) - workload_result = await db.execute( - select(UserWorkload).where( - and_( - UserWorkload.tenant_id == tenant_id, - UserWorkload.user_id == user_id, - ) - ) - ) - workload = workload_result.scalar_one_or_none() - if not workload: - workload = UserWorkload(tenant_id=tenant_id, user_id=user_id) - db.add(workload) - workload.current_assigned_hours = 0.0 - workload.workload_ratio = 0.0 - - await log_operation( - db, - tenant_id=tenant_id, - user_id=int(current_user.id), - project_id=None, - entity_type="user", - entity_id=str(user.id), - action="status_patch", - before_data=before_data, - after_data=user.to_dict(), - ) - await db.commit() - await db.refresh(user) - return {"message": "Status updated", "user": _serialize_user(user)} - - -@router.delete("/{user_id}") -async def delete_user( - user_id: int, - tenant_id: int = Depends(get_tenant_id), - current_user: User = Depends(require_roles("BOSS", "ADMIN")), - db: AsyncSession = Depends(get_db), -): - """删除用户(有未完成任务时禁止)。""" - if int(current_user.id) == user_id: - raise HTTPException(status_code=400, detail="Cannot delete yourself") - - result = await db.execute( - select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) - ) - user = result.scalar_one_or_none() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - incomplete_count_result = await db.execute( - select(func.count(Task.id)).where( - and_( - Task.tenant_id == tenant_id, - Task.assignee_id == user_id, - func.lower(Task.status).notin_(COMPLETED_TASK_STATUSES), - ) - ) - ) - incomplete_count = int(incomplete_count_result.scalar() or 0) - if incomplete_count > 0: - raise HTTPException( - status_code=400, - detail=f"User has {incomplete_count} incomplete tasks", - ) - - before_data = user.to_dict() - await AIPMOKernelService.invalidate_world_model_cache_for_user( - db=db, - tenant_id=tenant_id, - user_id=user_id, - ) - await db.execute( - delete(UserSkillBinding).where( - and_( - UserSkillBinding.tenant_id == tenant_id, - UserSkillBinding.user_id == user_id, - ) - ) - ) - await db.execute( - delete(UserWorkload).where( - and_( - UserWorkload.tenant_id == tenant_id, - UserWorkload.user_id == user_id, - ) - ) - ) - await db.execute( - delete(UserProject).where( - and_( - UserProject.tenant_id == tenant_id, - UserProject.user_id == user_id, - ) - ) - ) - await db.delete(user) - await log_operation( - db, - tenant_id=tenant_id, - user_id=int(current_user.id), - project_id=None, - entity_type="user", - entity_id=str(user_id), - action="delete", - before_data=before_data, - after_data=None, - ) - await db.commit() - return {"message": "User deleted successfully"} - - -@router.post("/bulk-delete") -async def bulk_delete_users( - body: BulkDeleteUsersRequest, - tenant_id: int = Depends(get_tenant_id), - current_user: User = Depends(require_roles("BOSS", "ADMIN")), - db: AsyncSession = Depends(get_db), -): - """批量删除用户(带未完成任务保护)。""" - if not body.user_ids: - raise HTTPException(status_code=400, detail="user_ids required") - - deleted: list[int] = [] - skipped: list[dict[str, Any]] = [] - - for user_id in sorted(set(body.user_ids)): - if int(current_user.id) == user_id: - skipped.append({"user_id": user_id, "reason": "Cannot delete yourself"}) - continue - - user = ( - await db.execute( - select(User).where( - and_( - User.tenant_id == tenant_id, - User.id == user_id, - ) - ) - ) - ).scalar_one_or_none() - if not user: - skipped.append({"user_id": user_id, "reason": "User not found"}) - continue - - incomplete_count_result = await db.execute( - select(func.count(Task.id)).where( - and_( - Task.tenant_id == tenant_id, - Task.assignee_id == user_id, - func.lower(Task.status).notin_(COMPLETED_TASK_STATUSES), - ) - ) - ) - incomplete_count = int(incomplete_count_result.scalar() or 0) - if incomplete_count > 0: - skipped.append( - { - "user_id": user_id, - "reason": f"User has {incomplete_count} incomplete tasks", - } - ) - continue - - await AIPMOKernelService.invalidate_world_model_cache_for_user( - db=db, - tenant_id=tenant_id, - user_id=user_id, - ) - await db.execute( - delete(UserSkillBinding).where( - and_( - UserSkillBinding.tenant_id == tenant_id, - UserSkillBinding.user_id == user_id, - ) - ) - ) - await db.execute( - delete(UserWorkload).where( - and_( - UserWorkload.tenant_id == tenant_id, - UserWorkload.user_id == user_id, - ) - ) - ) - await db.execute( - delete(UserProject).where( - and_( - UserProject.tenant_id == tenant_id, - UserProject.user_id == user_id, - ) - ) - ) - await db.delete(user) - deleted.append(user_id) - - await db.commit() - return {"deleted_count": len(deleted), "deleted_user_ids": deleted, "skipped": skipped} - - -@router.post("/bulk-assign") -async def bulk_assign_users( - body: BulkAssignUsersRequest, - tenant_id: int = Depends(get_tenant_id), - current_user: User = Depends(require_roles("BOSS", "ADMIN")), - db: AsyncSession = Depends(get_db), -): - """批量分配项目/角色。""" - user_ids = sorted(set(body.user_ids or [])) - if not user_ids: - raise HTTPException(status_code=400, detail="user_ids required") - if body.role is None and body.project_ids is None: - raise HTTPException(status_code=400, detail="role or project_ids required") - - updated = 0 - skipped: list[dict[str, Any]] = [] - - for user_id in user_ids: - user = ( - await db.execute( - select(User).where( - and_( - User.tenant_id == tenant_id, - User.id == user_id, - ) - ) - ) - ).scalar_one_or_none() - if not user: - skipped.append({"user_id": user_id, "reason": "User not found"}) - continue - - before_data = user.to_dict() - - if body.role is not None: - if not _has_any_role(_enum_value(current_user.role), "BOSS"): - skipped.append({"user_id": user_id, "reason": "Only BOSS can bulk assign roles"}) - continue - try: - user.role = _pack_roles(body.role) - except ValueError as exc: - skipped.append({"user_id": user_id, "reason": str(exc)}) - continue - - if body.project_ids is not None: - await _replace_user_projects( - db, - tenant_id=tenant_id, - user_id=user_id, - project_ids=body.project_ids, - ) - - await log_operation( - db, - tenant_id=tenant_id, - user_id=int(current_user.id), - project_id=None, - entity_type="user", - entity_id=str(user_id), - action="bulk_assign", - before_data=before_data, - after_data=user.to_dict(), - remark=f"role={body.role}, projects={body.project_ids}", - ) - updated += 1 - - await db.commit() - return {"updated_count": updated, "skipped": skipped} - - -@router.put("/{user_id}/workload") -async def update_user_workload( - user_id: int, - workload: int = Query(..., ge=0, le=200, description="Workload value (0-200)"), - tenant_id: int = Depends(get_tenant_id), - current_user: User = Depends(require_self_or_manager), - db: AsyncSession = Depends(get_db), -): - """手动更新用户 workload 百分比。""" - _ = current_user - - result = await db.execute( - select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) - ) - user = result.scalar_one_or_none() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - user.workload = max(0, min(200, int(workload))) - user.updated_at = now_cn_naive() - await db.commit() - await db.refresh(user) - return _serialize_user(user) - - -def _normalize_import_row(raw: dict[str, Any]) -> dict[str, Any]: - lower_map = {str(k).strip().lower(): v for k, v in raw.items()} - return { - "username": str(lower_map.get("username", "")).strip(), - "password": str(lower_map.get("password", "")).strip(), - "name": str(lower_map.get("name", "")).strip(), - "email": str(lower_map.get("email", "")).strip() or None, - "role": str(lower_map.get("role", "FE")).strip().upper(), - "project_ids": _parse_int_list(str(lower_map.get("project_ids", ""))), - } - - -async def _iter_import_rows(file: UploadFile) -> list[dict[str, Any]]: - filename = (file.filename or "").lower() - content = await file.read() - - if filename.endswith(".csv"): - text = content.decode("utf-8-sig") - reader = csv.DictReader(StringIO(text)) - return [dict(row) for row in reader] - - if filename.endswith(".xlsx") or filename.endswith(".xls"): - try: - from openpyxl import load_workbook # type: ignore - except Exception as exc: - raise HTTPException( - status_code=400, - detail=f"Excel import requires openpyxl: {exc}", - ) - - wb = load_workbook(filename=BytesIO(content), read_only=True) - sheet = wb.active - rows = list(sheet.iter_rows(values_only=True)) - if not rows: - return [] - headers = [str(v).strip() if v is not None else "" for v in rows[0]] - data_rows: list[dict[str, Any]] = [] - for row in rows[1:]: - item = { - headers[idx]: row[idx] - for idx in range(min(len(headers), len(row))) - if headers[idx] - } - data_rows.append(item) - return data_rows - - raise HTTPException(status_code=400, detail="Only .csv/.xlsx/.xls are supported") - - -@router.post("/import") -async def import_users( - file: UploadFile = File(...), - tenant_id: int = Depends(get_tenant_id), - current_user: User = Depends(require_roles("BOSS", "ADMIN")), - db: AsyncSession = Depends(get_db), -): - """批量导入用户(最多 50 行)。""" - rows = await _iter_import_rows(file) - if not rows: - raise HTTPException(status_code=400, detail="Import file is empty") - if len(rows) > 50: - raise HTTPException(status_code=400, detail="Import rows exceed max limit: 50") - - results: list[dict[str, Any]] = [] - success_count = 0 - - for idx, raw in enumerate(rows, start=1): - row = _normalize_import_row(raw) - try: - if row["role"] not in {r.value for r in UserRole}: - raise ValueError(f"Invalid role: {row['role']}") - if not row["username"] or not row["password"]: - raise ValueError("username/password required") - - data = UserCreate( - username=row["username"], - password=row["password"], - name=row["name"] or row["username"], - email=row["email"], - role=UserRole(row["role"]), - assigned_domains=[], - project_ids=row["project_ids"], - ) - user = await _create_user_with_defaults( - db, - tenant_id=tenant_id, - user_data=data, - actor_id=int(current_user.id), - commit=True, - ) - success_count += 1 - results.append( - { - "row": idx, - "status": "success", - "user_id": str(user.id), - "message": "created", - } - ) - except Exception as exc: - await db.rollback() - results.append( - { - "row": idx, - "status": "failed", - "message": str(exc), - } - ) - - return { - "total": len(rows), - "success": success_count, - "failed": len(rows) - success_count, - "results": results, - } +__all__ = ["router"] diff --git a/backend/app/api/users_api/__init__.py b/backend/app/api/users_api/__init__.py new file mode 100644 index 00000000..87b39da6 --- /dev/null +++ b/backend/app/api/users_api/__init__.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from fastapi import APIRouter + +from . import routes_bulk, routes_import, routes_mutation, routes_query + +router = APIRouter(prefix="/users", tags=["users"]) +for _module in [routes_query, routes_mutation, routes_bulk, routes_import]: + router.include_router(_module.router) + +__all__ = ["router"] diff --git a/backend/app/api/users_api/helpers_roles.py b/backend/app/api/users_api/helpers_roles.py new file mode 100644 index 00000000..a5ec82f9 --- /dev/null +++ b/backend/app/api/users_api/helpers_roles.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from .shared import * + +def _enum_value(value: Any) -> Any: + return value.value if hasattr(value, "value") else value + +def _parse_role_values(raw: Any) -> list[str]: + if raw is None: + return [] + if isinstance(raw, (list, tuple, set)): + values = [str(_enum_value(item)).strip().upper() for item in raw] + else: + values = [token.strip().upper() for token in str(_enum_value(raw)).split(",")] + deduped: list[str] = [] + for value in values: + if value and value not in deduped: + deduped.append(value) + return deduped + +def _normalize_role_input(raw: Any, *, allow_empty: bool = False) -> list[str]: + values = _parse_role_values(raw) + if not values and not allow_empty: + raise ValueError("role required") + invalid = [value for value in values if value not in VALID_ROLE_VALUES] + if invalid: + raise ValueError(f"Invalid role: {','.join(invalid)}") + return values + +def _pack_roles(raw: Any) -> str: + return ",".join(_normalize_role_input(raw)) + +def _has_any_role(raw: Any, *roles: str) -> bool: + values = set(_parse_role_values(raw)) + expected = {str(role).upper() for role in roles} + return bool(values.intersection(expected)) + +def _build_role_filter(column: Any, roles: list[str]) -> Any: + filters = [] + for role in roles: + filters.extend( + [ + column == role, + column.like(f"{role},%"), + column.like(f"%,{role},%"), + column.like(f"%,{role}"), + ] + ) + return or_(*filters) + +def _parse_int_list(raw: Optional[str]) -> list[int]: + if not raw: + return [] + ids: list[int] = [] + for part in raw.split(","): + token = part.strip() + if not token: + continue + try: + ids.append(int(token)) + except ValueError: + continue + return ids + diff --git a/backend/app/api/users_api/helpers_user.py b/backend/app/api/users_api/helpers_user.py new file mode 100644 index 00000000..88297d7b --- /dev/null +++ b/backend/app/api/users_api/helpers_user.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +from .shared import * +from .helpers_roles import _normalize_role_input, _pack_roles + +def _translate_user_integrity_error(exc: IntegrityError) -> str: + message = str(getattr(exc, "orig", exc)).lower() + if "users.username" in message or "username" in message: + return "用户名已存在" + if "users.email" in message or "email" in message: + return "邮箱已存在" + return "用户数据冲突,请检查用户名或邮箱是否重复" + +async def _collect_user_enrichment( + db: AsyncSession, + tenant_id: int, + users: list[User], +) -> tuple[dict[int, float], dict[int, list[str]], dict[int, list[int]]]: + if not users: + return {}, {}, {} + + user_ids = [int(u.id) for u in users] + + workloads_result = await db.execute( + select(UserWorkload).where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id.in_(user_ids), + ) + ) + ) + workload_map: dict[int, float] = { + int(w.user_id): float(w.workload_ratio or 0.0) + for w in workloads_result.scalars().all() + } + + skill_rows = await db.execute( + select(UserSkillBinding.user_id, SkillTag.name, UserSkillBinding.proficiency) + .join(SkillTag, SkillTag.id == UserSkillBinding.skill_tag_id) + .where( + and_( + UserSkillBinding.tenant_id == tenant_id, + UserSkillBinding.user_id.in_(user_ids), + ) + ) + .order_by(UserSkillBinding.user_id, UserSkillBinding.proficiency.desc()) + ) + top_skills: dict[int, list[str]] = {} + for uid, skill_name, _prof in skill_rows.all(): + key = int(uid) + top_skills.setdefault(key, []) + if len(top_skills[key]) < 3: + top_skills[key].append(skill_name) + + project_rows = await db.execute( + select(UserProject.user_id, UserProject.project_id).where( + and_( + UserProject.tenant_id == tenant_id, + UserProject.user_id.in_(user_ids), + ) + ) + ) + project_map: dict[int, list[int]] = {} + for uid, pid in project_rows.all(): + project_map.setdefault(int(uid), []).append(int(pid)) + + return workload_map, top_skills, project_map + +def _serialize_user( + user: User, + workload_map: dict[int, float] | None = None, + top_skills: dict[int, list[str]] | None = None, + project_map: dict[int, list[int]] | None = None, +) -> dict: + payload = user.to_dict() + uid = int(user.id) + payload["workload_ratio"] = (workload_map or {}).get(uid, float(user.workload or 0)) + payload["top_skills"] = (top_skills or {}).get(uid, []) + payload["project_ids"] = (project_map or {}).get(uid, []) + return payload + +async def _replace_user_projects( + db: AsyncSession, + tenant_id: int, + user_id: int, + project_ids: list[int], +) -> None: + existing_rows = await db.execute( + select(UserProject.project_id).where( + and_( + UserProject.tenant_id == tenant_id, + UserProject.user_id == user_id, + ) + ) + ) + existing_project_ids = {int(pid) for pid in existing_rows.scalars().all() if pid is not None} + await db.execute( + delete(UserProject).where( + and_( + UserProject.tenant_id == tenant_id, + UserProject.user_id == user_id, + ) + ) + ) + unique_project_ids = sorted(set(project_ids)) + for pid in unique_project_ids: + db.add(UserProject(tenant_id=tenant_id, user_id=user_id, project_id=pid)) + + # bulk delete(UserProject) 不会触发 ORM 对象事件,这里手动注册缓存失效意图。 + cache_payload = db.sync_session.info.get("_pmflow_cache_invalidation") + if not isinstance(cache_payload, dict): + cache_payload = { + "tenant_ids": set(), + "invalidate_system": False, + "invalidate_project_list": False, + "invalidate_signal_pending": False, + "user_ids": set(), + } + db.sync_session.info["_pmflow_cache_invalidation"] = cache_payload + cache_payload["tenant_ids"].add(int(tenant_id)) + cache_payload["invalidate_system"] = True + cache_payload["invalidate_project_list"] = True + cache_payload["user_ids"].add(int(user_id)) + + affected_project_ids = sorted(existing_project_ids.union({int(pid) for pid in unique_project_ids})) + for project_id in affected_project_ids: + await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=project_id) + +async def _create_user_with_defaults( + db: AsyncSession, + tenant_id: int, + user_data: UserCreate, + actor_id: int, + *, + commit: bool, +) -> User: + username = user_data.username.strip() + if not username: + raise ValueError("用户名不能为空") + + existing_username = await db.execute( + select(User).where( + and_( + User.tenant_id == tenant_id, + User.username == username, + ) + ) + ) + if existing_username.scalar_one_or_none(): + raise ValueError("用户名已存在") + + if user_data.email: + existing_email = await db.execute( + select(User).where( + and_( + User.tenant_id == tenant_id, + User.email == user_data.email.strip(), + ) + ) + ) + if existing_email.scalar_one_or_none(): + raise ValueError("邮箱已存在") + + user = User( + tenant_id=tenant_id, + username=username, + password_hash=AuthService.hash_password(user_data.password), + name=(user_data.name or username).strip(), + email=(user_data.email or f"{username}@placeholder.local").strip(), + role=_pack_roles(user_data.role), + assigned_domains=user_data.assigned_domains or [], + workload=0, + status=UserStatus.ACTIVE.value, + is_active=True, + ) + db.add(user) + await db.flush() + + db.add( + UserWorkload( + tenant_id=tenant_id, + user_id=int(user.id), + current_assigned_hours=0.0, + max_available_hours_per_week=40.0, + workload_ratio=0.0, + ) + ) + await skill_service.init_user_skill_bindings( + db, + tenant_id=tenant_id, + user_id=int(user.id), + include_system_only=True, + commit=False, + ) + + if user_data.project_ids: + await _replace_user_projects( + db, + tenant_id=tenant_id, + user_id=int(user.id), + project_ids=user_data.project_ids, + ) + + await log_operation( + db, + tenant_id=tenant_id, + user_id=actor_id, + project_id=None, + entity_type="user", + entity_id=str(user.id), + action="create", + before_data=None, + after_data=user.to_dict(), + ) + + if commit: + await db.commit() + await db.refresh(user) + return user + +def _normalize_import_row(raw: dict[str, Any]) -> dict[str, Any]: + lower_map = {str(k).strip().lower(): v for k, v in raw.items()} + return { + "username": str(lower_map.get("username", "")).strip(), + "password": str(lower_map.get("password", "")).strip(), + "name": str(lower_map.get("name", "")).strip(), + "email": str(lower_map.get("email", "")).strip() or None, + "role": str(lower_map.get("role", "FE")).strip().upper(), + "project_ids": _parse_int_list(str(lower_map.get("project_ids", ""))), + } + +async def _iter_import_rows(file: UploadFile) -> list[dict[str, Any]]: + filename = (file.filename or "").lower() + content = await file.read() + + if filename.endswith(".csv"): + text = content.decode("utf-8-sig") + reader = csv.DictReader(StringIO(text)) + return [dict(row) for row in reader] + + if filename.endswith(".xlsx") or filename.endswith(".xls"): + try: + from openpyxl import load_workbook # type: ignore + except Exception as exc: + raise HTTPException( + status_code=400, + detail=f"Excel import requires openpyxl: {exc}", + ) + + wb = load_workbook(filename=BytesIO(content), read_only=True) + sheet = wb.active + rows = list(sheet.iter_rows(values_only=True)) + if not rows: + return [] + headers = [str(v).strip() if v is not None else "" for v in rows[0]] + data_rows: list[dict[str, Any]] = [] + for row in rows[1:]: + item = { + headers[idx]: row[idx] + for idx in range(min(len(headers), len(row))) + if headers[idx] + } + data_rows.append(item) + return data_rows + + raise HTTPException(status_code=400, detail="Only .csv/.xlsx/.xls are supported") + diff --git a/backend/app/api/users_api/routes_bulk.py b/backend/app/api/users_api/routes_bulk.py new file mode 100644 index 00000000..7dcca3df --- /dev/null +++ b/backend/app/api/users_api/routes_bulk.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +from .shared import * +from .helpers_roles import _normalize_role_input, _pack_roles +from .helpers_user import _replace_user_projects, _serialize_user +from .schemas import BulkAssignUsersRequest, BulkDeleteUsersRequest + +@router.delete("/{user_id}") +async def delete_user( + user_id: int, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), + db: AsyncSession = Depends(get_db), +): + """删除用户(有未完成任务时禁止)。""" + if int(current_user.id) == user_id: + raise HTTPException(status_code=400, detail="Cannot delete yourself") + + result = await db.execute( + select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) + ) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + incomplete_count_result = await db.execute( + select(func.count(Task.id)).where( + and_( + Task.tenant_id == tenant_id, + Task.assignee_id == user_id, + func.lower(Task.status).notin_(COMPLETED_TASK_STATUSES), + ) + ) + ) + incomplete_count = int(incomplete_count_result.scalar() or 0) + if incomplete_count > 0: + raise HTTPException( + status_code=400, + detail=f"User has {incomplete_count} incomplete tasks", + ) + + before_data = user.to_dict() + await AIPMOKernelService.invalidate_world_model_cache_for_user( + db=db, + tenant_id=tenant_id, + user_id=user_id, + ) + await db.execute( + delete(UserSkillBinding).where( + and_( + UserSkillBinding.tenant_id == tenant_id, + UserSkillBinding.user_id == user_id, + ) + ) + ) + await db.execute( + delete(UserWorkload).where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id == user_id, + ) + ) + ) + await db.execute( + delete(UserProject).where( + and_( + UserProject.tenant_id == tenant_id, + UserProject.user_id == user_id, + ) + ) + ) + await db.delete(user) + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=None, + entity_type="user", + entity_id=str(user_id), + action="delete", + before_data=before_data, + after_data=None, + ) + await db.commit() + return {"message": "User deleted successfully"} + +@router.post("/bulk-delete") +async def bulk_delete_users( + body: BulkDeleteUsersRequest, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), + db: AsyncSession = Depends(get_db), +): + """批量删除用户(带未完成任务保护)。""" + if not body.user_ids: + raise HTTPException(status_code=400, detail="user_ids required") + + deleted: list[int] = [] + skipped: list[dict[str, Any]] = [] + + for user_id in sorted(set(body.user_ids)): + if int(current_user.id) == user_id: + skipped.append({"user_id": user_id, "reason": "Cannot delete yourself"}) + continue + + user = ( + await db.execute( + select(User).where( + and_( + User.tenant_id == tenant_id, + User.id == user_id, + ) + ) + ) + ).scalar_one_or_none() + if not user: + skipped.append({"user_id": user_id, "reason": "User not found"}) + continue + + incomplete_count_result = await db.execute( + select(func.count(Task.id)).where( + and_( + Task.tenant_id == tenant_id, + Task.assignee_id == user_id, + func.lower(Task.status).notin_(COMPLETED_TASK_STATUSES), + ) + ) + ) + incomplete_count = int(incomplete_count_result.scalar() or 0) + if incomplete_count > 0: + skipped.append( + { + "user_id": user_id, + "reason": f"User has {incomplete_count} incomplete tasks", + } + ) + continue + + await AIPMOKernelService.invalidate_world_model_cache_for_user( + db=db, + tenant_id=tenant_id, + user_id=user_id, + ) + await db.execute( + delete(UserSkillBinding).where( + and_( + UserSkillBinding.tenant_id == tenant_id, + UserSkillBinding.user_id == user_id, + ) + ) + ) + await db.execute( + delete(UserWorkload).where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id == user_id, + ) + ) + ) + await db.execute( + delete(UserProject).where( + and_( + UserProject.tenant_id == tenant_id, + UserProject.user_id == user_id, + ) + ) + ) + await db.delete(user) + deleted.append(user_id) + + await db.commit() + return {"deleted_count": len(deleted), "deleted_user_ids": deleted, "skipped": skipped} + +@router.post("/bulk-assign") +async def bulk_assign_users( + body: BulkAssignUsersRequest, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), + db: AsyncSession = Depends(get_db), +): + """批量分配项目/角色。""" + user_ids = sorted(set(body.user_ids or [])) + if not user_ids: + raise HTTPException(status_code=400, detail="user_ids required") + if body.role is None and body.project_ids is None: + raise HTTPException(status_code=400, detail="role or project_ids required") + + updated = 0 + skipped: list[dict[str, Any]] = [] + + for user_id in user_ids: + user = ( + await db.execute( + select(User).where( + and_( + User.tenant_id == tenant_id, + User.id == user_id, + ) + ) + ) + ).scalar_one_or_none() + if not user: + skipped.append({"user_id": user_id, "reason": "User not found"}) + continue + + before_data = user.to_dict() + + if body.role is not None: + if not _has_any_role(_enum_value(current_user.role), "BOSS"): + skipped.append({"user_id": user_id, "reason": "Only BOSS can bulk assign roles"}) + continue + try: + user.role = _pack_roles(body.role) + except ValueError as exc: + skipped.append({"user_id": user_id, "reason": str(exc)}) + continue + + if body.project_ids is not None: + await _replace_user_projects( + db, + tenant_id=tenant_id, + user_id=user_id, + project_ids=body.project_ids, + ) + + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=None, + entity_type="user", + entity_id=str(user_id), + action="bulk_assign", + before_data=before_data, + after_data=user.to_dict(), + remark=f"role={body.role}, projects={body.project_ids}", + ) + updated += 1 + + await db.commit() + return {"updated_count": updated, "skipped": skipped} + diff --git a/backend/app/api/users_api/routes_import.py b/backend/app/api/users_api/routes_import.py new file mode 100644 index 00000000..817defc7 --- /dev/null +++ b/backend/app/api/users_api/routes_import.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from .shared import * +from .helpers_user import _iter_import_rows, _create_user_with_defaults + +@router.put("/{user_id}/workload") +async def update_user_workload( + user_id: int, + workload: int = Query(..., ge=0, le=200, description="Workload value (0-200)"), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_self_or_manager), + db: AsyncSession = Depends(get_db), +): + """手动更新用户 workload 百分比。""" + _ = current_user + + result = await db.execute( + select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) + ) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + user.workload = max(0, min(200, int(workload))) + user.updated_at = now_cn_naive() + await db.commit() + await db.refresh(user) + return _serialize_user(user) + +@router.post("/import") +async def import_users( + file: UploadFile = File(...), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), + db: AsyncSession = Depends(get_db), +): + """批量导入用户(最多 50 行)。""" + rows = await _iter_import_rows(file) + if not rows: + raise HTTPException(status_code=400, detail="Import file is empty") + if len(rows) > 50: + raise HTTPException(status_code=400, detail="Import rows exceed max limit: 50") + + results: list[dict[str, Any]] = [] + success_count = 0 + + for idx, raw in enumerate(rows, start=1): + row = _normalize_import_row(raw) + try: + if row["role"] not in {r.value for r in UserRole}: + raise ValueError(f"Invalid role: {row['role']}") + if not row["username"] or not row["password"]: + raise ValueError("username/password required") + + data = UserCreate( + username=row["username"], + password=row["password"], + name=row["name"] or row["username"], + email=row["email"], + role=UserRole(row["role"]), + assigned_domains=[], + project_ids=row["project_ids"], + ) + user = await _create_user_with_defaults( + db, + tenant_id=tenant_id, + user_data=data, + actor_id=int(current_user.id), + commit=True, + ) + success_count += 1 + results.append( + { + "row": idx, + "status": "success", + "user_id": str(user.id), + "message": "created", + } + ) + except Exception as exc: + await db.rollback() + results.append( + { + "row": idx, + "status": "failed", + "message": str(exc), + } + ) + + return { + "total": len(rows), + "success": success_count, + "failed": len(rows) - success_count, + "results": results, + } + diff --git a/backend/app/api/users_api/routes_mutation.py b/backend/app/api/users_api/routes_mutation.py new file mode 100644 index 00000000..b62301b3 --- /dev/null +++ b/backend/app/api/users_api/routes_mutation.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +from .shared import * +from .helpers_roles import _normalize_role_input, _pack_roles +from .helpers_user import _replace_user_projects, _serialize_user, _translate_user_integrity_error, _create_user_with_defaults + +@router.post("/") +async def create_user( + user_data: UserCreate, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), + db: AsyncSession = Depends(get_db), +): + """创建用户(自动初始化 user_workloads)。""" + try: + user = await _create_user_with_defaults( + db, + tenant_id=tenant_id, + user_data=user_data, + actor_id=int(current_user.id), + commit=True, + ) + except ValueError as exc: + await db.rollback() + raise HTTPException(status_code=400, detail=str(exc)) + except IntegrityError as exc: + await db.rollback() + raise HTTPException(status_code=400, detail=_translate_user_integrity_error(exc)) + except Exception: + await db.rollback() + raise + + workloads, skills, projects = await _collect_user_enrichment(db, tenant_id, [user]) + return _serialize_user(user, workloads, skills, projects) + +@router.put("/{user_id}") +@router.patch("/{user_id}") +async def update_user( + user_id: int, + user_data: UserUpdate, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """更新用户信息。""" + result = await db.execute( + select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) + ) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + current_role = _enum_value(current_user.role) + is_self = int(current_user.id) == user_id + is_manager = _has_any_role(current_role, *MANAGER_ROLE_SET) + if not is_self and not is_manager: + raise HTTPException(status_code=403, detail="No permission") + + if is_self and not is_manager: + # 普通成员仅可修改自身基础资料。 + if user_data.role is not None or user_data.status is not None or user_data.is_active is not None: + raise HTTPException(status_code=403, detail="Members can only edit own profile fields") + if user_data.project_ids is not None: + raise HTTPException(status_code=403, detail="Members cannot change project assignments") + + before_data = user.to_dict() + + if user_data.name is not None: + user.name = user_data.name.strip() + if user_data.email is not None: + user.email = user_data.email.strip() + if user_data.assigned_domains is not None: + user.assigned_domains = user_data.assigned_domains + if user_data.workload is not None: + user.workload = max(0, min(200, int(user_data.workload))) + if user_data.status is not None: + user.status = _enum_value(user_data.status) + user.is_active = user.status == UserStatus.ACTIVE.value + if user_data.is_active is not None: + user.is_active = bool(user_data.is_active) + if not user.is_active: + user.status = UserStatus.OFFLINE.value + elif user.status == UserStatus.OFFLINE.value: + user.status = UserStatus.ACTIVE.value + if user_data.role is not None and is_manager: + try: + user.role = _pack_roles(user_data.role) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + if user_data.project_ids is not None: + await _replace_user_projects( + db, + tenant_id=tenant_id, + user_id=user_id, + project_ids=user_data.project_ids, + ) + + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=None, + entity_type="user", + entity_id=str(user.id), + action="update", + before_data=before_data, + after_data=user.to_dict(), + ) + try: + await db.commit() + await db.refresh(user) + except IntegrityError as exc: + await db.rollback() + raise HTTPException(status_code=400, detail=_translate_user_integrity_error(exc)) + + workloads, skills, projects = await _collect_user_enrichment(db, tenant_id, [user]) + return _serialize_user(user, workloads, skills, projects) + +@router.post("/{user_id}/reset-password") +async def reset_password( + user_id: int, + body: UserResetPasswordRequest, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """重置用户密码。""" + result = await db.execute( + select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) + ) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + current_role = _enum_value(current_user.role) + is_manager = _has_any_role(current_role, *MANAGER_ROLE_SET) + if not is_manager and int(current_user.id) != user_id: + raise HTTPException(status_code=403, detail="No permission") + + try: + user.password_hash = AuthService.hash_password(body.new_password) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + user.updated_at = now_cn_naive() + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=None, + entity_type="user", + entity_id=str(user.id), + action="reset_password", + before_data=None, + after_data={"updated_at": user.updated_at.isoformat()}, + ) + await db.commit() + return {"message": "Password reset successfully"} + +@router.patch("/{user_id}/status") +async def patch_user_status( + user_id: int, + body: UserStatusPatchRequest, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(require_roles("BOSS", "ADMIN")), + db: AsyncSession = Depends(get_db), +): + """禁用/启用用户,禁用时清空任务指派并归零负载。""" + result = await db.execute( + select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) + ) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + before_data = user.to_dict() + user.is_active = body.is_active + user.status = UserStatus.ACTIVE.value if body.is_active else UserStatus.OFFLINE.value + + if not body.is_active: + user.workload = 0 + await db.execute( + update(Task) + .where( + and_( + Task.tenant_id == tenant_id, + Task.assignee_id == user_id, + ) + ) + .values(assignee_id=None, updated_at=now_cn_naive()) + ) + workload_result = await db.execute( + select(UserWorkload).where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id == user_id, + ) + ) + ) + workload = workload_result.scalar_one_or_none() + if not workload: + workload = UserWorkload(tenant_id=tenant_id, user_id=user_id) + db.add(workload) + workload.current_assigned_hours = 0.0 + workload.workload_ratio = 0.0 + + await log_operation( + db, + tenant_id=tenant_id, + user_id=int(current_user.id), + project_id=None, + entity_type="user", + entity_id=str(user.id), + action="status_patch", + before_data=before_data, + after_data=user.to_dict(), + ) + await db.commit() + await db.refresh(user) + return {"message": "Status updated", "user": _serialize_user(user)} + diff --git a/backend/app/api/users_api/routes_query.py b/backend/app/api/users_api/routes_query.py new file mode 100644 index 00000000..61a4b6bf --- /dev/null +++ b/backend/app/api/users_api/routes_query.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +from .shared import * +from .helpers_roles import _build_role_filter, _has_any_role, _normalize_role_input, _parse_int_list +from .helpers_user import _collect_user_enrichment, _serialize_user + +@router.get("/me", response_model=UserResponse) +async def get_current_user_info(current_user: User = Depends(get_current_active_user)): + """当前用户信息。""" + return UserResponse(**_serialize_user(current_user)) + +@router.get("/") +async def list_users( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + search: Optional[str] = Query(None, description="用户名/姓名/邮箱关键字"), + role: Optional[str] = Query(None), + status_value: Optional[str] = Query(None, alias="status"), + is_active: Optional[bool] = Query(None), + project_id: Optional[int] = Query(None), + workload_state: Optional[str] = Query(None, description="idle|normal|overloaded"), + skill_ids: Optional[str] = Query(None, description="逗号分隔技能ID"), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """用户分页列表(支持多维过滤)。""" + _ = current_user + skill_id_list = _parse_int_list(skill_ids) + + query = select(User).where(User.tenant_id == tenant_id) + if role: + try: + role_values = _normalize_role_input(role, allow_empty=True) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + if role_values: + query = query.where(_build_role_filter(User.role, role_values)) + if status_value: + query = query.where(User.status == status_value) + if is_active is not None: + query = query.where(User.is_active == is_active) + if search: + q = f"%{search.strip()}%" + query = query.where( + or_( + User.username.ilike(q), + User.name.ilike(q), + User.email.ilike(q), + ) + ) + + if project_id is not None: + query = query.join( + UserProject, + and_( + UserProject.user_id == User.id, + UserProject.tenant_id == tenant_id, + ), + ).where(UserProject.project_id == project_id) + + if skill_id_list: + query = query.join( + UserSkillBinding, + and_( + UserSkillBinding.user_id == User.id, + UserSkillBinding.tenant_id == tenant_id, + ), + ).where(UserSkillBinding.skill_tag_id.in_(skill_id_list)) + + if workload_state: + query = query.outerjoin( + UserWorkload, + and_( + UserWorkload.user_id == User.id, + UserWorkload.tenant_id == tenant_id, + ), + ) + if workload_state == "idle": + query = query.where(func.coalesce(UserWorkload.workload_ratio, 0.0) < 70.0) + elif workload_state == "normal": + query = query.where( + and_( + func.coalesce(UserWorkload.workload_ratio, 0.0) >= 70.0, + func.coalesce(UserWorkload.workload_ratio, 0.0) <= 100.0, + ) + ) + elif workload_state == "overloaded": + query = query.where(func.coalesce(UserWorkload.workload_ratio, 0.0) > 100.0) + + total_result = await db.execute( + select(func.count()).select_from(query.distinct().subquery()) + ) + total = int(total_result.scalar() or 0) + + result = await db.execute( + query.distinct() + .order_by(User.updated_at.desc(), User.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + users = list(result.scalars().all()) + workload_map, top_skills, project_map = await _collect_user_enrichment(db, tenant_id, users) + + return { + "items": [ + _serialize_user( + u, + workload_map=workload_map, + top_skills=top_skills, + project_map=project_map, + ) + for u in users + ], + "page": page, + "page_size": page_size, + "total": total, + } + +@router.get("/available") +async def get_available_staff( + role: Optional[str] = Query(None, description="按角色过滤"), + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取可分配任务成员(启用 + active)。""" + _ = current_user + query = select(User).where( + and_( + User.tenant_id == tenant_id, + User.status == UserStatus.ACTIVE.value, + User.is_active == True, + ) + ) + if role: + try: + role_values = _normalize_role_input(role, allow_empty=True) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + if role_values: + query = query.where(_build_role_filter(User.role, role_values)) + + result = await db.execute(query.order_by(User.workload.asc(), User.id.asc())) + users = result.scalars().all() + return [_serialize_user(u) for u in users] + +@router.get("/{user_id}") +async def get_user( + user_id: int, + tenant_id: int = Depends(get_tenant_id), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取单用户详情。""" + _ = current_user + result = await db.execute( + select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) + ) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + workloads, skills, projects = await _collect_user_enrichment(db, tenant_id, [user]) + return _serialize_user(user, workloads, skills, projects) + diff --git a/backend/app/api/users_api/schemas.py b/backend/app/api/users_api/schemas.py new file mode 100644 index 00000000..3b582f43 --- /dev/null +++ b/backend/app/api/users_api/schemas.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from .shared import BaseModel, Optional + +class BulkDeleteUsersRequest(BaseModel): + user_ids: list[int] + +class BulkAssignUsersRequest(BaseModel): + user_ids: list[int] + role: Optional[str] = None + project_ids: Optional[list[int]] = None + diff --git a/backend/app/api/users_api/shared.py b/backend/app/api/users_api/shared.py new file mode 100644 index 00000000..4c1170aa --- /dev/null +++ b/backend/app/api/users_api/shared.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import csv +from datetime import datetime +from io import BytesIO, StringIO +from typing import Any, Optional + +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status +from pydantic import BaseModel +from sqlalchemy import and_, delete, func, or_, select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from ...database import get_db +from ...dependencies import ( + get_current_active_user, + get_current_user, + get_tenant_id, + require_roles, + require_self_or_manager, +) +from ...models import ( + SkillTag, + Task, + User, + UserProject, + UserRole, + UserSkillBinding, + UserStatus, + UserWorkload, +) +from ...schemas import ( + UserCreate, + UserResetPasswordRequest, + UserResponse, + UserStatusPatchRequest, + UserUpdate, +) +from ...services import skill_service +from ...services.ai_pmo_kernel_service import AIPMOKernelService +from ...services.auth import AuthService +from ...services.operation_log_service import log_operation +from ...utils.timezone import now_cn_naive + +router = APIRouter(prefix="/users", tags=["users"]) + +COMPLETED_TASK_STATUSES = {"done", "completed", "closed", "resolved", "archived"} +MANAGER_ROLE_SET = {"BOSS", "ADMIN"} +VALID_ROLE_VALUES = {r.value for r in UserRole} diff --git a/backend/app/api/wecom.py b/backend/app/api/wecom.py index b6cc25a2..65c05d38 100644 --- a/backend/app/api/wecom.py +++ b/backend/app/api/wecom.py @@ -1,327 +1,7 @@ -""" -WeChat Work (企业微信) Integration API - 企业微信集成接口 +"""WeCom API compatibility module.""" -功能: -1. 配置企业微信应用凭证 -2. 验证 URL 和处理事件 -3. 发送消息到企业微信群 -4. 查询集成状态 -""" -from fastapi import APIRouter, Depends, HTTPException, Request, BackgroundTasks -from sqlalchemy.ext.asyncio import AsyncSession -from pydantic import BaseModel, Field -from typing import List, Optional, Dict, Any +from __future__ import annotations -from ..database import get_db -from ..dependencies.auth import get_current_user -from ..dependencies.tenant import get_tenant_id -from ..models import User -from ..services.auth import has_any_role -from ..services.wecom_integration import get_wecom_service +from .wecom_api import router - -router = APIRouter(prefix="/wecom", tags=["WeChat Work Integration"]) - - -# ==================== Request/Response Models ==================== - -class WeComConfigRequest(BaseModel): - """企业微信配置请求""" - corp_id: str = Field(..., description="企业 ID") - agent_id: str = Field(..., description="应用 ID") - app_secret: str = Field(..., description="应用 Secret") - token: str = Field(..., description="回调验证 Token") - encoding_aes_key: str = Field(..., description="消息加密密钥") - - -class WeComSendMessageRequest(BaseModel): - """发送消息请求""" - chat_id: str = Field(..., description="群聊 ID") - message: str = Field(..., description="消息内容") - - -# ==================== Configuration Endpoints ==================== - -@router.post("/config") -async def save_wecom_config( - request: WeComConfigRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 保存企业微信配置 - - 配置信息说明: - - corp_id: 企业 ID,从企业微信管理后台获取 - - agent_id: 应用 ID - - app_secret: 应用 Secret - - token: 事件订阅验证 Token - - encoding_aes_key: 消息加密密钥 - - 获取步骤: - 1. 访问 https://work.weixin.qq.com/ - 2. 进入应用管理 -> 自建应用 - 3. 在「开发配置」获取 Agent ID 和 Secret - 4. 在「接收消息」配置 Token 和 EncodingAESKey - """ - if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]): - raise HTTPException(status_code=403, detail="只有管理员可以配置企业微信集成") - - try: - service = get_wecom_service(db) - await service.save_config( - corp_id=request.corp_id, - agent_id=request.agent_id, - app_secret=request.app_secret, - token=request.token, - encoding_aes_key=request.encoding_aes_key, - tenant_id=tenant_id - ) - - return { - 'status': 'success', - 'message': '企业微信配置已保存', - 'next_steps': [ - '1. 在企业微信管理平台配置接收消息 URL', - f'2. URL: https://your-domain.com/api/wecom/webhook', - '3. 点击验证按钮完成 URL 验证' - ] - } - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"保存配置失败: {str(e)}" - ) - - -@router.get("/config") -async def get_wecom_config( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """获取企业微信配置状态。管理员可获取完整配置用于回填修改表单。""" - try: - service = get_wecom_service(db) - await service.load_config(tenant_id) - can_view_secrets = has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]) - - return { - 'is_configured': service.is_configured(), - 'corp_id': service.corp_id, - 'agent_id': service.agent_id, - 'app_secret': service.app_secret if can_view_secrets else None, - 'has_token': bool(service.token), - 'has_encoding_key': bool(service.encoding_aes_key), - 'token_preview': f"{service.token[:4]}...{service.token[-4:]}" if service.token and len(service.token) > 8 else (service.token or None), - 'encoding_aes_key_preview': f"{service.encoding_aes_key[:4]}...{service.encoding_aes_key[-4:]}" if service.encoding_aes_key and len(service.encoding_aes_key) > 8 else (service.encoding_aes_key or None), - 'token': service.token if can_view_secrets else None, - 'encoding_aes_key': service.encoding_aes_key if can_view_secrets else None, - } - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"获取配置失败: {str(e)}" - ) - - -@router.delete("/config") -async def delete_wecom_config( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """删除企业微信配置""" - from ..models import Integration - from sqlalchemy.future import select - - if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]): - raise HTTPException(status_code=403, detail="只有管理员可以删除配置") - - try: - result = await db.execute( - select(Integration).where( - Integration.tenant_id == tenant_id, - Integration.type == 'wecom' - ) - ) - integration = result.scalar_one_or_none() - - if integration: - integration.is_active = False - await db.commit() - - return {'status': 'success', 'message': '企业微信配置已删除'} - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"删除配置失败: {str(e)}" - ) - - -# ==================== Webhook Endpoints ==================== - -@router.get("/webhook") -@router.get("/webhook/", include_in_schema=False) -async def verify_webhook_url( - msg_signature: str, - timestamp: str, - nonce: str, - echostr: str, - db: AsyncSession = Depends(get_db) -): - """ - 验证企业微信事件订阅 URL - - 企业微信会发送 GET 请求来验证 URL 有效性 - """ - try: - service = get_wecom_service(db) - await service.load_config(tenant_id=1) - - if not service.is_configured(): - raise HTTPException( - status_code=400, - detail="请先配置企业微信" - ) - - decrypted_echostr = service.verify_url(msg_signature, timestamp, nonce, echostr) - - return { - "errcode": 0, - "errmsg": "ok", - "echostr": decrypted_echostr - } - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/webhook") -@router.post("/webhook/", include_in_schema=False) -async def handle_wecom_event( - request: Request, - db: AsyncSession = Depends(get_db) -): - """ - 处理企业微信事件推送 - - 企业微信会发送 POST 请求推送事件 - 需要验证签名确保请求来自企业微信 - """ - try: - # 获取请求头 - msg_signature = request.headers.get('msg_signature', '') - timestamp = request.headers.get('timestamp', '') - nonce = request.headers.get('nonce', '') - - # 读取请求体 - body_bytes = await request.body() - body = body_bytes.decode('utf-8') - - # 加载企业微信配置 - service = get_wecom_service(db) - await service.load_config(tenant_id=1) - - # 验证签名 - if not service.verify_callback(msg_signature, timestamp, nonce, body): - raise HTTPException(status_code=401, detail="签名验证失败") - - # 解析事件 - from ..services.wecom_integration import WeChatWorkIntegrationService - decrypted = service._decrypt(body) - event_data = json.loads(decrypted) if decrypted else {} - - # 处理事件 - result = await service.handle_message_event(event_data) - - return { - "errcode": 0, - "errmsg": "ok" - } - - except HTTPException: - raise - except Exception as e: - print(f"[WeCom] Webhook error: {e}") - # 企业微信要求返回成功状态,避免重复推送 - return {'errcode': 0, 'errmsg': 'ok'} - - -# ==================== Message Endpoints ==================== - -@router.post("/send-message") -async def send_wecom_message( - request: WeComSendMessageRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """ - 发送文本消息到企业微信群 - - 用于发送简报、通知等 - """ - try: - service = get_wecom_service(db) - await service.load_config(tenant_id) - - result = await service.send_message_to_group( - chat_id=request.chat_id, - message=request.message - ) - - return { - 'status': 'success', - 'result': result - } - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"发送消息失败: {str(e)}" - ) - - -# ==================== Test Endpoints ==================== - -@router.post("/test-connection") -async def test_wecom_connection( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id) -): - """测试企业微信连接是否正常""" - try: - service = get_wecom_service(db) - await service.load_config(tenant_id) - - if not service.is_configured(): - return { - 'status': 'error', - 'message': '企业微信未配置' - } - - # 尝试获取 access_token 测试连接 - try: - access_token = await service._get_access_token() - return { - 'status': 'success', - 'message': '企业微信连接正常', - 'has_token': bool(access_token) - } - except Exception as token_error: - return { - 'status': 'error', - 'message': f'获取 Token 失败: {str(token_error)}', - 'hint': '请检查 Corp ID、Agent ID 和 App Secret 是否正确' - } - - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"测试连接失败: {str(e)}" - ) +__all__ = ["router"] diff --git a/backend/app/api/wecom_api/__init__.py b/backend/app/api/wecom_api/__init__.py new file mode 100644 index 00000000..63de244e --- /dev/null +++ b/backend/app/api/wecom_api/__init__.py @@ -0,0 +1,16 @@ +"""Router assembly for WeCom API.""" + +from __future__ import annotations + +from fastapi import APIRouter + +from .config import router as config_router +from .messaging import router as messaging_router +from .webhook import router as webhook_router + +router = APIRouter(prefix="/wecom", tags=["WeChat Work Integration"]) +router.include_router(config_router) +router.include_router(webhook_router) +router.include_router(messaging_router) + +__all__ = ["router"] diff --git a/backend/app/api/wecom_api/config.py b/backend/app/api/wecom_api/config.py new file mode 100644 index 00000000..e099723b --- /dev/null +++ b/backend/app/api/wecom_api/config.py @@ -0,0 +1,114 @@ +"""WeCom configuration endpoints.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from ...database import get_db +from ...dependencies.auth import get_current_user +from ...dependencies.tenant import get_tenant_id +from ...models import Integration, User +from ...services.auth import has_any_role +from ...services.wecom_integration import get_wecom_service +from .schemas import WeComConfigRequest + +router = APIRouter() + + +@router.post("/config") +async def save_wecom_config( + request: WeComConfigRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]): + raise HTTPException(status_code=403, detail="只有管理员可以配置企业微信集成") + + try: + service = get_wecom_service(db) + await service.save_config( + corp_id=request.corp_id, + agent_id=request.agent_id, + app_secret=request.app_secret, + token=request.token, + encoding_aes_key=request.encoding_aes_key, + tenant_id=tenant_id, + ) + + return { + "status": "success", + "message": "企业微信配置已保存", + "next_steps": [ + "1. 在企业微信管理平台配置接收消息 URL", + "2. URL: https://your-domain.com/api/wecom/webhook", + "3. 点击验证按钮完成 URL 验证", + ], + } + except Exception as exc: + raise HTTPException(status_code=500, detail=f"保存配置失败: {str(exc)}") + + +@router.get("/config") +async def get_wecom_config( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + """获取企业微信配置状态。管理员可获取完整配置用于回填修改表单。""" + try: + service = get_wecom_service(db) + await service.load_config(tenant_id) + can_view_secrets = has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]) + + return { + "is_configured": service.is_configured(), + "corp_id": service.corp_id, + "agent_id": service.agent_id, + "app_secret": service.app_secret if can_view_secrets else None, + "has_token": bool(service.token), + "has_encoding_key": bool(service.encoding_aes_key), + "token_preview": ( + f"{service.token[:4]}...{service.token[-4:]}" + if service.token and len(service.token) > 8 + else (service.token or None) + ), + "encoding_aes_key_preview": ( + f"{service.encoding_aes_key[:4]}...{service.encoding_aes_key[-4:]}" + if service.encoding_aes_key and len(service.encoding_aes_key) > 8 + else (service.encoding_aes_key or None) + ), + "token": service.token if can_view_secrets else None, + "encoding_aes_key": service.encoding_aes_key if can_view_secrets else None, + } + except Exception as exc: + raise HTTPException(status_code=500, detail=f"获取配置失败: {str(exc)}") + + +@router.delete("/config") +async def delete_wecom_config( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]): + raise HTTPException(status_code=403, detail="只有管理员可以删除配置") + + try: + result = await db.execute( + select(Integration).where( + Integration.tenant_id == tenant_id, + Integration.type == "wecom", + ) + ) + integration = result.scalar_one_or_none() + + if integration: + integration.is_active = False + await db.commit() + + return {"status": "success", "message": "企业微信配置已删除"} + except Exception as exc: + raise HTTPException(status_code=500, detail=f"删除配置失败: {str(exc)}") diff --git a/backend/app/api/wecom_api/messaging.py b/backend/app/api/wecom_api/messaging.py new file mode 100644 index 00000000..42c4cc9c --- /dev/null +++ b/backend/app/api/wecom_api/messaging.py @@ -0,0 +1,77 @@ +"""WeCom messaging/test endpoints.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from ...database import get_db +from ...dependencies.auth import get_current_user +from ...dependencies.tenant import get_tenant_id +from ...models import User +from ...services.wecom_integration import get_wecom_service +from .schemas import WeComSendMessageRequest + +router = APIRouter() + + +@router.post("/send-message") +async def send_wecom_message( + request: WeComSendMessageRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + """发送文本消息到企业微信群。""" + try: + service = get_wecom_service(db) + await service.load_config(tenant_id) + + result = await service.send_message_to_group( + chat_id=request.chat_id, + message=request.message, + ) + + return { + "status": "success", + "result": result, + } + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"发送消息失败: {str(exc)}") + + +@router.post("/test-connection") +async def test_wecom_connection( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + """测试企业微信连接是否正常。""" + try: + service = get_wecom_service(db) + await service.load_config(tenant_id) + + if not service.is_configured(): + return { + "status": "error", + "message": "企业微信未配置", + } + + try: + access_token = await service._get_access_token() + return { + "status": "success", + "message": "企业微信连接正常", + "has_token": bool(access_token), + } + except Exception as token_error: + return { + "status": "error", + "message": f"获取 Token 失败: {str(token_error)}", + "hint": "请检查 Corp ID、Agent ID 和 App Secret 是否正确", + } + + except Exception as exc: + raise HTTPException(status_code=500, detail=f"测试连接失败: {str(exc)}") diff --git a/backend/app/api/wecom_api/schemas.py b/backend/app/api/wecom_api/schemas.py new file mode 100644 index 00000000..5094bbc4 --- /dev/null +++ b/backend/app/api/wecom_api/schemas.py @@ -0,0 +1,22 @@ +"""Schemas for WeCom API.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class WeComConfigRequest(BaseModel): + """企业微信配置请求""" + + corp_id: str = Field(..., description="企业 ID") + agent_id: str = Field(..., description="应用 ID") + app_secret: str = Field(..., description="应用 Secret") + token: str = Field(..., description="回调验证 Token") + encoding_aes_key: str = Field(..., description="消息加密密钥") + + +class WeComSendMessageRequest(BaseModel): + """发送消息请求""" + + chat_id: str = Field(..., description="群聊 ID") + message: str = Field(..., description="消息内容") diff --git a/backend/app/api/wecom_api/webhook.py b/backend/app/api/wecom_api/webhook.py new file mode 100644 index 00000000..2b5b89a0 --- /dev/null +++ b/backend/app/api/wecom_api/webhook.py @@ -0,0 +1,79 @@ +"""WeCom webhook endpoints.""" + +from __future__ import annotations + +import json + +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from ...database import get_db +from ...services.wecom_integration import get_wecom_service + +router = APIRouter() + + +@router.get("/webhook") +@router.get("/webhook/", include_in_schema=False) +async def verify_webhook_url( + msg_signature: str, + timestamp: str, + nonce: str, + echostr: str, + db: AsyncSession = Depends(get_db), +): + """验证企业微信事件订阅 URL。""" + try: + service = get_wecom_service(db) + await service.load_config(tenant_id=1) + + if not service.is_configured(): + raise HTTPException(status_code=400, detail="请先配置企业微信") + + decrypted_echostr = service.verify_url(msg_signature, timestamp, nonce, echostr) + + return { + "errcode": 0, + "errmsg": "ok", + "echostr": decrypted_echostr, + } + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + +@router.post("/webhook") +@router.post("/webhook/", include_in_schema=False) +async def handle_wecom_event( + request: Request, + db: AsyncSession = Depends(get_db), +): + """处理企业微信事件推送。""" + try: + msg_signature = request.headers.get("msg_signature", "") + timestamp = request.headers.get("timestamp", "") + nonce = request.headers.get("nonce", "") + + body_bytes = await request.body() + body = body_bytes.decode("utf-8") + + service = get_wecom_service(db) + await service.load_config(tenant_id=1) + + if not service.verify_callback(msg_signature, timestamp, nonce, body): + raise HTTPException(status_code=401, detail="签名验证失败") + + decrypted = service._decrypt(body) + event_data = json.loads(decrypted) if decrypted else {} + await service.handle_message_event(event_data) + + return { + "errcode": 0, + "errmsg": "ok", + } + except HTTPException: + raise + except Exception as exc: + print(f"[WeCom] Webhook error: {exc}") + return {"errcode": 0, "errmsg": "ok"} diff --git a/backend/app/config/ai_prompts.py b/backend/app/config/ai_prompts.py index 02c2446a..1c4dce84 100644 --- a/backend/app/config/ai_prompts.py +++ b/backend/app/config/ai_prompts.py @@ -1,3 +1,5 @@ +from __future__ import annotations + """ AI Prompt 集中管理 @@ -5,1031 +7,7 @@ AI Prompt 集中管理 使用 .format() 或 f-string 时需保证占位符与调用处一致。 """ -# ============================================================================ -# 影响分析 -# ============================================================================ - -PROMPT_IMPACT_ANALYSIS = """ -Analyze the following news/signal for an AI engineering team. -Determine its "Impact Score" (0.0 to 1.0) based on how critical it is for our RAG/LLM development. - -Signal: "{signal_content}" - -Rules: -- 0.9-1.0: Major SOTA model release (e.g., GPT-5, DeepSeek-V4), critical vulnerability, or massive breakthrough. -- 0.7-0.8: Important update, new useful library, or relevant research paper. -- 0.4-0.6: Interesting but not critical news. -- 0.0-0.3: Irrelevant noise. - -Output ONLY the numeric score (e.g., 0.85). Do not output any text. -""" - - -# ============================================================================ -# 任务拆解 -# ============================================================================ - -PROMPT_TASK_BREAKDOWN = """ -You are an expert AI Technical Project Manager. -Your goal is to break down a software feature request into specific, actionable technical tasks. - -Feature Request: "{description}" - -Required Output Format: JSON Array only. -Each item must have: -- "task_name": Concise technical task description. -- "type": One of ["frontend", "backend", "database"]. -- "estimated_hours": Realistic hourly estimate (float). - -Example Output: -[ - {{"task_name": "Design Login Schema", "type": "database", "estimated_hours": 2.0}}, - {{"task_name": "Implement Auth API", "type": "backend", "estimated_hours": 4.0}} -] - -Ensure the breakdown is comprehensive (Frontend, Backend, Database) and strictly follows the JSON format. -Do not include any markdown formatting or explanation outside the JSON. -""" - - -# ============================================================================ -# 策略分析(CTO 视角) -# ============================================================================ - -PROMPT_STRATEGIC_ANALYSIS_HEADER = """ -You are a Chief Technology Officer (CTO) AI Assistant for our team. - -=== Our Product/Team === -{business_context} -{rag_context} -=== External Technology Signal === -Signal: "{signal_content}" - -=== Your Task === -1. Analyze: How does this signal impact OUR product's competitiveness? -2. Decide: Is it necessary to follow up immediately? (is_strategic) -3. Proposal: If YES, generate a "Strategic Decision Proposal". -4. Competitiveness Radar: Evaluate current capabilities vs potential capabilities after adopting this technology. - - Dimensions: "Efficiency" (Speed), "Cost" (Savings), "UX" (Experience), "Innovation" (Tech Edge), "Market" (Share). - - Score 1-10. -5. Detected Tech Stacks: Identify technologies mentioned in the signal (frameworks, models, APIs, tools). - - Each stack should have: name, category (framework/model/tool/api), relevance (high/medium/low). -6. Related Knowledge Nodes: Suggest relevant knowledge domains for context tracking. - - Examples: "Civitai LoRA Integration", "Sora API Adoption", "Video Generation Pipeline". -7. Plan: Break down the strategy into specific technical tasks. - -Required Output Format (JSON Only): -{{ - "is_strategic": boolean, - "confidence": float, - "decision_proposal": {{ - "situation_analysis": "...", - "recommendation": "...", - "expected_benefits": "..." - }}, - "competitiveness_radar": {{ - "current": [5, 4, 6, 5, 5], - "projected": [8, 7, 9, 9, 7] - }}, - "detected_tech_stacks": [ - {{"name": "Technology Name", "category": "framework|model|tool|api", "relevance": "high|medium|low"}} - ], - "related_knowledge_nodes": [ - {{"title": "Knowledge Node Title", "category": "tech_stack|competitor|market", "summary": "Brief summary"}} - ], - "tasks": [ - {{ - "task_name": "Task description", - "type": "backend/frontend/database", - "estimated_hours": float - }} - ] -}} - -If is_strategic is false, tasks should be empty, but decision_proposal is still required to explain why. -Do not output any markdown formatting or text outside the JSON. -""" - - -# ============================================================================ -# RAG 上下文片段 -# ============================================================================ - -PROMPT_RAG_CONTEXT_HEADER = """ -=== Related Knowledge from Our Database === -The following relevant information was found in our knowledge base: - -""" - -PROMPT_RAG_CONTEXT_ITEM = "[Relevance: {similarity:.1%}]\n{content}\n\n" - - -# ============================================================================ -# 代码审计 -# ============================================================================ - -PROMPT_CODE_AUDIT = """ -You are a Senior Tech Lead performing an automated Code Audit. - -Task: "{task_name}" -Commit Message: "{commit_message}" - -Code Diff: -{code_diff} - -Evaluate the code change against the task requirements. Check for: -1. Security vulnerabilities -2. Performance issues -3. Code style and best practices -4. Functional correctness - -Output JSON with: score (0-100), passed (boolean), issues (list of {{severity, message, suggestion}}), summary. -""" - - -# ============================================================================ -# 知识节点洞察提取 -# ============================================================================ - -PROMPT_CONTEXT_INSIGHTS = """ -Analyze the following technical documentation/context and extract key insights. - -Context: "{content}" - -Output JSON: -{{ - "moat": "Core competitive advantage described", - "tech_specs": "Key technical specifications" -}} -""" - - -# ============================================================================ -# PMFlow 专属 Prompts -# ============================================================================ - - -PROMPT_FEISHU_REQUIREMENT_ANALYSIS = """ -你是 PMFlow 的需求信号分析器。请结合团队现有项目,判断一条飞书群消息是否是“可执行需求”,并判定该需求更可能属于“新项目”还是“已有项目”。 - -团队活跃项目列表(每行一个项目): -{project_summaries} - -群消息: -{message} - -输出 JSON,字段如下: -{{ - "is_valuable": true/false, - "reason": "简短原因", - "impact_score": 0.0-1.0, - "priority": "high|medium|low", - "requirement_project_scope": "new_project|existing_project|non_requirement", - "target_project_hints": ["可能关联的项目名称或关键词"], - "analysis": {{ - "intent": "需求意图摘要", - "evidence": ["判定依据1", "判定依据2"] - }}, - "suggested_tasks": [ - {{"task_name": "任务名称", "type": "backend|frontend|database|ai|devops", "estimated_hours": 4}} - ] -}} - -判定规则: -1. 纯表情、寒暄、确认类回复(如“收到”“好的”“1”)一律判定为 non_requirement。 -2. 若明确是全新方向、全新业务目标、全新系统建设,判定为 new_project。 -3. 若明确是现有功能优化、Bug 修复、已有模块迭代,判定为 existing_project。 -4. 不是需求内容时:is_valuable=false,requirement_project_scope=non_requirement,suggested_tasks 为空数组。 - -只输出 JSON,不要输出任何解释文本或 Markdown。 -""" - - -PROMPT_FEISHU_HUMANIZED_REPLY = """ -直接回答用户问题,不要做自我介绍。 - -你将基于以下上下文生成一条 35-140 字中文回复: -- 当前系统时间(北京时间):{now_cn_text} -- 提及用户:{sender_name} -- 用户消息:{user_message} -- 系统处理状态:{status} -- 分类结果:{classification} -- 处理原因:{reason} -- 处理追踪ID:{ai_trace_id} -- 关联项目ID:{linked_projects_text} - -硬性规则: -1. 首句必须直接回答“用户消息”的核心问题,禁止先讲系统身份或接入方式。 -2. 禁止默认输出“我是...”“我可以...”“我现在通过...接入...”这类自我介绍句式。 -3. 仅当用户明确询问“你是谁/你能做什么/系统能力”时,才允许用一句话说明能力边界。 -4. 若信息不足,请明确缺失信息并给出 1-3 条可执行下一步。 -5. 如果用户询问日期/时间/星期,必须基于“当前系统时间(北京时间)”直接回答,不得编造。 -6. 不要输出 Markdown,不要输出 JSON。 -7. 禁止宣称“已执行/将执行”任何未在上下文明确完成的动作。 -8. 禁止输出数据库内部实施细节(如数据库迁移、DDL、SQL、ALTER TABLE、DROP TABLE、Alembic)。 -9. 当 status/classification 无法证明写入动作已成功时,禁止编造处理步骤。 - -只输出最终回复正文。 -""".strip() - - -PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION = """ -你是 PMFlow 的项目需求信号分流器。 -请基于“项目列表上下文”和“飞书消息”,判断该消息是否为有效需求信号,并识别它属于哪个已有项目。 - -项目列表(JSON 数组): -{projects_json} - -飞书消息: -{message} - -输出 JSON: -{{ - "is_requirement": true/false, - "reason": "判定原因", - "confidence": 0.0-1.0, - "classification": "existing_project|new_project|pending_classification|non_requirement", - "matched_project_ids": [1,2], - "matched_project_titles": ["项目A","项目B"], - "impact_score": 0.0-1.0, - "priority": "high|medium|low", - "summary": "需求摘要(简短)" -}} - -规则: -1. 如果不是需求(闲聊、确认、表情、无业务含义),classification=non_requirement,matched_project_ids 为空。 -2. 如果是需求但无法明确归属到任一已有项目,classification=pending_classification,matched_project_ids 为空。 -3. 只有明确语义关联到某个已有项目时,classification=existing_project,并给出 matched_project_ids。 -4. 如果消息明确在描述“全新立项/新项目”,classification=new_project,matched_project_ids 为空。 -5. 不允许猜测项目归属;不确定时必须给 pending_classification。 - -只输出 JSON,不要输出任何其他文字。 -""" - - -PROMPT_FEISHU_LEVEL_CLASSIFICATION = """ -你是 PMFlow 的飞书消息分流器。请按四维度对消息做 0-1 评分,并判断是 project-level 还是 signal-level。 - -输入: -- 飞书消息:{message} -- 活跃项目列表(JSON):{projects_json} - -输出 JSON: -{{ - "build_intent_score": 0.0-1.0, - "scope_score": 0.0-1.0, - "timeline_score": 0.0-1.0, - "owner_score": 0.0-1.0, - "project_score": 0.0-1.0, - "level": "project|signal", - "level_confidence": 0.0-1.0, - "level_reasoning": "1-2句解释", - "should_split": true/false, - "split_items": [ - {{"level":"project","content":"拆分后的项目部分"}}, - {{"level":"signal","content":"拆分后的信号部分"}} - ] -}} - -约束: -1. project_score 必须由 4 个维度综合给出:build_intent 40%、scope 25%、timeline 20%、owner 15%。 -2. 若消息同时包含明确立项意图与明确信号事实(竞品发布/用户反馈/风险事件),should_split=true。 -3. 不确定是否拆分时 should_split=false,且默认 level=signal。 -4. 只输出 JSON。 -""" - - -PROMPT_FEISHU_SIGNAL_EXTRACTION = """ -你是 PMFlow 的信号结构化助手。请从飞书消息中提取信号字段。 - -输入: -- 飞书消息:{message} -- 活跃项目列表(JSON):{projects_json} - -输出 JSON: -{{ - "title": "一句话标题", - "type": "竞品动态|用户反馈|项目风险|新需求", - "priority": "P0|P1|P2|P3", - "matched_project_id": 1, - "summary": "2-3句摘要", - "reasoning": ["依据1","依据2","依据3"], - "confidence": 0.0-1.0, - "recommended_action": "enter_only|create_charter|trigger_alert" -}} - -约束: -1. 无法确定 matched_project_id 时返回 null。 -2. reasoning 必须是 3 条简短 bullet。 -3. 只输出 JSON。 -""" - - -PROMPT_FEISHU_PROJECT_PREFILL = """ -你是 PMFlow 的项目预填助手。请从飞书消息中抽取新项目建议信息。 - -输入: -- 飞书消息:{message} - -输出 JSON: -{{ - "suggested_name": "项目名", - "suggested_description": "1-2句描述", - "suggested_owner": "负责人名或角色", - "suggested_timeline": "截止日期或周期", - "confidence": 0.0-1.0, - "reasoning": ["依据1","依据2","依据3"] -}} - -约束: -1. reasoning 必须是 3 条简短 bullet。 -2. 没有明确字段时可合理推断并在 reasoning 说明。 -3. 只输出 JSON。 -""" - - -PROMPT_FEISHU_TASK_ORCHESTRATION = """ -你是 PMFlow 的任务编排与人员分配 AI。 -请基于飞书需求信号,为指定项目产出“可执行任务 + 负责人建议”。 - -输入: -- project_json: {project_json} -- signal_json: {signal_json} -- members_json: {members_json} - -输出 JSON: -{{ - "overall_summary": "一句话总结本次编排策略", - "tasks": [ - {{ - "title": "任务标题(8-32字)", - "description": "任务描述(40-200字)", - "priority": "high|medium|low", - "estimated_hours": 1-80, - "required_skill_names": ["FastAPI", "React"], - "recommended_assignee_id": 123, - "assignee_reason": "为什么推荐该成员", - "confidence": 0.0-1.0 - }} - ] -}} - -约束: -1. 任务数量 2-8 条,不要空数组。 -2. 优先覆盖“立即可执行”的任务,避免泛化管理话术。 -3. `recommended_assignee_id` 必须来自 members_json.user_id;无法确认时填 null。 -4. 推荐负责人时同时考虑技能匹配与 workload_ratio,避免把任务继续压给高负载成员。 -5. 只输出 JSON,不要 Markdown,不要解释。 -""" - - -PROMPT_SIGNAL_SOURCE_ONBOARDING = """ -首字符必须是 {{,末字符必须是 }}。 -你是 PMFlow 的信号源接入规划器。判断当前请求是不是“新增/接入一个信号源”,并返回结构化计划。 - -输入: -- request_json: {request_json} -- project_json: {project_json} -- signal_json: {signal_json} - -只输出 JSON: -{{ - "should_onboard": true, - "summary": "一句话说明为什么要接入该信号源", - "source": {{ - "source_kind": "feishu|pmflow|github|webhook|url|browser|custom", - "source_name": "信号源名称", - "source_identifier": "稳定标识;无明确标识时可基于名称做简短稳定键", - "description": "这个信号源要监控什么", - "config": {{}} - }}, - "worker_roles": ["pm_dispatcher", "tech_architect", "delivery_coach"], - "execution_instruction": "交给显式服务执行层的自然语言指令" -}} - -若不是信号源接入请求,输出: -{{ - "should_onboard": false, - "summary": "不是信号源接入请求的原因", - "source": null, - "worker_roles": [], - "execution_instruction": "" -}} - -规则: -1. 只有明确新增/接入/配置信号源时才返回 should_onboard=true。 -2. 不编造 url / chat_id / repo / webhook。 -3. worker_roles 只允许 pm_dispatcher / tech_architect / delivery_coach。 -4. execution_instruction 保持简短,只说接入准备、任务拆解、负责人通知。 -5. 无法确定 source_identifier 时,返回短且稳定的键,不要输出解释。 -6. 禁止输出 thinking、analysis、解释句、Markdown、代码块。 -7. 字段未知时返回空字符串、空数组或 null。 -8. 不要 Markdown,不要解释,只返回 JSON。 -""" - - -PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION = """ -首字符必须是 {{,末字符必须是 }}。 -你是 PMFlow 的下级 AI worker,正在执行一个“signal source onboarding”工作项。 - -输入: -- role_json: {role_json} -- project_json: {project_json} -- source_json: {source_json} -- signal_json: {signal_json} -- commander_json: {commander_json} - -输出 JSON: -{{ - "summary": "本角色已完成的核心产出", - "deliverables": ["产出1", "产出2"], - "risks": ["风险1", "风险2"], - "next_actions": ["下一步1", "下一步2"] -}} - -约束: -1. 输出必须体现当前 role_json 对应角色的职责边界。 -2. `deliverables` / `risks` / `next_actions` 各自最多 4 条。 -3. 不要编造数据库 ID,不要输出代码块。 -4. 顶层必须始终包含 summary / deliverables / risks / next_actions 四个键,即使数组为空。 -5. 字段未知时返回空字符串、空数组或 null。 -6. 禁止输出 thinking、analysis、解释句、Markdown、代码块。 -7. 只输出 JSON,不要 Markdown,不要解释文字。 -""" - - -PROMPT_AI_PMO_INTENT_CLASSIFICATION = """ -首字符必须是 {{,末字符必须是 }}。 -你是 PMFlow 的 AI PMO 意图识别模块。你只做分类,不做执行。 - -输入: -- message_text: {message_text} -- session_last_intent: {session_last_intent} -- session_project_id: {session_project_id} -- world_model_project_id: {world_model_project_id} -- candidate_intents_json: {candidate_intents_json} -- candidate_routes_json: {candidate_routes_json} - -输出 JSON: -{{ - "family": "query|project_create|project_signal|signal_source", - "action": "answer|create|discover|onboard", - "intent": "query.read_only|project.create|signal.create|signal_source.onboard|followup.continue", - "confidence": 0.0, - "project_id": 0, - "requested_count": 1, - "requires_confirmation": false, - "reasoning": "一句话简述判定原因" -}} - -规则: -1. family/action 必须和 candidate_routes_json 可映射,不能自造。 -2. 兼容字段 intent 必须从 candidate_intents_json 内选择,不能自造。 -3. 若 family=project_signal 且 action=discover,requires_confirmation 必须为 true。 -4. requested_count 仅在 project_signal 路由有效,范围 1-5;无法判断时返回 1。 -5. 若可直接回答,优先 family=query action=answer。 -6. “直接新增信号”返回 family=project_signal action=create;“先分析候选让我确认”返回 family=project_signal action=discover。 -7. “新增/接入信号源”返回 family=signal_source action=onboard。 -8. “新增项目”返回 family=project_create action=create。 -2. 若消息是短确认语气,优先结合 session_last_intent 判定。 -9. project_id 无法确定时返回 null,不要猜测无依据项目。 -10. 噪音/寒暄/无关消息返回 family=query action=answer,intent=query.read_only,reasoning 包含 smalltalk_ignore。 -5. 禁止输出 thinking、analysis、解释句、Markdown、代码块。 -11. 若消息表达“新增/增加/添加/新建/创建/建立 + 项目”(如“给我新增个项目”),优先判定 project_create.create;仅当明确是“信号源/来源/webhook/rss/github 接入或创建信号”时才判 signal_source.onboard 或 project_signal.create。 -12. 只输出 JSON,不要额外文本。 -13. 若消息要求直接执行数据库 DDL(建表/改表/删表等),禁止判定为可自动写入意图,必须返回 family=query action=answer,intent=query.read_only,并在 reasoning 包含 ddl_api_required。 -""" - - -PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """ -首字符必须是 {{,末字符必须是 }}。 -你是 PMFlow 的高级信号分析器。请把用户指令拆成可入库的信号列表。 - -输入: -- message_text: {message_text} -- project_id: {project_id} -- requested_count: {requested_count} - -输出 JSON(只输出 JSON): -{{ - "signals": [ - {{ - "title": "信号标题(8-30字)", - "summary": "信号摘要(20-120字)", - "content": "信号详情(40-300字)", - "signal_type": "新需求|项目风险|竞品动态|用户反馈", - "priority": "high|medium|low", - "reasoning": ["依据1", "依据2"] - }} - ] -}} - -规则: -1. signals 数量必须等于 requested_count。 -2. 标题和摘要禁止原样照抄整句用户指令。 -3. 当信息不足时,允许基于指令语义做合理拆分,但必须给出可执行、可入库的信号文本。 -4. 不要输出 Markdown,不要解释,不要代码块。 -""".strip() - - -# ============================================================================ -# Full Adaptive RAG -# ============================================================================ - -PROMPT_RAG_INTENT_CLASSIFICATION = """ -你是 PMFlow 的意图分类器。请判断输入文本属于:question / statement / mixed。 - -输入: -{message} - -输出 JSON: -{{ - "intent": "question|statement|mixed", - "confidence": 0.0, - "question_part": "若无则空字符串", - "statement_part": "若无则空字符串" -}} - -要求:只输出 JSON。 -""" - - -PROMPT_RAG_ANSWER = """ -你是 PMFlow 的 RAG 问答助手,负责项目与人员协同管理。 -要求: -1. 只基于给定上下文回答,不要编造。 -2. 若证据不足,明确说“不确定/信息不足”。 -3. 回答后用“来源:”列出引用的来源编号。 -4. 优先从 System Snapshot 与 User Snapshot 作答,不要忽略结构化业务数据。 -5. 当问题涉及“我/当前用户/我的任务”时,只使用 User Snapshot 对应数据回答。 -6. 不允许出现“无法访问项目数据”这类泛化托辞,若缺失请明确指出缺失字段。 -7. 当问题是“你是谁/你能做什么”时,必须优先使用 System Snapshot.assistant_profile 回答。 -8. 当问题涉及“系统架构/运行机制/技术栈”时,必须优先使用 System Snapshot.platform_profile 回答。 -9. 即使检索上下文为空,也要基于 Snapshot 给出当前系统可验证信息,禁止输出默认兜底句。 -10. 若字段缺失,显式列出缺失字段名,不要假设。 -11. 当问题涉及“成员/人员/负责人/谁在做”时,优先使用 System Snapshot.team_members、User Snapshot 与结构化数据库结果,禁止默认返回项目列表。 - -当前项目ID:{project_id} -问题:{question} -检索最大相关度:{relevance_max} - -System Snapshot(全局): -{system_snapshot} - -User Snapshot(当前用户): -{user_snapshot} - -最近会话(最多10轮): -{conversation} - -检索上下文: -{context_blocks} -""" - -PROMPT_RAG_DB_PROJECT_QA = """ -你是 PMFlow 的项目数据语义检索助手。 -你会收到“用户问题”和“项目索引(JSON)”,请基于索引做语义匹配后回答。 - -规则: -1. 优先语义理解,不要机械关键词匹配;要容错同义词、口语、轻微错别字(如“键身”≈“健身”)。 -2. 若用户问“有没有/是否存在某类项目”,必须给出明确结论(有/没有)并列出匹配项目。 -3. 若用户问“进度/阶段/完成率”,请综合 task_total/done_count/doing_count/pending_count 输出量化结论。 -4. 若证据不足,请明确说明不足点,不要编造。 -5. 若问题焦点是成员/人员/负责人/谁在做(人员管理),该问题不应由项目索引直接回答,必须返回 handled=false。 - -输出 JSON(只输出 JSON,不要 Markdown): -{{ - "handled": true, - "answer": "给用户展示的最终中文回答", - "matched_project_ids": [1, 2], - "confidence": 0.0 -}} - -用户问题: -{question} - -项目索引(JSON): -{project_index} -""" - - -PROMPT_GENERATE_CHARTER = """ -你是一位资深产品经理。根据以下市场/技术信号,生成一份结构化的需求宪章草案。 - -信号内容: -{signal_content} - -请输出 JSON 格式,包含以下字段: -{{ - "title": "需求宪章标题", - "summary": "一句话摘要", - "background": "背景与动机", - "objectives": ["目标1", "目标2"], - "scope": "范围说明", - "success_metrics": ["指标1", "指标2"], - "risks": ["风险1", "风险2"] -}} - -只输出 JSON,不要其他文字。 -""" - - -PROMPT_DECOMPOSE_TASKS = """ -你是一位 AI 技术项目经理。根据以下需求宪章内容,拆解为可执行的任务列表。 -项目 ID:{project_id}。 - -宪章内容: -{charter_content} - -请识别每个任务所需的 AI 开发技能标签(从系统技能中匹配:Python, TypeScript, FastAPI, React, SQLAlchemy, LangChain, LLM调优, RAG构建, 向量数据库, Ollama, Docker, Git 等)。 - -输出 JSON 格式: -{{ - "tasks": [ - {{ - "task_name": "任务名称", - "description": "任务描述", - "type": "frontend|backend|database|ai|devops", - "estimated_hours": 0.0, - "required_skill_names": ["技能1", "技能2"], - "priority": "high|medium|low" - }} - ], - "detected_skills": ["检测到的所有技能"] -}} - -只输出 JSON,不要其他文字。 -""" - - -PROMPT_RECOMMEND_ASSIGNEES = """ -你是一位项目协调员。根据以下任务内容、所需技能、以及候选人信息,推荐 Top3 最合适的负责人。 - -任务内容:{task_content} - -所需技能标签 IDs:{required_skill_tag_ids} - -候选人信息(JSON 数组,每项包含 user_id, user_name, workload, proficiency_avg, skill_match_count): -{candidates_json} - -请考虑:当前负载、技能匹配度、历史表现。输出 JSON: -{{ - "recommendations": [ - {{ - "user_id": "用户ID", - "user_name": "姓名", - "match_score": 0-100, - "reason": "推荐理由" - }} - ] -}} - -只输出 JSON。 -""" - - -PROMPT_ALIGNMENT_SCORE = """ -计算用户对真理节点的认知对齐度(0-100 分)。 - -用户 ID:{user_id} -真理节点 ID:{truth_node_id} - -真理节点内容: -{truth_content} - -用户相关行为/产出(如有): -{user_context} - -请评估该用户对该真理节点的理解与践行程度。输出 JSON: -{{ - "alignment_score": 0-100, - "explanation": "简要说明" -}} - -只输出 JSON。 -""" - - -PROMPT_COGNITIVE_CONFLICTS = """ -识别真理节点下的隐性认知冲突。 - -真理节点 ID:{truth_node_id} -真理节点内容: -{truth_content} - -相关用户的观点/产出: -{user_viewpoints} - -请识别冲突类型(如 definition/定义分歧、priority/优先级冲突、scope/范围争议),输出 JSON: -{{ - "conflicts": [ - {{ - "conflict_type": "类型", - "description": "冲突描述", - "involved_user_ids": ["用户ID"], - "involved_user_names": ["姓名"], - "severity": "low|medium|high" - }} - ] -}} - -只输出 JSON。 -""" - - -PROMPT_EVALUATE_MODEL_REQUIREMENT = """ -评估需求与 AI 模型的匹配度。 - -宪章内容:{charter_content} -模型版本 ID:{model_version_id} -模型信息(如有):{model_info} - -请评估兼容性、成本、风险。输出 JSON: -{{ - "compatibility_score": 0-100, - "cost_estimate_usd": 预估日成本美元, - "cost_estimate_tokens_per_day": 预估日 token 量, - "risk_hints": ["风险1", "风险2"], - "recommendations": ["建议1", "建议2"] -}} - -只输出 JSON。 -""" - - -# ============================================================================ -# 模型能力矩阵专属 Prompts -# ============================================================================ - -PROMPT_ANALYZE_CAPABILITY_GAP = """ -你是一位 AI 模型能力专家。分析以下需求与模型之间的能力差距。 - -需求宪章: -{charter_content} - -模型信息: -{model_info} - -请识别并分类能力差距,输出 JSON: -{{ - "gaps": [ - {{ - "gap_type": "capability|cost|performance|risk", - "severity": "critical|high|medium|low", - "description": "差距描述", - "impact": "对项目的影响", - "suggestion": "改进建议" - }} - ], - "overall_risk_level": "critical|high|medium|low", - "confidence": 0.0-1.0 -}} - -只输出 JSON。 -""" - - -PROMPT_ANALYZE_COST_ROI = """ -分析 AI 项目的成本效益。 - -总成本:{total_cost} -总 Token:{total_tokens} -任务数:{task_count} -项目内容:{truth_node_content} - -请计算: -1. ROI 评分 (0-10) -2. 成本效率评估 -3. 优化建议 - -输出 JSON: -{{ - "roi_score": 0.0-10.0, - "cost_efficiency": "excellent|good|fair|poor", - "cost_per_task": 数值, - "optimization_suggestions": ["建议1", "建议2"], - "benchmark_comparison": "与行业标准比较" -}} - -只输出 JSON。 -""" - - -PROMPT_ANALYZE_COST_OPTIMIZATION = """ -分析成本优化机会。 - -成本历史:{cost_history} -项目内容:{truth_node_content} - -请识别优化机会,输出 JSON: -{{ - "can_downgrade_model": true|false, - "downgrade_savings": 预计节省金额, - "suggested_model": "建议的替代模型", - "high_cache_potential": true|false, - "cache_savings": 预计节省金额, - "batchable_operations": true|false, - "batch_savings": 预计节省金额, - "monthly_cost": 数值, - "total_potential_savings": 数值, - "roi_after_optimization": 数值 -}} - -只输出 JSON。 -""" - - -PROMPT_ASSESS_MODEL_UPDATE_IMPACT = """ -评估模型升级影响。 - -旧模型信息:{old_model_info} -新模型信息:{new_model_info} -受影响宪章:{affected_truth_nodes} - -输出 JSON: -{{ - "cost_change_percentage": 数值, - "performance_change": {{ - "context_window_change_percent": 数值, - "quality_change_percent": 数值 - }}, - "compatibility_risks": [{{"type": "风险类型", "severity": "low|medium|high"}}], - "recommendation": "immediate_upgrade|evaluate|defer" -}} - -只输出 JSON。 -""" - - -PROMPT_EXPERIMENT_SYNC_DECISION = """ -评估实验结果是否可自动同步到宪章。 - -实验信息:{experiment_info} -宪章内容:{truth_content} -阈值:{threshold} - -输出 JSON: -{{ - "confidence_score": 0.0-1.0, - "auto_apply": true|false, - "reason": "原因" -}} - -只输出 JSON。 -""" - - -# ============================================================================ -# Day8: 技能分更新 / 负载优化 -# ============================================================================ - -PROMPT_SKILL_SCORE_UPDATE = """ -你是 PMFlow 的技能评估引擎。 -请根据任务完成结果,给每个技能标签输出质量分与效率分的增量(delta)。 - -输入: -- task_id: {task_id} -- priority: {priority} -- quality_score: {quality_score} -- estimated_hours: {estimated_hours} -- actual_hours: {actual_hours} -- required_skill_tag_ids: {required_skill_tag_ids} - -规则: -1. quality_delta 与 efficiency_delta 范围均为 [-10, 10]。 -2. quality_score 越高,quality_delta 越大。 -3. estimated_hours / actual_hours > 1,表示效率高,efficiency_delta 应偏正;< 1 偏负。 -4. 结果必须覆盖所有 required_skill_tag_ids。 -5. 不输出解释文本,仅输出 JSON。 - -输出 JSON: -{{ - "deltas": [ - {{ - "skill_tag_id": 1, - "quality_delta": 1.2, - "efficiency_delta": -0.5 - }} - ] -}} -""" - - -PROMPT_WORKLOAD_OPTIMIZATION = """ -你是 PMFlow 的任务分配优化器。 -请基于团队成员技能与当前负载,输出任务重分配建议。 - -输入: -- members_json: {members_json} -- tasks_json: {tasks_json} - -约束: -1. 只对未完成任务(todo/in_progress/review/blocked)给建议。 -2. 优先降低 >100% 过载成员负载。 -3. 优先选择技能匹配更高且负载更低的成员。 -4. 每条建议都要给 reason。 -5. 不输出解释文本,仅输出 JSON。 - -输出 JSON: -{{ - "proposals": [ - {{ - "task_id": 101, - "new_assignee_id": 2001, - "reason": "skill_match_high_and_lower_workload" - }} - ] -}} -""" - - -# ============================================================================ -# 食谱热量识别 Prompts -# ============================================================================ - -PROMPT_ANALYZE_FOOD_CALORIES = """ -你是一位专业的营养分析师,擅长通过图片识别食物并估算热量和营养成分。 - -请基于提供的食物描述或特征,识别食物类型并估算营养信息。 - -输入信息: -{food_description} - -请输出 JSON 格式,包含以下字段: -{{ - "food_name": "食物名称", - "confidence": 0.0-1.0, - "estimated_portion_size": "份量大小描述(如:一份、100克、半碗等)", - "calories": 数值, - "protein": 数值, - "fat": 数值, - "carbs": 数值, - "fiber": 数值, - "category": "分类(如:主食、蔬菜、肉类、汤品、甜品、饮品等)", - "cuisine": "菜系(如:中式、西式、日式、韩式等)", - "description": "简短的食物描述", - "analysis_notes": ["分析要点1", "分析要点2"], - "uncertainty_factors": ["不确定因素1(如有)"] -}} - -估算规则: -1. 热量估算范围:考虑到份量不确定性,给出合理的中位值 -2. 营养成分:基于常见食物数据库估算 -3. confidence 根据描述清晰度和食物识别难度给出 -4. 若无法识别,confidence 应 < 0.5,并在 uncertainty_factors 中说明 - -只输出 JSON,不要其他文字或 Markdown。 -""" - - -# ============================================================================ -# Signal Planner(成员匹配 + 执行建议) -# ============================================================================ - -PROMPT_SIGNAL_PLANNER = """ -你是 PMFlow 的 Signal Planner。请基于信号内容、项目上下文和团队成员能力,给出可执行的规划建议。 - -输入: -- 信号ID:{signal_id} -- 信号标题:{signal_title} -- 信号摘要:{signal_summary} -- 信号正文:{signal_content} -- 项目ID:{project_id} -- 项目标题:{project_title} -- 项目归属判定:{project_scope} -- 最近相关信号(JSON):{recent_signals} -- 项目成员能力画像(JSON):{member_profiles} -- 当前时间(北京时间):{now_cn} - -输出 JSON(严格只输出 JSON): -{{ - "summary": "本次分析摘要(<=120字)", - "priority": "high|medium|low", - "assignee_user_id": 123, - "assignee_reason": "为什么推荐该成员", - "execution_plan": [ - "第一步动作", - "第二步动作" - ], - "risks": [ - "风险1", - "风险2" - ], - "confidence": 0.0 -}} - -规则: -1. 若无法确定负责人,assignee_user_id 返回 null,且在 assignee_reason 中说明原因。 -2. execution_plan 必须是 2-5 条可执行动作,不要空泛描述。 -3. confidence 范围 0-1,不能省略。 -4. 只输出 JSON,不要输出 Markdown 或解释文本。 -""" - - -# ============================================================================ -# AI 角色 Prompt 配置(支持前端可视化设置) -# ============================================================================ - +from .ai_prompts_catalog import * def build_default_role_prompt_bundle() -> dict: """返回默认角色 Prompt 配置(每次调用返回新对象)。""" @@ -1099,3 +77,44 @@ def build_default_role_prompt_bundle() -> dict: }, ], } + + +__all__ = [ + "PROMPT_IMPACT_ANALYSIS", + "PROMPT_TASK_BREAKDOWN", + "PROMPT_STRATEGIC_ANALYSIS_HEADER", + "PROMPT_RAG_CONTEXT_HEADER", + "PROMPT_RAG_CONTEXT_ITEM", + "PROMPT_CODE_AUDIT", + "PROMPT_CONTEXT_INSIGHTS", + "PROMPT_FEISHU_REQUIREMENT_ANALYSIS", + "PROMPT_FEISHU_HUMANIZED_REPLY", + "PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION", + "PROMPT_FEISHU_LEVEL_CLASSIFICATION", + "PROMPT_FEISHU_SIGNAL_EXTRACTION", + "PROMPT_FEISHU_PROJECT_PREFILL", + "PROMPT_FEISHU_TASK_ORCHESTRATION", + "PROMPT_SIGNAL_SOURCE_ONBOARDING", + "PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION", + "PROMPT_AI_PMO_INTENT_CLASSIFICATION", + "PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS", + "PROMPT_RAG_INTENT_CLASSIFICATION", + "PROMPT_RAG_ANSWER", + "PROMPT_RAG_DB_PROJECT_QA", + "PROMPT_GENERATE_CHARTER", + "PROMPT_DECOMPOSE_TASKS", + "PROMPT_RECOMMEND_ASSIGNEES", + "PROMPT_ALIGNMENT_SCORE", + "PROMPT_COGNITIVE_CONFLICTS", + "PROMPT_EVALUATE_MODEL_REQUIREMENT", + "PROMPT_ANALYZE_CAPABILITY_GAP", + "PROMPT_ANALYZE_COST_ROI", + "PROMPT_ANALYZE_COST_OPTIMIZATION", + "PROMPT_ASSESS_MODEL_UPDATE_IMPACT", + "PROMPT_EXPERIMENT_SYNC_DECISION", + "PROMPT_SKILL_SCORE_UPDATE", + "PROMPT_WORKLOAD_OPTIMIZATION", + "PROMPT_ANALYZE_FOOD_CALORIES", + "PROMPT_SIGNAL_PLANNER", + "build_default_role_prompt_bundle", +] diff --git a/backend/app/config/ai_prompts_catalog/__init__.py b/backend/app/config/ai_prompts_catalog/__init__.py new file mode 100644 index 00000000..9f657114 --- /dev/null +++ b/backend/app/config/ai_prompts_catalog/__init__.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from .part_01 import PROMPT_IMPACT_ANALYSIS, PROMPT_TASK_BREAKDOWN, PROMPT_STRATEGIC_ANALYSIS_HEADER, PROMPT_RAG_CONTEXT_HEADER, PROMPT_RAG_CONTEXT_ITEM, PROMPT_CODE_AUDIT, PROMPT_CONTEXT_INSIGHTS, PROMPT_FEISHU_REQUIREMENT_ANALYSIS, PROMPT_FEISHU_HUMANIZED_REPLY, PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, PROMPT_FEISHU_LEVEL_CLASSIFICATION +from .part_02 import PROMPT_FEISHU_SIGNAL_EXTRACTION, PROMPT_FEISHU_PROJECT_PREFILL, PROMPT_FEISHU_TASK_ORCHESTRATION, PROMPT_SIGNAL_SOURCE_ONBOARDING, PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION, PROMPT_AI_PMO_INTENT_CLASSIFICATION, PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS, PROMPT_RAG_INTENT_CLASSIFICATION +from .part_03 import PROMPT_RAG_ANSWER, PROMPT_RAG_DB_PROJECT_QA, PROMPT_GENERATE_CHARTER, PROMPT_DECOMPOSE_TASKS, PROMPT_RECOMMEND_ASSIGNEES, PROMPT_ALIGNMENT_SCORE, PROMPT_COGNITIVE_CONFLICTS, PROMPT_EVALUATE_MODEL_REQUIREMENT, PROMPT_ANALYZE_CAPABILITY_GAP, PROMPT_ANALYZE_COST_ROI +from .part_04 import PROMPT_ANALYZE_COST_OPTIMIZATION, PROMPT_ASSESS_MODEL_UPDATE_IMPACT, PROMPT_EXPERIMENT_SYNC_DECISION, PROMPT_SKILL_SCORE_UPDATE, PROMPT_WORKLOAD_OPTIMIZATION, PROMPT_ANALYZE_FOOD_CALORIES, PROMPT_SIGNAL_PLANNER + +__all__ = [ + "PROMPT_IMPACT_ANALYSIS", + "PROMPT_TASK_BREAKDOWN", + "PROMPT_STRATEGIC_ANALYSIS_HEADER", + "PROMPT_RAG_CONTEXT_HEADER", + "PROMPT_RAG_CONTEXT_ITEM", + "PROMPT_CODE_AUDIT", + "PROMPT_CONTEXT_INSIGHTS", + "PROMPT_FEISHU_REQUIREMENT_ANALYSIS", + "PROMPT_FEISHU_HUMANIZED_REPLY", + "PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION", + "PROMPT_FEISHU_LEVEL_CLASSIFICATION", + "PROMPT_FEISHU_SIGNAL_EXTRACTION", + "PROMPT_FEISHU_PROJECT_PREFILL", + "PROMPT_FEISHU_TASK_ORCHESTRATION", + "PROMPT_SIGNAL_SOURCE_ONBOARDING", + "PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION", + "PROMPT_AI_PMO_INTENT_CLASSIFICATION", + "PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS", + "PROMPT_RAG_INTENT_CLASSIFICATION", + "PROMPT_RAG_ANSWER", + "PROMPT_RAG_DB_PROJECT_QA", + "PROMPT_GENERATE_CHARTER", + "PROMPT_DECOMPOSE_TASKS", + "PROMPT_RECOMMEND_ASSIGNEES", + "PROMPT_ALIGNMENT_SCORE", + "PROMPT_COGNITIVE_CONFLICTS", + "PROMPT_EVALUATE_MODEL_REQUIREMENT", + "PROMPT_ANALYZE_CAPABILITY_GAP", + "PROMPT_ANALYZE_COST_ROI", + "PROMPT_ANALYZE_COST_OPTIMIZATION", + "PROMPT_ASSESS_MODEL_UPDATE_IMPACT", + "PROMPT_EXPERIMENT_SYNC_DECISION", + "PROMPT_SKILL_SCORE_UPDATE", + "PROMPT_WORKLOAD_OPTIMIZATION", + "PROMPT_ANALYZE_FOOD_CALORIES", + "PROMPT_SIGNAL_PLANNER", +] diff --git a/backend/app/config/ai_prompts_catalog/part_01.py b/backend/app/config/ai_prompts_catalog/part_01.py new file mode 100644 index 00000000..fbb21ee6 --- /dev/null +++ b/backend/app/config/ai_prompts_catalog/part_01.py @@ -0,0 +1,270 @@ +from __future__ import annotations + +PROMPT_IMPACT_ANALYSIS = """ +Analyze the following news/signal for an AI engineering team. +Determine its "Impact Score" (0.0 to 1.0) based on how critical it is for our RAG/LLM development. + +Signal: "{signal_content}" + +Rules: +- 0.9-1.0: Major SOTA model release (e.g., GPT-5, DeepSeek-V4), critical vulnerability, or massive breakthrough. +- 0.7-0.8: Important update, new useful library, or relevant research paper. +- 0.4-0.6: Interesting but not critical news. +- 0.0-0.3: Irrelevant noise. + +Output ONLY the numeric score (e.g., 0.85). Do not output any text. +""" + +PROMPT_TASK_BREAKDOWN = """ +You are an expert AI Technical Project Manager. +Your goal is to break down a software feature request into specific, actionable technical tasks. + +Feature Request: "{description}" + +Required Output Format: JSON Array only. +Each item must have: +- "task_name": Concise technical task description. +- "type": One of ["frontend", "backend", "database"]. +- "estimated_hours": Realistic hourly estimate (float). + +Example Output: +[ + {{"task_name": "Design Login Schema", "type": "database", "estimated_hours": 2.0}}, + {{"task_name": "Implement Auth API", "type": "backend", "estimated_hours": 4.0}} +] + +Ensure the breakdown is comprehensive (Frontend, Backend, Database) and strictly follows the JSON format. +Do not include any markdown formatting or explanation outside the JSON. +""" + +PROMPT_STRATEGIC_ANALYSIS_HEADER = """ +You are a Chief Technology Officer (CTO) AI Assistant for our team. + +=== Our Product/Team === +{business_context} +{rag_context} +=== External Technology Signal === +Signal: "{signal_content}" + +=== Your Task === +1. Analyze: How does this signal impact OUR product's competitiveness? +2. Decide: Is it necessary to follow up immediately? (is_strategic) +3. Proposal: If YES, generate a "Strategic Decision Proposal". +4. Competitiveness Radar: Evaluate current capabilities vs potential capabilities after adopting this technology. + - Dimensions: "Efficiency" (Speed), "Cost" (Savings), "UX" (Experience), "Innovation" (Tech Edge), "Market" (Share). + - Score 1-10. +5. Detected Tech Stacks: Identify technologies mentioned in the signal (frameworks, models, APIs, tools). + - Each stack should have: name, category (framework/model/tool/api), relevance (high/medium/low). +6. Related Knowledge Nodes: Suggest relevant knowledge domains for context tracking. + - Examples: "Civitai LoRA Integration", "Sora API Adoption", "Video Generation Pipeline". +7. Plan: Break down the strategy into specific technical tasks. + +Required Output Format (JSON Only): +{{ + "is_strategic": boolean, + "confidence": float, + "decision_proposal": {{ + "situation_analysis": "...", + "recommendation": "...", + "expected_benefits": "..." + }}, + "competitiveness_radar": {{ + "current": [5, 4, 6, 5, 5], + "projected": [8, 7, 9, 9, 7] + }}, + "detected_tech_stacks": [ + {{"name": "Technology Name", "category": "framework|model|tool|api", "relevance": "high|medium|low"}} + ], + "related_knowledge_nodes": [ + {{"title": "Knowledge Node Title", "category": "tech_stack|competitor|market", "summary": "Brief summary"}} + ], + "tasks": [ + {{ + "task_name": "Task description", + "type": "backend/frontend/database", + "estimated_hours": float + }} + ] +}} + +If is_strategic is false, tasks should be empty, but decision_proposal is still required to explain why. +Do not output any markdown formatting or text outside the JSON. +""" + +PROMPT_RAG_CONTEXT_HEADER = """ +=== Related Knowledge from Our Database === +The following relevant information was found in our knowledge base: + +""" + +PROMPT_RAG_CONTEXT_ITEM = "[Relevance: {similarity:.1%}]\n{content}\n\n" + +PROMPT_CODE_AUDIT = """ +You are a Senior Tech Lead performing an automated Code Audit. + +Task: "{task_name}" +Commit Message: "{commit_message}" + +Code Diff: +{code_diff} + +Evaluate the code change against the task requirements. Check for: +1. Security vulnerabilities +2. Performance issues +3. Code style and best practices +4. Functional correctness + +Output JSON with: score (0-100), passed (boolean), issues (list of {{severity, message, suggestion}}), summary. +""" + +PROMPT_CONTEXT_INSIGHTS = """ +Analyze the following technical documentation/context and extract key insights. + +Context: "{content}" + +Output JSON: +{{ + "moat": "Core competitive advantage described", + "tech_specs": "Key technical specifications" +}} +""" + +PROMPT_FEISHU_REQUIREMENT_ANALYSIS = """ +你是 PMFlow 的需求信号分析器。请结合团队现有项目,判断一条飞书群消息是否是“可执行需求”,并判定该需求更可能属于“新项目”还是“已有项目”。 + +团队活跃项目列表(每行一个项目): +{project_summaries} + +群消息: +{message} + +输出 JSON,字段如下: +{{ + "is_valuable": true/false, + "reason": "简短原因", + "impact_score": 0.0-1.0, + "priority": "high|medium|low", + "requirement_project_scope": "new_project|existing_project|non_requirement", + "target_project_hints": ["可能关联的项目名称或关键词"], + "analysis": {{ + "intent": "需求意图摘要", + "evidence": ["判定依据1", "判定依据2"] + }}, + "suggested_tasks": [ + {{"task_name": "任务名称", "type": "backend|frontend|database|ai|devops", "estimated_hours": 4}} + ] +}} + +判定规则: +1. 纯表情、寒暄、确认类回复(如“收到”“好的”“1”)一律判定为 non_requirement。 +2. 若明确是全新方向、全新业务目标、全新系统建设,判定为 new_project。 +3. 若明确是现有功能优化、Bug 修复、已有模块迭代,判定为 existing_project。 +4. 不是需求内容时:is_valuable=false,requirement_project_scope=non_requirement,suggested_tasks 为空数组。 + +只输出 JSON,不要输出任何解释文本或 Markdown。 +""" + +PROMPT_FEISHU_HUMANIZED_REPLY = """ +直接回答用户问题,不要做自我介绍。 + +你将基于以下上下文生成一条 35-140 字中文回复: +- 当前系统时间(北京时间):{now_cn_text} +- 提及用户:{sender_name} +- 用户消息:{user_message} +- 系统处理状态:{status} +- 分类结果:{classification} +- 处理原因:{reason} +- 处理追踪ID:{ai_trace_id} +- 关联项目ID:{linked_projects_text} + +硬性规则: +1. 首句必须直接回答“用户消息”的核心问题,禁止先讲系统身份或接入方式。 +2. 禁止默认输出“我是...”“我可以...”“我现在通过...接入...”这类自我介绍句式。 +3. 仅当用户明确询问“你是谁/你能做什么/系统能力”时,才允许用一句话说明能力边界。 +4. 若信息不足,请明确缺失信息并给出 1-3 条可执行下一步。 +5. 如果用户询问日期/时间/星期,必须基于“当前系统时间(北京时间)”直接回答,不得编造。 +6. 不要输出 Markdown,不要输出 JSON。 +7. 禁止宣称“已执行/将执行”任何未在上下文明确完成的动作。 +8. 禁止输出数据库内部实施细节(如数据库迁移、DDL、SQL、ALTER TABLE、DROP TABLE、Alembic)。 +9. 当 status/classification 无法证明写入动作已成功时,禁止编造处理步骤。 + +只输出最终回复正文。 +""".strip() + +PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION = """ +你是 PMFlow 的项目需求信号分流器。 +请基于“项目列表上下文”和“飞书消息”,判断该消息是否为有效需求信号,并识别它属于哪个已有项目。 + +项目列表(JSON 数组): +{projects_json} + +飞书消息: +{message} + +输出 JSON: +{{ + "is_requirement": true/false, + "reason": "判定原因", + "confidence": 0.0-1.0, + "classification": "existing_project|new_project|pending_classification|non_requirement", + "matched_project_ids": [1,2], + "matched_project_titles": ["项目A","项目B"], + "impact_score": 0.0-1.0, + "priority": "high|medium|low", + "summary": "需求摘要(简短)" +}} + +规则: +1. 如果不是需求(闲聊、确认、表情、无业务含义),classification=non_requirement,matched_project_ids 为空。 +2. 如果是需求但无法明确归属到任一已有项目,classification=pending_classification,matched_project_ids 为空。 +3. 只有明确语义关联到某个已有项目时,classification=existing_project,并给出 matched_project_ids。 +4. 如果消息明确在描述“全新立项/新项目”,classification=new_project,matched_project_ids 为空。 +5. 不允许猜测项目归属;不确定时必须给 pending_classification。 + +只输出 JSON,不要输出任何其他文字。 +""" + +PROMPT_FEISHU_LEVEL_CLASSIFICATION = """ +你是 PMFlow 的飞书消息分流器。请按四维度对消息做 0-1 评分,并判断是 project-level 还是 signal-level。 + +输入: +- 飞书消息:{message} +- 活跃项目列表(JSON):{projects_json} + +输出 JSON: +{{ + "build_intent_score": 0.0-1.0, + "scope_score": 0.0-1.0, + "timeline_score": 0.0-1.0, + "owner_score": 0.0-1.0, + "project_score": 0.0-1.0, + "level": "project|signal", + "level_confidence": 0.0-1.0, + "level_reasoning": "1-2句解释", + "should_split": true/false, + "split_items": [ + {{"level":"project","content":"拆分后的项目部分"}}, + {{"level":"signal","content":"拆分后的信号部分"}} + ] +}} + +约束: +1. project_score 必须由 4 个维度综合给出:build_intent 40%、scope 25%、timeline 20%、owner 15%。 +2. 若消息同时包含明确立项意图与明确信号事实(竞品发布/用户反馈/风险事件),should_split=true。 +3. 不确定是否拆分时 should_split=false,且默认 level=signal。 +4. 只输出 JSON。 +""" + +__all__ = [ + "PROMPT_IMPACT_ANALYSIS", + "PROMPT_TASK_BREAKDOWN", + "PROMPT_STRATEGIC_ANALYSIS_HEADER", + "PROMPT_RAG_CONTEXT_HEADER", + "PROMPT_RAG_CONTEXT_ITEM", + "PROMPT_CODE_AUDIT", + "PROMPT_CONTEXT_INSIGHTS", + "PROMPT_FEISHU_REQUIREMENT_ANALYSIS", + "PROMPT_FEISHU_HUMANIZED_REPLY", + "PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION", + "PROMPT_FEISHU_LEVEL_CLASSIFICATION", +] diff --git a/backend/app/config/ai_prompts_catalog/part_02.py b/backend/app/config/ai_prompts_catalog/part_02.py new file mode 100644 index 00000000..c5e8c3ec --- /dev/null +++ b/backend/app/config/ai_prompts_catalog/part_02.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +PROMPT_FEISHU_SIGNAL_EXTRACTION = """ +你是 PMFlow 的信号结构化助手。请从飞书消息中提取信号字段。 + +输入: +- 飞书消息:{message} +- 活跃项目列表(JSON):{projects_json} + +输出 JSON: +{{ + "title": "一句话标题", + "type": "竞品动态|用户反馈|项目风险|新需求", + "priority": "P0|P1|P2|P3", + "matched_project_id": 1, + "summary": "2-3句摘要", + "reasoning": ["依据1","依据2","依据3"], + "confidence": 0.0-1.0, + "recommended_action": "enter_only|create_charter|trigger_alert" +}} + +约束: +1. 无法确定 matched_project_id 时返回 null。 +2. reasoning 必须是 3 条简短 bullet。 +3. 只输出 JSON。 +""" + +PROMPT_FEISHU_PROJECT_PREFILL = """ +你是 PMFlow 的项目预填助手。请从飞书消息中抽取新项目建议信息。 + +输入: +- 飞书消息:{message} + +输出 JSON: +{{ + "suggested_name": "项目名", + "suggested_description": "1-2句描述", + "suggested_owner": "负责人名或角色", + "suggested_timeline": "截止日期或周期", + "confidence": 0.0-1.0, + "reasoning": ["依据1","依据2","依据3"] +}} + +约束: +1. reasoning 必须是 3 条简短 bullet。 +2. 没有明确字段时可合理推断并在 reasoning 说明。 +3. 只输出 JSON。 +""" + +PROMPT_FEISHU_TASK_ORCHESTRATION = """ +你是 PMFlow 的任务编排与人员分配 AI。 +请基于飞书需求信号,为指定项目产出“可执行任务 + 负责人建议”。 + +输入: +- project_json: {project_json} +- signal_json: {signal_json} +- members_json: {members_json} + +输出 JSON: +{{ + "overall_summary": "一句话总结本次编排策略", + "tasks": [ + {{ + "title": "任务标题(8-32字)", + "description": "任务描述(40-200字)", + "priority": "high|medium|low", + "estimated_hours": 1-80, + "required_skill_names": ["FastAPI", "React"], + "recommended_assignee_id": 123, + "assignee_reason": "为什么推荐该成员", + "confidence": 0.0-1.0 + }} + ] +}} + +约束: +1. 任务数量 2-8 条,不要空数组。 +2. 优先覆盖“立即可执行”的任务,避免泛化管理话术。 +3. `recommended_assignee_id` 必须来自 members_json.user_id;无法确认时填 null。 +4. 推荐负责人时同时考虑技能匹配与 workload_ratio,避免把任务继续压给高负载成员。 +5. 只输出 JSON,不要 Markdown,不要解释。 +""" + +PROMPT_SIGNAL_SOURCE_ONBOARDING = """ +首字符必须是 {{,末字符必须是 }}。 +你是 PMFlow 的信号源接入规划器。判断当前请求是不是“新增/接入一个信号源”,并返回结构化计划。 + +输入: +- request_json: {request_json} +- project_json: {project_json} +- signal_json: {signal_json} + +只输出 JSON: +{{ + "should_onboard": true, + "summary": "一句话说明为什么要接入该信号源", + "source": {{ + "source_kind": "feishu|pmflow|github|webhook|url|browser|custom", + "source_name": "信号源名称", + "source_identifier": "稳定标识;无明确标识时可基于名称做简短稳定键", + "description": "这个信号源要监控什么", + "config": {{}} + }}, + "worker_roles": ["pm_dispatcher", "tech_architect", "delivery_coach"], + "execution_instruction": "交给显式服务执行层的自然语言指令" +}} + +若不是信号源接入请求,输出: +{{ + "should_onboard": false, + "summary": "不是信号源接入请求的原因", + "source": null, + "worker_roles": [], + "execution_instruction": "" +}} + +规则: +1. 只有明确新增/接入/配置信号源时才返回 should_onboard=true。 +2. 不编造 url / chat_id / repo / webhook。 +3. worker_roles 只允许 pm_dispatcher / tech_architect / delivery_coach。 +4. execution_instruction 保持简短,只说接入准备、任务拆解、负责人通知。 +5. 无法确定 source_identifier 时,返回短且稳定的键,不要输出解释。 +6. 禁止输出 thinking、analysis、解释句、Markdown、代码块。 +7. 字段未知时返回空字符串、空数组或 null。 +8. 不要 Markdown,不要解释,只返回 JSON。 +""" + +PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION = """ +首字符必须是 {{,末字符必须是 }}。 +你是 PMFlow 的下级 AI worker,正在执行一个“signal source onboarding”工作项。 + +输入: +- role_json: {role_json} +- project_json: {project_json} +- source_json: {source_json} +- signal_json: {signal_json} +- commander_json: {commander_json} + +输出 JSON: +{{ + "summary": "本角色已完成的核心产出", + "deliverables": ["产出1", "产出2"], + "risks": ["风险1", "风险2"], + "next_actions": ["下一步1", "下一步2"] +}} + +约束: +1. 输出必须体现当前 role_json 对应角色的职责边界。 +2. `deliverables` / `risks` / `next_actions` 各自最多 4 条。 +3. 不要编造数据库 ID,不要输出代码块。 +4. 顶层必须始终包含 summary / deliverables / risks / next_actions 四个键,即使数组为空。 +5. 字段未知时返回空字符串、空数组或 null。 +6. 禁止输出 thinking、analysis、解释句、Markdown、代码块。 +7. 只输出 JSON,不要 Markdown,不要解释文字。 +""" + +PROMPT_AI_PMO_INTENT_CLASSIFICATION = """ +首字符必须是 {{,末字符必须是 }}。 +你是 PMFlow 的 AI PMO 意图识别模块。你只做分类,不做执行。 + +输入: +- message_text: {message_text} +- session_last_intent: {session_last_intent} +- session_project_id: {session_project_id} +- world_model_project_id: {world_model_project_id} +- candidate_intents_json: {candidate_intents_json} +- candidate_routes_json: {candidate_routes_json} + +输出 JSON: +{{ + "family": "query|project_create|project_signal|signal_source", + "action": "answer|create|discover|onboard", + "intent": "query.read_only|project.create|signal.create|signal_source.onboard|followup.continue", + "confidence": 0.0, + "project_id": 0, + "requested_count": 1, + "requires_confirmation": false, + "reasoning": "一句话简述判定原因" +}} + +规则: +1. family/action 必须和 candidate_routes_json 可映射,不能自造。 +2. 兼容字段 intent 必须从 candidate_intents_json 内选择,不能自造。 +3. 若 family=project_signal 且 action=discover,requires_confirmation 必须为 true。 +4. requested_count 仅在 project_signal 路由有效,范围 1-5;无法判断时返回 1。 +5. 若可直接回答,优先 family=query action=answer。 +6. “直接新增信号”返回 family=project_signal action=create;“先分析候选让我确认”返回 family=project_signal action=discover。 +7. “新增/接入信号源”返回 family=signal_source action=onboard。 +8. “新增项目”返回 family=project_create action=create。 +2. 若消息是短确认语气,优先结合 session_last_intent 判定。 +9. project_id 无法确定时返回 null,不要猜测无依据项目。 +10. 噪音/寒暄/无关消息返回 family=query action=answer,intent=query.read_only,reasoning 包含 smalltalk_ignore。 +5. 禁止输出 thinking、analysis、解释句、Markdown、代码块。 +11. 若消息表达“新增/增加/添加/新建/创建/建立 + 项目”(如“给我新增个项目”),优先判定 project_create.create;仅当明确是“信号源/来源/webhook/rss/github 接入或创建信号”时才判 signal_source.onboard 或 project_signal.create。 +12. 只输出 JSON,不要额外文本。 +13. 若消息要求直接执行数据库 DDL(建表/改表/删表等),禁止判定为可自动写入意图,必须返回 family=query action=answer,intent=query.read_only,并在 reasoning 包含 ddl_api_required。 +""" + +PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """ +首字符必须是 {{,末字符必须是 }}。 +你是 PMFlow 的高级信号分析器。请把用户指令拆成可入库的信号列表。 + +输入: +- message_text: {message_text} +- project_id: {project_id} +- requested_count: {requested_count} + +输出 JSON(只输出 JSON): +{{ + "signals": [ + {{ + "title": "信号标题(8-30字)", + "summary": "信号摘要(20-120字)", + "content": "信号详情(40-300字)", + "signal_type": "新需求|项目风险|竞品动态|用户反馈", + "priority": "high|medium|low", + "reasoning": ["依据1", "依据2"] + }} + ] +}} + +规则: +1. signals 数量必须等于 requested_count。 +2. 标题和摘要禁止原样照抄整句用户指令。 +3. 当信息不足时,允许基于指令语义做合理拆分,但必须给出可执行、可入库的信号文本。 +4. 不要输出 Markdown,不要解释,不要代码块。 +""".strip() + +PROMPT_RAG_INTENT_CLASSIFICATION = """ +你是 PMFlow 的意图分类器。请判断输入文本属于:question / statement / mixed。 + +输入: +{message} + +输出 JSON: +{{ + "intent": "question|statement|mixed", + "confidence": 0.0, + "question_part": "若无则空字符串", + "statement_part": "若无则空字符串" +}} + +要求:只输出 JSON。 +""" + +__all__ = [ + "PROMPT_FEISHU_SIGNAL_EXTRACTION", + "PROMPT_FEISHU_PROJECT_PREFILL", + "PROMPT_FEISHU_TASK_ORCHESTRATION", + "PROMPT_SIGNAL_SOURCE_ONBOARDING", + "PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION", + "PROMPT_AI_PMO_INTENT_CLASSIFICATION", + "PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS", + "PROMPT_RAG_INTENT_CLASSIFICATION", +] diff --git a/backend/app/config/ai_prompts_catalog/part_03.py b/backend/app/config/ai_prompts_catalog/part_03.py new file mode 100644 index 00000000..75ddb687 --- /dev/null +++ b/backend/app/config/ai_prompts_catalog/part_03.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +PROMPT_RAG_ANSWER = """ +你是 PMFlow 的 RAG 问答助手,负责项目与人员协同管理。 +要求: +1. 只基于给定上下文回答,不要编造。 +2. 若证据不足,明确说“不确定/信息不足”。 +3. 回答后用“来源:”列出引用的来源编号。 +4. 优先从 System Snapshot 与 User Snapshot 作答,不要忽略结构化业务数据。 +5. 当问题涉及“我/当前用户/我的任务”时,只使用 User Snapshot 对应数据回答。 +6. 不允许出现“无法访问项目数据”这类泛化托辞,若缺失请明确指出缺失字段。 +7. 当问题是“你是谁/你能做什么”时,必须优先使用 System Snapshot.assistant_profile 回答。 +8. 当问题涉及“系统架构/运行机制/技术栈”时,必须优先使用 System Snapshot.platform_profile 回答。 +9. 即使检索上下文为空,也要基于 Snapshot 给出当前系统可验证信息,禁止输出默认兜底句。 +10. 若字段缺失,显式列出缺失字段名,不要假设。 +11. 当问题涉及“成员/人员/负责人/谁在做”时,优先使用 System Snapshot.team_members、User Snapshot 与结构化数据库结果,禁止默认返回项目列表。 + +当前项目ID:{project_id} +问题:{question} +检索最大相关度:{relevance_max} + +System Snapshot(全局): +{system_snapshot} + +User Snapshot(当前用户): +{user_snapshot} + +最近会话(最多10轮): +{conversation} + +检索上下文: +{context_blocks} +""" + +PROMPT_RAG_DB_PROJECT_QA = """ +你是 PMFlow 的项目数据语义检索助手。 +你会收到“用户问题”和“项目索引(JSON)”,请基于索引做语义匹配后回答。 + +规则: +1. 优先语义理解,不要机械关键词匹配;要容错同义词、口语、轻微错别字(如“键身”≈“健身”)。 +2. 若用户问“有没有/是否存在某类项目”,必须给出明确结论(有/没有)并列出匹配项目。 +3. 若用户问“进度/阶段/完成率”,请综合 task_total/done_count/doing_count/pending_count 输出量化结论。 +4. 若证据不足,请明确说明不足点,不要编造。 +5. 若问题焦点是成员/人员/负责人/谁在做(人员管理),该问题不应由项目索引直接回答,必须返回 handled=false。 + +输出 JSON(只输出 JSON,不要 Markdown): +{{ + "handled": true, + "answer": "给用户展示的最终中文回答", + "matched_project_ids": [1, 2], + "confidence": 0.0 +}} + +用户问题: +{question} + +项目索引(JSON): +{project_index} +""" + +PROMPT_GENERATE_CHARTER = """ +你是一位资深产品经理。根据以下市场/技术信号,生成一份结构化的需求宪章草案。 + +信号内容: +{signal_content} + +请输出 JSON 格式,包含以下字段: +{{ + "title": "需求宪章标题", + "summary": "一句话摘要", + "background": "背景与动机", + "objectives": ["目标1", "目标2"], + "scope": "范围说明", + "success_metrics": ["指标1", "指标2"], + "risks": ["风险1", "风险2"] +}} + +只输出 JSON,不要其他文字。 +""" + +PROMPT_DECOMPOSE_TASKS = """ +你是一位 AI 技术项目经理。根据以下需求宪章内容,拆解为可执行的任务列表。 +项目 ID:{project_id}。 + +宪章内容: +{charter_content} + +请识别每个任务所需的 AI 开发技能标签(从系统技能中匹配:Python, TypeScript, FastAPI, React, SQLAlchemy, LangChain, LLM调优, RAG构建, 向量数据库, Ollama, Docker, Git 等)。 + +输出 JSON 格式: +{{ + "tasks": [ + {{ + "task_name": "任务名称", + "description": "任务描述", + "type": "frontend|backend|database|ai|devops", + "estimated_hours": 0.0, + "required_skill_names": ["技能1", "技能2"], + "priority": "high|medium|low" + }} + ], + "detected_skills": ["检测到的所有技能"] +}} + +只输出 JSON,不要其他文字。 +""" + +PROMPT_RECOMMEND_ASSIGNEES = """ +你是一位项目协调员。根据以下任务内容、所需技能、以及候选人信息,推荐 Top3 最合适的负责人。 + +任务内容:{task_content} + +所需技能标签 IDs:{required_skill_tag_ids} + +候选人信息(JSON 数组,每项包含 user_id, user_name, workload, proficiency_avg, skill_match_count): +{candidates_json} + +请考虑:当前负载、技能匹配度、历史表现。输出 JSON: +{{ + "recommendations": [ + {{ + "user_id": "用户ID", + "user_name": "姓名", + "match_score": 0-100, + "reason": "推荐理由" + }} + ] +}} + +只输出 JSON。 +""" + +PROMPT_ALIGNMENT_SCORE = """ +计算用户对真理节点的认知对齐度(0-100 分)。 + +用户 ID:{user_id} +真理节点 ID:{truth_node_id} + +真理节点内容: +{truth_content} + +用户相关行为/产出(如有): +{user_context} + +请评估该用户对该真理节点的理解与践行程度。输出 JSON: +{{ + "alignment_score": 0-100, + "explanation": "简要说明" +}} + +只输出 JSON。 +""" + +PROMPT_COGNITIVE_CONFLICTS = """ +识别真理节点下的隐性认知冲突。 + +真理节点 ID:{truth_node_id} +真理节点内容: +{truth_content} + +相关用户的观点/产出: +{user_viewpoints} + +请识别冲突类型(如 definition/定义分歧、priority/优先级冲突、scope/范围争议),输出 JSON: +{{ + "conflicts": [ + {{ + "conflict_type": "类型", + "description": "冲突描述", + "involved_user_ids": ["用户ID"], + "involved_user_names": ["姓名"], + "severity": "low|medium|high" + }} + ] +}} + +只输出 JSON。 +""" + +PROMPT_EVALUATE_MODEL_REQUIREMENT = """ +评估需求与 AI 模型的匹配度。 + +宪章内容:{charter_content} +模型版本 ID:{model_version_id} +模型信息(如有):{model_info} + +请评估兼容性、成本、风险。输出 JSON: +{{ + "compatibility_score": 0-100, + "cost_estimate_usd": 预估日成本美元, + "cost_estimate_tokens_per_day": 预估日 token 量, + "risk_hints": ["风险1", "风险2"], + "recommendations": ["建议1", "建议2"] +}} + +只输出 JSON。 +""" + +PROMPT_ANALYZE_CAPABILITY_GAP = """ +你是一位 AI 模型能力专家。分析以下需求与模型之间的能力差距。 + +需求宪章: +{charter_content} + +模型信息: +{model_info} + +请识别并分类能力差距,输出 JSON: +{{ + "gaps": [ + {{ + "gap_type": "capability|cost|performance|risk", + "severity": "critical|high|medium|low", + "description": "差距描述", + "impact": "对项目的影响", + "suggestion": "改进建议" + }} + ], + "overall_risk_level": "critical|high|medium|low", + "confidence": 0.0-1.0 +}} + +只输出 JSON。 +""" + +PROMPT_ANALYZE_COST_ROI = """ +分析 AI 项目的成本效益。 + +总成本:{total_cost} +总 Token:{total_tokens} +任务数:{task_count} +项目内容:{truth_node_content} + +请计算: +1. ROI 评分 (0-10) +2. 成本效率评估 +3. 优化建议 + +输出 JSON: +{{ + "roi_score": 0.0-10.0, + "cost_efficiency": "excellent|good|fair|poor", + "cost_per_task": 数值, + "optimization_suggestions": ["建议1", "建议2"], + "benchmark_comparison": "与行业标准比较" +}} + +只输出 JSON。 +""" + +__all__ = [ + "PROMPT_RAG_ANSWER", + "PROMPT_RAG_DB_PROJECT_QA", + "PROMPT_GENERATE_CHARTER", + "PROMPT_DECOMPOSE_TASKS", + "PROMPT_RECOMMEND_ASSIGNEES", + "PROMPT_ALIGNMENT_SCORE", + "PROMPT_COGNITIVE_CONFLICTS", + "PROMPT_EVALUATE_MODEL_REQUIREMENT", + "PROMPT_ANALYZE_CAPABILITY_GAP", + "PROMPT_ANALYZE_COST_ROI", +] diff --git a/backend/app/config/ai_prompts_catalog/part_04.py b/backend/app/config/ai_prompts_catalog/part_04.py new file mode 100644 index 00000000..e951e42e --- /dev/null +++ b/backend/app/config/ai_prompts_catalog/part_04.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +PROMPT_ANALYZE_COST_OPTIMIZATION = """ +分析成本优化机会。 + +成本历史:{cost_history} +项目内容:{truth_node_content} + +请识别优化机会,输出 JSON: +{{ + "can_downgrade_model": true|false, + "downgrade_savings": 预计节省金额, + "suggested_model": "建议的替代模型", + "high_cache_potential": true|false, + "cache_savings": 预计节省金额, + "batchable_operations": true|false, + "batch_savings": 预计节省金额, + "monthly_cost": 数值, + "total_potential_savings": 数值, + "roi_after_optimization": 数值 +}} + +只输出 JSON。 +""" + +PROMPT_ASSESS_MODEL_UPDATE_IMPACT = """ +评估模型升级影响。 + +旧模型信息:{old_model_info} +新模型信息:{new_model_info} +受影响宪章:{affected_truth_nodes} + +输出 JSON: +{{ + "cost_change_percentage": 数值, + "performance_change": {{ + "context_window_change_percent": 数值, + "quality_change_percent": 数值 + }}, + "compatibility_risks": [{{"type": "风险类型", "severity": "low|medium|high"}}], + "recommendation": "immediate_upgrade|evaluate|defer" +}} + +只输出 JSON。 +""" + +PROMPT_EXPERIMENT_SYNC_DECISION = """ +评估实验结果是否可自动同步到宪章。 + +实验信息:{experiment_info} +宪章内容:{truth_content} +阈值:{threshold} + +输出 JSON: +{{ + "confidence_score": 0.0-1.0, + "auto_apply": true|false, + "reason": "原因" +}} + +只输出 JSON。 +""" + +PROMPT_SKILL_SCORE_UPDATE = """ +你是 PMFlow 的技能评估引擎。 +请根据任务完成结果,给每个技能标签输出质量分与效率分的增量(delta)。 + +输入: +- task_id: {task_id} +- priority: {priority} +- quality_score: {quality_score} +- estimated_hours: {estimated_hours} +- actual_hours: {actual_hours} +- required_skill_tag_ids: {required_skill_tag_ids} + +规则: +1. quality_delta 与 efficiency_delta 范围均为 [-10, 10]。 +2. quality_score 越高,quality_delta 越大。 +3. estimated_hours / actual_hours > 1,表示效率高,efficiency_delta 应偏正;< 1 偏负。 +4. 结果必须覆盖所有 required_skill_tag_ids。 +5. 不输出解释文本,仅输出 JSON。 + +输出 JSON: +{{ + "deltas": [ + {{ + "skill_tag_id": 1, + "quality_delta": 1.2, + "efficiency_delta": -0.5 + }} + ] +}} +""" + +PROMPT_WORKLOAD_OPTIMIZATION = """ +你是 PMFlow 的任务分配优化器。 +请基于团队成员技能与当前负载,输出任务重分配建议。 + +输入: +- members_json: {members_json} +- tasks_json: {tasks_json} + +约束: +1. 只对未完成任务(todo/in_progress/review/blocked)给建议。 +2. 优先降低 >100% 过载成员负载。 +3. 优先选择技能匹配更高且负载更低的成员。 +4. 每条建议都要给 reason。 +5. 不输出解释文本,仅输出 JSON。 + +输出 JSON: +{{ + "proposals": [ + {{ + "task_id": 101, + "new_assignee_id": 2001, + "reason": "skill_match_high_and_lower_workload" + }} + ] +}} +""" + +PROMPT_ANALYZE_FOOD_CALORIES = """ +你是一位专业的营养分析师,擅长通过图片识别食物并估算热量和营养成分。 + +请基于提供的食物描述或特征,识别食物类型并估算营养信息。 + +输入信息: +{food_description} + +请输出 JSON 格式,包含以下字段: +{{ + "food_name": "食物名称", + "confidence": 0.0-1.0, + "estimated_portion_size": "份量大小描述(如:一份、100克、半碗等)", + "calories": 数值, + "protein": 数值, + "fat": 数值, + "carbs": 数值, + "fiber": 数值, + "category": "分类(如:主食、蔬菜、肉类、汤品、甜品、饮品等)", + "cuisine": "菜系(如:中式、西式、日式、韩式等)", + "description": "简短的食物描述", + "analysis_notes": ["分析要点1", "分析要点2"], + "uncertainty_factors": ["不确定因素1(如有)"] +}} + +估算规则: +1. 热量估算范围:考虑到份量不确定性,给出合理的中位值 +2. 营养成分:基于常见食物数据库估算 +3. confidence 根据描述清晰度和食物识别难度给出 +4. 若无法识别,confidence 应 < 0.5,并在 uncertainty_factors 中说明 + +只输出 JSON,不要其他文字或 Markdown。 +""" + +PROMPT_SIGNAL_PLANNER = """ +你是 PMFlow 的 Signal Planner。请基于信号内容、项目上下文和团队成员能力,给出可执行的规划建议。 + +输入: +- 信号ID:{signal_id} +- 信号标题:{signal_title} +- 信号摘要:{signal_summary} +- 信号正文:{signal_content} +- 项目ID:{project_id} +- 项目标题:{project_title} +- 项目归属判定:{project_scope} +- 最近相关信号(JSON):{recent_signals} +- 项目成员能力画像(JSON):{member_profiles} +- 当前时间(北京时间):{now_cn} + +输出 JSON(严格只输出 JSON): +{{ + "summary": "本次分析摘要(<=120字)", + "priority": "high|medium|low", + "assignee_user_id": 123, + "assignee_reason": "为什么推荐该成员", + "execution_plan": [ + "第一步动作", + "第二步动作" + ], + "risks": [ + "风险1", + "风险2" + ], + "confidence": 0.0 +}} + +规则: +1. 若无法确定负责人,assignee_user_id 返回 null,且在 assignee_reason 中说明原因。 +2. execution_plan 必须是 2-5 条可执行动作,不要空泛描述。 +3. confidence 范围 0-1,不能省略。 +4. 只输出 JSON,不要输出 Markdown 或解释文本。 +""" + +__all__ = [ + "PROMPT_ANALYZE_COST_OPTIMIZATION", + "PROMPT_ASSESS_MODEL_UPDATE_IMPACT", + "PROMPT_EXPERIMENT_SYNC_DECISION", + "PROMPT_SKILL_SCORE_UPDATE", + "PROMPT_WORKLOAD_OPTIMIZATION", + "PROMPT_ANALYZE_FOOD_CALORIES", + "PROMPT_SIGNAL_PLANNER", +] diff --git a/backend/app/config/operation_catalog.py b/backend/app/config/operation_catalog.py index 92b8341d..52f11403 100644 --- a/backend/app/config/operation_catalog.py +++ b/backend/app/config/operation_catalog.py @@ -1,455 +1,17 @@ -"""Operation Catalog(系统写操作目录). - -规则: -1. LLM 仅输出结构化意图,真实写库必须走 Tool/Service 层。 -2. 每个操作必须声明 operation_code/input_schema/rbac_policy/idempotency_key/audit_fields/side_effect_guard。 -3. 执行态统一遵循 commit_then_outbox,便于副作用可追溯与重放。 -""" +"""Operation Catalog(系统写操作目录)。""" from __future__ import annotations -from typing import Any, Literal - -from pydantic import BaseModel, ConfigDict, Field - - -class OperationIdempotencySpec(BaseModel): - model_config = ConfigDict(extra="forbid") - - fields: list[str] = Field(default_factory=list, min_length=1) - scope: Literal["tenant", "project", "global"] = "tenant" - ttl_seconds: int = Field(default=86400, ge=60, le=604800) - - -class OperationSideEffectGuard(BaseModel): - model_config = ConfigDict(extra="forbid") - - require_tool_executor: bool = True - require_schema_validation: bool = True - deny_free_text_write: bool = True - transaction_mode: Literal["commit_then_outbox", "transactional"] = "commit_then_outbox" - allowed_paths: list[str] = Field(default_factory=list) - - -class OperationCatalogEntry(BaseModel): - model_config = ConfigDict(extra="forbid") - - operation_code: str = Field(min_length=3, max_length=120) - domain: Literal["project", "task", "personnel", "knowledge", "notification", "file"] - description: str = Field(min_length=4, max_length=300) - input_schema: dict[str, Any] - rbac_policy: list[str] = Field(default_factory=list, min_length=1) - idempotency_key: OperationIdempotencySpec - audit_fields: list[str] = Field(default_factory=list, min_length=1) - side_effect_guard: OperationSideEffectGuard - - -def _entry( - *, - operation_code: str, - domain: Literal["project", "task", "personnel", "knowledge", "notification", "file"], - description: str, - input_schema: dict[str, Any], - rbac_policy: list[str], - idempotency_fields: list[str], - audit_fields: list[str], - allowed_paths: list[str] | None = None, -) -> OperationCatalogEntry: - return OperationCatalogEntry( - operation_code=operation_code, - domain=domain, - description=description, - input_schema=input_schema, - rbac_policy=rbac_policy, - idempotency_key=OperationIdempotencySpec(fields=idempotency_fields), - audit_fields=audit_fields, - side_effect_guard=OperationSideEffectGuard( - allowed_paths=allowed_paths or [], - ), - ) - - -_COMMON_AUDIT_FIELDS = [ - "tenant_id", - "user_id", - "trace_id", - "operation_code", - "idempotency_key", - "target_type", - "target_id", - "before_snapshot", - "after_snapshot", -] - - -OPERATION_CATALOG: tuple[OperationCatalogEntry, ...] = ( - _entry( - operation_code="project.create", - domain="project", - description="创建项目", - input_schema={ - "type": "object", - "required": ["title"], - "properties": { - "title": {"type": "string", "minLength": 1, "maxLength": 80}, - "description": {"type": "string", "maxLength": 4000}, - "owner_id": {"type": "integer"}, - }, - "additionalProperties": False, - }, - rbac_policy=["BOSS", "ADMIN"], - idempotency_fields=["tenant_id", "title"], - audit_fields=_COMMON_AUDIT_FIELDS, - ), - _entry( - operation_code="project.update", - domain="project", - description="更新项目元数据", - input_schema={ - "type": "object", - "required": ["project_id"], - "properties": { - "project_id": {"type": "integer"}, - "title": {"type": "string", "maxLength": 80}, - "description": {"type": "string", "maxLength": 4000}, - "status": {"type": "string", "enum": ["active", "archived", "done"]}, - }, - "additionalProperties": False, - }, - rbac_policy=["BOSS", "ADMIN"], - idempotency_fields=["tenant_id", "project_id", "updated_at"], - audit_fields=_COMMON_AUDIT_FIELDS, - ), - _entry( - operation_code="project.archive", - domain="project", - description="归档项目", - input_schema={ - "type": "object", - "required": ["project_id"], - "properties": {"project_id": {"type": "integer"}}, - "additionalProperties": False, - }, - rbac_policy=["BOSS", "ADMIN"], - idempotency_fields=["tenant_id", "project_id", "status"], - audit_fields=_COMMON_AUDIT_FIELDS, - ), - _entry( - operation_code="project.reopen", - domain="project", - description="重新打开项目", - input_schema={ - "type": "object", - "required": ["project_id"], - "properties": {"project_id": {"type": "integer"}}, - "additionalProperties": False, - }, - rbac_policy=["BOSS", "ADMIN"], - idempotency_fields=["tenant_id", "project_id", "status"], - audit_fields=_COMMON_AUDIT_FIELDS, - ), - _entry( - operation_code="task.create", - domain="task", - description="创建任务", - input_schema={ - "type": "object", - "required": ["project_id", "title"], - "properties": { - "project_id": {"type": "integer"}, - "title": {"type": "string", "minLength": 1, "maxLength": 120}, - "description": {"type": "string", "maxLength": 5000}, - "assignee_id": {"type": "integer"}, - "required_skill_tag_ids": { - "type": "array", - "items": {"type": "integer"}, - "maxItems": 20, - }, - "priority": {"type": "string", "enum": ["high", "medium", "low", "P0", "P1", "P2", "P3"]}, - "estimated_hours": {"type": "number", "minimum": 0, "maximum": 200}, - "due_date": {"type": "string", "maxLength": 40}, - }, - "additionalProperties": False, - }, - rbac_policy=["BOSS", "ADMIN", "PM"], - idempotency_fields=["tenant_id", "project_id", "title"], - audit_fields=_COMMON_AUDIT_FIELDS, - ), - _entry( - operation_code="task.update", - domain="task", - description="更新任务内容", - input_schema={ - "type": "object", - "required": ["task_id"], - "properties": { - "task_id": {"type": "integer"}, - "title": {"type": "string", "maxLength": 120}, - "description": {"type": "string", "maxLength": 5000}, - }, - "additionalProperties": False, - }, - rbac_policy=["BOSS", "ADMIN", "PM"], - idempotency_fields=["tenant_id", "task_id", "updated_at"], - audit_fields=_COMMON_AUDIT_FIELDS, - ), - _entry( - operation_code="task.assign", - domain="task", - description="分配任务负责人", - input_schema={ - "type": "object", - "required": ["task_id", "assignee_id"], - "properties": { - "task_id": {"type": "integer"}, - "assignee_id": {"type": "integer"}, - }, - "additionalProperties": False, - }, - rbac_policy=["BOSS", "ADMIN", "PM"], - idempotency_fields=["tenant_id", "task_id", "assignee_id"], - audit_fields=_COMMON_AUDIT_FIELDS, - ), - _entry( - operation_code="task.prioritize", - domain="task", - description="调整任务优先级", - input_schema={ - "type": "object", - "required": ["task_id", "priority"], - "properties": { - "task_id": {"type": "integer"}, - "priority": {"type": "string", "enum": ["high", "medium", "low", "P0", "P1", "P2", "P3"]}, - }, - "additionalProperties": False, - }, - rbac_policy=["BOSS", "ADMIN", "PM"], - idempotency_fields=["tenant_id", "task_id", "priority"], - audit_fields=_COMMON_AUDIT_FIELDS, - ), - _entry( - operation_code="task.status_transition", - domain="task", - description="任务状态迁移", - input_schema={ - "type": "object", - "required": ["task_id", "status"], - "properties": { - "task_id": {"type": "integer"}, - "status": {"type": "string", "enum": ["todo", "in_progress", "review", "done", "blocked", "closed"]}, - }, - "additionalProperties": False, - }, - rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"], - idempotency_fields=["tenant_id", "task_id", "status"], - audit_fields=_COMMON_AUDIT_FIELDS, - ), - _entry( - operation_code="task.delete", - domain="task", - description="删除任务", - input_schema={ - "type": "object", - "required": ["task_id"], - "properties": {"task_id": {"type": "integer"}}, - "additionalProperties": False, - }, - rbac_policy=["BOSS", "ADMIN"], - idempotency_fields=["tenant_id", "task_id"], - audit_fields=_COMMON_AUDIT_FIELDS, - ), - _entry( - operation_code="personnel.role_update", - domain="personnel", - description="更新人员角色", - input_schema={ - "type": "object", - "required": ["user_id", "role"], - "properties": { - "user_id": {"type": "integer"}, - "role": {"type": "string", "maxLength": 50}, - }, - "additionalProperties": False, - }, - rbac_policy=["BOSS", "ADMIN"], - idempotency_fields=["tenant_id", "user_id", "role"], - audit_fields=_COMMON_AUDIT_FIELDS, - ), - _entry( - operation_code="personnel.workload_update", - domain="personnel", - description="更新人员每周可用工时", - input_schema={ - "type": "object", - "required": ["user_id", "max_weekly_hours"], - "properties": { - "user_id": {"type": "integer"}, - "max_weekly_hours": {"type": "number", "minimum": 1, "maximum": 120}, - }, - "additionalProperties": False, - }, - rbac_policy=["BOSS", "ADMIN", "PM"], - idempotency_fields=["tenant_id", "user_id", "max_weekly_hours", "date"], - audit_fields=_COMMON_AUDIT_FIELDS, - ), - _entry( - operation_code="personnel.availability_update", - domain="personnel", - description="更新人员可用性", - input_schema={ - "type": "object", - "required": ["user_id", "status"], - "properties": { - "user_id": {"type": "integer"}, - "status": {"type": "string", "enum": ["active", "away", "offline"]}, - }, - "additionalProperties": False, - }, - rbac_policy=["BOSS", "ADMIN", "PM"], - idempotency_fields=["tenant_id", "user_id", "status", "date"], - audit_fields=_COMMON_AUDIT_FIELDS, - ), - _entry( - operation_code="knowledge.doc_upsert", - domain="knowledge", - description="新增或更新知识文档", - input_schema={ - "type": "object", - "required": ["title", "content"], - "properties": { - "doc_id": {"type": "integer"}, - "title": {"type": "string", "minLength": 1, "maxLength": 255}, - "content": {"type": "string", "minLength": 1}, - }, - "additionalProperties": False, - }, - rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"], - idempotency_fields=["tenant_id", "doc_id", "content_hash"], - audit_fields=_COMMON_AUDIT_FIELDS, - ), - _entry( - operation_code="knowledge.chunk_upsert", - domain="knowledge", - description="新增或更新知识分片", - input_schema={ - "type": "object", - "required": ["doc_id", "chunk_text"], - "properties": { - "doc_id": {"type": "integer"}, - "chunk_id": {"type": "integer"}, - "chunk_text": {"type": "string", "minLength": 1}, - }, - "additionalProperties": False, - }, - rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"], - idempotency_fields=["tenant_id", "doc_id", "chunk_hash"], - audit_fields=_COMMON_AUDIT_FIELDS, - ), - _entry( - operation_code="knowledge.link_upsert", - domain="knowledge", - description="维护知识链接(文档/实体/项目关系)", - input_schema={ - "type": "object", - "required": ["source_type", "source_id", "target_type", "target_id"], - "properties": { - "source_type": {"type": "string", "maxLength": 40}, - "source_id": {"type": "integer"}, - "target_type": {"type": "string", "maxLength": 40}, - "target_id": {"type": "integer"}, - }, - "additionalProperties": False, - }, - rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"], - idempotency_fields=["tenant_id", "source_type", "source_id", "target_type", "target_id"], - audit_fields=_COMMON_AUDIT_FIELDS, - ), - _entry( - operation_code="knowledge.reindex", - domain="knowledge", - description="触发知识库重建索引", - input_schema={ - "type": "object", - "required": ["scope"], - "properties": { - "scope": {"type": "string", "enum": ["tenant", "project", "document"]}, - "project_id": {"type": "integer"}, - "doc_id": {"type": "integer"}, - }, - "additionalProperties": False, - }, - rbac_policy=["BOSS", "ADMIN", "AI_ENGINEER"], - idempotency_fields=["tenant_id", "scope", "project_id", "doc_id", "date_hour"], - audit_fields=_COMMON_AUDIT_FIELDS, - ), - _entry( - operation_code="notification.create", - domain="notification", - description="创建通知", - input_schema={ - "type": "object", - "required": ["user_id", "title", "content"], - "properties": { - "user_id": {"type": "integer"}, - "title": {"type": "string", "minLength": 1, "maxLength": 150}, - "content": {"type": "string", "minLength": 1, "maxLength": 2000}, - }, - "additionalProperties": False, - }, - rbac_policy=["BOSS", "ADMIN", "PM"], - idempotency_fields=["tenant_id", "project_id", "user_id", "title", "content_hash"], - audit_fields=_COMMON_AUDIT_FIELDS, - ), - _entry( - operation_code="notification.ack", - domain="notification", - description="确认通知", - input_schema={ - "type": "object", - "required": ["notification_id"], - "properties": {"notification_id": {"type": "integer"}}, - "additionalProperties": False, - }, - rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"], - idempotency_fields=["tenant_id", "notification_id", "is_read"], - audit_fields=_COMMON_AUDIT_FIELDS, - ), - _entry( - operation_code="notification.close", - domain="notification", - description="关闭通知", - input_schema={ - "type": "object", - "required": ["notification_id"], - "properties": {"notification_id": {"type": "integer"}}, - "additionalProperties": False, - }, - rbac_policy=["BOSS", "ADMIN", "PM"], - idempotency_fields=["tenant_id", "notification_id", "status"], - audit_fields=_COMMON_AUDIT_FIELDS, - ), - _entry( - operation_code="file.write_whitelist", - domain="file", - description="写入白名单文件路径", - input_schema={ - "type": "object", - "required": ["path", "content"], - "properties": { - "path": {"type": "string", "minLength": 1, "maxLength": 400}, - "content": {"type": "string"}, - "encoding": {"type": "string", "enum": ["utf-8", "ascii"]}, - }, - "additionalProperties": False, - }, - rbac_policy=["BOSS", "ADMIN", "PM"], - idempotency_fields=["tenant_id", "path", "content_hash"], - audit_fields=_COMMON_AUDIT_FIELDS, - allowed_paths=[ - "/app/backend/reports", - "/app/backend/exports", - "/app/backend/tmp", - "/tmp/pmflow", - ], - ), +from .operation_catalog_core import ( + OPERATION_CATALOG, + OperationCatalogEntry, + OperationIdempotencySpec, + OperationSideEffectGuard, ) + +__all__ = [ + "OPERATION_CATALOG", + "OperationCatalogEntry", + "OperationIdempotencySpec", + "OperationSideEffectGuard", +] diff --git a/backend/app/config/operation_catalog_core/__init__.py b/backend/app/config/operation_catalog_core/__init__.py new file mode 100644 index 00000000..9bb41dfe --- /dev/null +++ b/backend/app/config/operation_catalog_core/__init__.py @@ -0,0 +1,19 @@ +"""Public exports for operation catalog core.""" + +from .models import OperationCatalogEntry, OperationIdempotencySpec, OperationSideEffectGuard +from .notification_file import NOTIFICATION_FILE_ENTRIES +from .personnel_knowledge import PERSONNEL_KNOWLEDGE_ENTRIES +from .project_task import PROJECT_TASK_ENTRIES + +OPERATION_CATALOG: tuple[OperationCatalogEntry, ...] = ( + *PROJECT_TASK_ENTRIES, + *PERSONNEL_KNOWLEDGE_ENTRIES, + *NOTIFICATION_FILE_ENTRIES, +) + +__all__ = [ + "OPERATION_CATALOG", + "OperationCatalogEntry", + "OperationIdempotencySpec", + "OperationSideEffectGuard", +] diff --git a/backend/app/config/operation_catalog_core/helpers.py b/backend/app/config/operation_catalog_core/helpers.py new file mode 100644 index 00000000..e6dab5cb --- /dev/null +++ b/backend/app/config/operation_catalog_core/helpers.py @@ -0,0 +1,48 @@ +"""Helpers and constants for operation catalog construction.""" + +from __future__ import annotations + +from .models import ( + DomainLiteral, + OperationCatalogEntry, + OperationIdempotencySpec, + OperationSideEffectGuard, +) + + +def entry( + *, + operation_code: str, + domain: DomainLiteral, + description: str, + input_schema: dict[str, object], + rbac_policy: list[str], + idempotency_fields: list[str], + audit_fields: list[str], + allowed_paths: list[str] | None = None, +) -> OperationCatalogEntry: + return OperationCatalogEntry( + operation_code=operation_code, + domain=domain, + description=description, + input_schema=input_schema, + rbac_policy=rbac_policy, + idempotency_key=OperationIdempotencySpec(fields=idempotency_fields), + audit_fields=audit_fields, + side_effect_guard=OperationSideEffectGuard( + allowed_paths=allowed_paths or [], + ), + ) + + +COMMON_AUDIT_FIELDS = [ + "tenant_id", + "user_id", + "trace_id", + "operation_code", + "idempotency_key", + "target_type", + "target_id", + "before_snapshot", + "after_snapshot", +] diff --git a/backend/app/config/operation_catalog_core/models.py b/backend/app/config/operation_catalog_core/models.py new file mode 100644 index 00000000..6b5015c0 --- /dev/null +++ b/backend/app/config/operation_catalog_core/models.py @@ -0,0 +1,40 @@ +"""Pydantic models for operation catalog entries.""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +DomainLiteral = Literal["project", "task", "personnel", "knowledge", "notification", "file"] + + +class OperationIdempotencySpec(BaseModel): + model_config = ConfigDict(extra="forbid") + + fields: list[str] = Field(default_factory=list, min_length=1) + scope: Literal["tenant", "project", "global"] = "tenant" + ttl_seconds: int = Field(default=86400, ge=60, le=604800) + + +class OperationSideEffectGuard(BaseModel): + model_config = ConfigDict(extra="forbid") + + require_tool_executor: bool = True + require_schema_validation: bool = True + deny_free_text_write: bool = True + transaction_mode: Literal["commit_then_outbox", "transactional"] = "commit_then_outbox" + allowed_paths: list[str] = Field(default_factory=list) + + +class OperationCatalogEntry(BaseModel): + model_config = ConfigDict(extra="forbid") + + operation_code: str = Field(min_length=3, max_length=120) + domain: DomainLiteral + description: str = Field(min_length=4, max_length=300) + input_schema: dict[str, Any] + rbac_policy: list[str] = Field(default_factory=list, min_length=1) + idempotency_key: OperationIdempotencySpec + audit_fields: list[str] = Field(default_factory=list, min_length=1) + side_effect_guard: OperationSideEffectGuard diff --git a/backend/app/config/operation_catalog_core/notification_file.py b/backend/app/config/operation_catalog_core/notification_file.py new file mode 100644 index 00000000..bc2d0304 --- /dev/null +++ b/backend/app/config/operation_catalog_core/notification_file.py @@ -0,0 +1,79 @@ +"""Notification/file operation catalog entries.""" + +from __future__ import annotations + +from .helpers import COMMON_AUDIT_FIELDS, entry +from .models import OperationCatalogEntry + +NOTIFICATION_FILE_ENTRIES: tuple[OperationCatalogEntry, ...] = ( + entry( + operation_code="notification.create", + domain="notification", + description="创建通知", + input_schema={ + "type": "object", + "required": ["user_id", "title", "content"], + "properties": { + "user_id": {"type": "integer"}, + "title": {"type": "string", "minLength": 1, "maxLength": 150}, + "content": {"type": "string", "minLength": 1, "maxLength": 2000}, + }, + "additionalProperties": False, + }, + rbac_policy=["BOSS", "ADMIN", "PM"], + idempotency_fields=["tenant_id", "project_id", "user_id", "title", "content_hash"], + audit_fields=COMMON_AUDIT_FIELDS, + ), + entry( + operation_code="notification.ack", + domain="notification", + description="确认通知", + input_schema={ + "type": "object", + "required": ["notification_id"], + "properties": {"notification_id": {"type": "integer"}}, + "additionalProperties": False, + }, + rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"], + idempotency_fields=["tenant_id", "notification_id", "is_read"], + audit_fields=COMMON_AUDIT_FIELDS, + ), + entry( + operation_code="notification.close", + domain="notification", + description="关闭通知", + input_schema={ + "type": "object", + "required": ["notification_id"], + "properties": {"notification_id": {"type": "integer"}}, + "additionalProperties": False, + }, + rbac_policy=["BOSS", "ADMIN", "PM"], + idempotency_fields=["tenant_id", "notification_id", "status"], + audit_fields=COMMON_AUDIT_FIELDS, + ), + entry( + operation_code="file.write_whitelist", + domain="file", + description="写入白名单文件路径", + input_schema={ + "type": "object", + "required": ["path", "content"], + "properties": { + "path": {"type": "string", "minLength": 1, "maxLength": 400}, + "content": {"type": "string"}, + "encoding": {"type": "string", "enum": ["utf-8", "ascii"]}, + }, + "additionalProperties": False, + }, + rbac_policy=["BOSS", "ADMIN", "PM"], + idempotency_fields=["tenant_id", "path", "content_hash"], + audit_fields=COMMON_AUDIT_FIELDS, + allowed_paths=[ + "/app/backend/reports", + "/app/backend/exports", + "/app/backend/tmp", + "/tmp/pmflow", + ], + ), +) diff --git a/backend/app/config/operation_catalog_core/personnel_knowledge.py b/backend/app/config/operation_catalog_core/personnel_knowledge.py new file mode 100644 index 00000000..a0402e06 --- /dev/null +++ b/backend/app/config/operation_catalog_core/personnel_knowledge.py @@ -0,0 +1,133 @@ +"""Personnel/knowledge operation catalog entries.""" + +from __future__ import annotations + +from .helpers import COMMON_AUDIT_FIELDS, entry +from .models import OperationCatalogEntry + +PERSONNEL_KNOWLEDGE_ENTRIES: tuple[OperationCatalogEntry, ...] = ( + entry( + operation_code="personnel.role_update", + domain="personnel", + description="更新人员角色", + input_schema={ + "type": "object", + "required": ["user_id", "role"], + "properties": { + "user_id": {"type": "integer"}, + "role": {"type": "string", "maxLength": 50}, + }, + "additionalProperties": False, + }, + rbac_policy=["BOSS", "ADMIN"], + idempotency_fields=["tenant_id", "user_id", "role"], + audit_fields=COMMON_AUDIT_FIELDS, + ), + entry( + operation_code="personnel.workload_update", + domain="personnel", + description="更新人员每周可用工时", + input_schema={ + "type": "object", + "required": ["user_id", "max_weekly_hours"], + "properties": { + "user_id": {"type": "integer"}, + "max_weekly_hours": {"type": "number", "minimum": 1, "maximum": 120}, + }, + "additionalProperties": False, + }, + rbac_policy=["BOSS", "ADMIN", "PM"], + idempotency_fields=["tenant_id", "user_id", "max_weekly_hours", "date"], + audit_fields=COMMON_AUDIT_FIELDS, + ), + entry( + operation_code="personnel.availability_update", + domain="personnel", + description="更新人员可用性", + input_schema={ + "type": "object", + "required": ["user_id", "status"], + "properties": { + "user_id": {"type": "integer"}, + "status": {"type": "string", "enum": ["active", "away", "offline"]}, + }, + "additionalProperties": False, + }, + rbac_policy=["BOSS", "ADMIN", "PM"], + idempotency_fields=["tenant_id", "user_id", "status", "date"], + audit_fields=COMMON_AUDIT_FIELDS, + ), + entry( + operation_code="knowledge.doc_upsert", + domain="knowledge", + description="新增或更新知识文档", + input_schema={ + "type": "object", + "required": ["title", "content"], + "properties": { + "doc_id": {"type": "integer"}, + "title": {"type": "string", "minLength": 1, "maxLength": 255}, + "content": {"type": "string", "minLength": 1}, + }, + "additionalProperties": False, + }, + rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"], + idempotency_fields=["tenant_id", "doc_id", "content_hash"], + audit_fields=COMMON_AUDIT_FIELDS, + ), + entry( + operation_code="knowledge.chunk_upsert", + domain="knowledge", + description="新增或更新知识分片", + input_schema={ + "type": "object", + "required": ["doc_id", "chunk_text"], + "properties": { + "doc_id": {"type": "integer"}, + "chunk_id": {"type": "integer"}, + "chunk_text": {"type": "string", "minLength": 1}, + }, + "additionalProperties": False, + }, + rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"], + idempotency_fields=["tenant_id", "doc_id", "chunk_hash"], + audit_fields=COMMON_AUDIT_FIELDS, + ), + entry( + operation_code="knowledge.link_upsert", + domain="knowledge", + description="维护知识链接(文档/实体/项目关系)", + input_schema={ + "type": "object", + "required": ["source_type", "source_id", "target_type", "target_id"], + "properties": { + "source_type": {"type": "string", "maxLength": 40}, + "source_id": {"type": "integer"}, + "target_type": {"type": "string", "maxLength": 40}, + "target_id": {"type": "integer"}, + }, + "additionalProperties": False, + }, + rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"], + idempotency_fields=["tenant_id", "source_type", "source_id", "target_type", "target_id"], + audit_fields=COMMON_AUDIT_FIELDS, + ), + entry( + operation_code="knowledge.reindex", + domain="knowledge", + description="触发知识库重建索引", + input_schema={ + "type": "object", + "required": ["scope"], + "properties": { + "scope": {"type": "string", "enum": ["tenant", "project", "document"]}, + "project_id": {"type": "integer"}, + "doc_id": {"type": "integer"}, + }, + "additionalProperties": False, + }, + rbac_policy=["BOSS", "ADMIN", "AI_ENGINEER"], + idempotency_fields=["tenant_id", "scope", "project_id", "doc_id", "date_hour"], + audit_fields=COMMON_AUDIT_FIELDS, + ), +) diff --git a/backend/app/config/operation_catalog_core/project_task.py b/backend/app/config/operation_catalog_core/project_task.py new file mode 100644 index 00000000..93887302 --- /dev/null +++ b/backend/app/config/operation_catalog_core/project_task.py @@ -0,0 +1,184 @@ +"""Project/task operation catalog entries.""" + +from __future__ import annotations + +from .helpers import COMMON_AUDIT_FIELDS, entry +from .models import OperationCatalogEntry + +PROJECT_TASK_ENTRIES: tuple[OperationCatalogEntry, ...] = ( + entry( + operation_code="project.create", + domain="project", + description="创建项目", + input_schema={ + "type": "object", + "required": ["title"], + "properties": { + "title": {"type": "string", "minLength": 1, "maxLength": 80}, + "description": {"type": "string", "maxLength": 4000}, + "owner_id": {"type": "integer"}, + }, + "additionalProperties": False, + }, + rbac_policy=["BOSS", "ADMIN"], + idempotency_fields=["tenant_id", "title"], + audit_fields=COMMON_AUDIT_FIELDS, + ), + entry( + operation_code="project.update", + domain="project", + description="更新项目元数据", + input_schema={ + "type": "object", + "required": ["project_id"], + "properties": { + "project_id": {"type": "integer"}, + "title": {"type": "string", "maxLength": 80}, + "description": {"type": "string", "maxLength": 4000}, + "status": {"type": "string", "enum": ["active", "archived", "done"]}, + }, + "additionalProperties": False, + }, + rbac_policy=["BOSS", "ADMIN"], + idempotency_fields=["tenant_id", "project_id", "updated_at"], + audit_fields=COMMON_AUDIT_FIELDS, + ), + entry( + operation_code="project.archive", + domain="project", + description="归档项目", + input_schema={ + "type": "object", + "required": ["project_id"], + "properties": {"project_id": {"type": "integer"}}, + "additionalProperties": False, + }, + rbac_policy=["BOSS", "ADMIN"], + idempotency_fields=["tenant_id", "project_id", "status"], + audit_fields=COMMON_AUDIT_FIELDS, + ), + entry( + operation_code="project.reopen", + domain="project", + description="重新打开项目", + input_schema={ + "type": "object", + "required": ["project_id"], + "properties": {"project_id": {"type": "integer"}}, + "additionalProperties": False, + }, + rbac_policy=["BOSS", "ADMIN"], + idempotency_fields=["tenant_id", "project_id", "status"], + audit_fields=COMMON_AUDIT_FIELDS, + ), + entry( + operation_code="task.create", + domain="task", + description="创建任务", + input_schema={ + "type": "object", + "required": ["project_id", "title"], + "properties": { + "project_id": {"type": "integer"}, + "title": {"type": "string", "minLength": 1, "maxLength": 120}, + "description": {"type": "string", "maxLength": 5000}, + "assignee_id": {"type": "integer"}, + "required_skill_tag_ids": { + "type": "array", + "items": {"type": "integer"}, + "maxItems": 20, + }, + "priority": {"type": "string", "enum": ["high", "medium", "low", "P0", "P1", "P2", "P3"]}, + "estimated_hours": {"type": "number", "minimum": 0, "maximum": 200}, + "due_date": {"type": "string", "maxLength": 40}, + }, + "additionalProperties": False, + }, + rbac_policy=["BOSS", "ADMIN", "PM"], + idempotency_fields=["tenant_id", "project_id", "title"], + audit_fields=COMMON_AUDIT_FIELDS, + ), + entry( + operation_code="task.update", + domain="task", + description="更新任务内容", + input_schema={ + "type": "object", + "required": ["task_id"], + "properties": { + "task_id": {"type": "integer"}, + "title": {"type": "string", "maxLength": 120}, + "description": {"type": "string", "maxLength": 5000}, + }, + "additionalProperties": False, + }, + rbac_policy=["BOSS", "ADMIN", "PM"], + idempotency_fields=["tenant_id", "task_id", "updated_at"], + audit_fields=COMMON_AUDIT_FIELDS, + ), + entry( + operation_code="task.assign", + domain="task", + description="分配任务负责人", + input_schema={ + "type": "object", + "required": ["task_id", "assignee_id"], + "properties": { + "task_id": {"type": "integer"}, + "assignee_id": {"type": "integer"}, + }, + "additionalProperties": False, + }, + rbac_policy=["BOSS", "ADMIN", "PM"], + idempotency_fields=["tenant_id", "task_id", "assignee_id"], + audit_fields=COMMON_AUDIT_FIELDS, + ), + entry( + operation_code="task.prioritize", + domain="task", + description="调整任务优先级", + input_schema={ + "type": "object", + "required": ["task_id", "priority"], + "properties": { + "task_id": {"type": "integer"}, + "priority": {"type": "string", "enum": ["high", "medium", "low", "P0", "P1", "P2", "P3"]}, + }, + "additionalProperties": False, + }, + rbac_policy=["BOSS", "ADMIN", "PM"], + idempotency_fields=["tenant_id", "task_id", "priority"], + audit_fields=COMMON_AUDIT_FIELDS, + ), + entry( + operation_code="task.status_transition", + domain="task", + description="任务状态迁移", + input_schema={ + "type": "object", + "required": ["task_id", "status"], + "properties": { + "task_id": {"type": "integer"}, + "status": {"type": "string", "enum": ["todo", "in_progress", "review", "done", "blocked", "closed"]}, + }, + "additionalProperties": False, + }, + rbac_policy=["BOSS", "ADMIN", "PM", "AI_ENGINEER"], + idempotency_fields=["tenant_id", "task_id", "status"], + audit_fields=COMMON_AUDIT_FIELDS, + ), + entry( + operation_code="task.delete", + domain="task", + description="删除任务", + input_schema={ + "type": "object", + "required": ["task_id"], + "properties": {"task_id": {"type": "integer"}}, + "additionalProperties": False, + }, + rbac_policy=["BOSS", "ADMIN"], + idempotency_fields=["tenant_id", "task_id"], + audit_fields=COMMON_AUDIT_FIELDS, + ), +) diff --git a/backend/app/dispatcher.py b/backend/app/dispatcher.py index b98a1041..f5cb9d51 100644 --- a/backend/app/dispatcher.py +++ b/backend/app/dispatcher.py @@ -1,229 +1,37 @@ -""" -Neural Sync - Role Dispatcher +"""Neural Sync - Role Dispatcher。""" -根据任务的技术指纹自动路由到对应角色的工作台。 -""" +from __future__ import annotations -from enum import Enum -from dataclasses import dataclass, field -from typing import Dict, List, Optional, Any, Tuple -from datetime import datetime -import re import logging +import re +from typing import Any, Optional + from pydantic import BaseModel, Field +from .dispatcher_rules import ROLE_RULES, TECH_KEYWORDS, Role, RoutingRule from .utils.timezone import now_cn_aware + logger = logging.getLogger(__name__) -class Role(str, Enum): - """系统角色定义""" - - BOSS = "boss" # 老板 - 战略决策 - AI_ENGINEER = "ai_engineer" # AI 工程师 - Prompt/模型/RAG - BACKEND = "backend" # 后端开发 - API/数据库/爬虫 - FRONTEND = "frontend" # 前端开发 - UI/组件/交互 - - -@dataclass -class RoutingRule: - """路由规则定义""" - - role: Role - name: str - description: str - triggers: List[str] = field(default_factory=list) # 触发关键词 - keywords: List[str] = field(default_factory=list) # 技术关键词 - patterns: List[str] = field(default_factory=list) # 正则模式 - threshold: Optional[Dict[str, Any]] = None # 阈值条件 - priority: int = 0 # 优先级,数值越大越优先 - - class RoleRouter: - """ - 角色路由引擎 + """角色路由引擎。""" - 根据信号的技术指纹自动路由到对应角色: - - Boss: 高成本、高风险、架构变更决策 - - AI Engineer: LLM、Prompt、RAG、数据处理 - - Backend: API、数据库、爬虫、性能优化 - - Frontend: UI、可视化、交互、响应式 - """ - - # 角色路由规则定义 - ROLE_RULES: List[RoutingRule] = [ - RoutingRule( - role=Role.BOSS, - name="战略决策", - description="需要老板决策的高影响任务", - triggers=["cost_estimate", "architecture_change", "high_risk", "security"], - keywords=["预算", "架构", "安全", "风险", "审批", "决策"], - threshold={"dev_days": 5, "risk_level": ["CRITICAL", "HIGH"]}, - priority=100, - ), - RoutingRule( - role=Role.AI_ENGINEER, - name="AI 工程", - description="AI 模型和数据处理相关任务", - triggers=["prompt", "model", "embedding", "rag", "llm"], - keywords=[ - "ollama", - "openai", - "claude", - "prompt", - "embedding", - "rag", - "llm", - "向量", - "模型", - "训练", - "数据清洗", - "token", - "context", - ], - patterns=[r"(prompt|embedding|rag|llm|vector|model)", r"(ai|machine.?learning|ml)"], - priority=50, - ), - RoutingRule( - role=Role.BACKEND, - name="后端开发", - description="API、数据库、爬虫等后端任务", - triggers=["api", "database", "crawler", "performance", "docker"], - keywords=[ - "fastapi", - "sqlalchemy", - "postgres", - "sql", - "async", - "docker", - "kubernetes", - "redis", - "celery", - "爬虫", - "api", - "接口", - "数据库", - "后端", - "性能", - "缓存", - ], - patterns=[ - r"(api|endpoint|route)", - r"(sql|database|db)", - r"(docker|k8s|kubernetes)", - r"(async|await|celery)", - ], - priority=30, - ), - RoutingRule( - role=Role.FRONTEND, - name="前端开发", - description="UI、可视化、交互等前端任务", - triggers=["ui", "visualization", "interaction", "responsive", "chart"], - keywords=[ - "vue", - "react", - "typescript", - "component", - "chart", - "echarts", - "d3", - "可视化", - "ui", - "ux", - "样式", - "动画", - "响应式", - "组件", - "交互", - "页面", - "布局", - ], - patterns=[ - r"(vue|react|tsx?|jsx?)", - r"(css|scss|tailwind)", - r"(component|props|emit)", - r"(chart|graph|visualization)", - ], - priority=30, - ), - ] - - # 技术栈关键词映射 - TECH_KEYWORDS = { - "frontend": [ - "vue", - "react", - "typescript", - "javascript", - "css", - "scss", - "tailwind", - "component", - "props", - "emit", - "template", - "style", - ], - "backend": [ - "fastapi", - "flask", - "django", - "sqlalchemy", - "postgres", - "mysql", - "redis", - "celery", - "async", - "await", - "docker", - "kubernetes", - "k8s", - "api", - "endpoint", - "route", - ], - "ai": [ - "llm", - "ollama", - "openai", - "claude", - "prompt", - "embedding", - "rag", - "vector", - "model", - "training", - "inference", - "token", - "context", - ], - } + ROLE_RULES: list[RoutingRule] = ROLE_RULES + TECH_KEYWORDS = TECH_KEYWORDS def __init__(self): - # 编译正则模式 - self._compiled_patterns: Dict[Role, List[re.Pattern]] = {} + self._compiled_patterns: dict[Role, list[re.Pattern]] = {} for rule in self.ROLE_RULES: - patterns = [re.compile(p, re.IGNORECASE) for p in rule.patterns] + patterns = [re.compile(pattern, re.IGNORECASE) for pattern in rule.patterns] self._compiled_patterns[rule.role] = patterns def route( self, signal_content: str, - analysis: Optional[Dict[str, Any]] = None, + analysis: Optional[dict[str, Any]] = None, source: Optional[str] = None, - ) -> Tuple[Role, float, Dict[str, Any]]: - """ - 根据信号内容和分析结果决定路由角色 - - Args: - signal_content: 信号内容 - analysis: AI 分析结果(可选) - source: 信号来源(可选) - - Returns: - (角色, 置信度, 路由详情) - """ + ) -> tuple[Role, float, dict[str, Any]]: analysis = analysis or {} details = { "matched_rules": [], @@ -231,52 +39,42 @@ class RoleRouter: "reasons": [], } - # 1. 首先检查是否需要升级到老板决策 if self._should_escalate_to_boss(analysis): details["reasons"].append("触发老板决策阈值") details["matched_rules"].append("threshold_check") return Role.BOSS, 1.0, details - # 2. 计算每个角色的匹配分数 - scores: Dict[Role, float] = {rule.role: 0.0 for rule in self.ROLE_RULES} + scores: dict[Role, float] = {rule.role: 0.0 for rule in self.ROLE_RULES} - # 基于关键词匹配 content_lower = signal_content.lower() for rule in self.ROLE_RULES: - keyword_score = sum( - 1 for kw in rule.keywords if kw.lower() in content_lower - ) + keyword_score = sum(1 for kw in rule.keywords if kw.lower() in content_lower) if keyword_score > 0: scores[rule.role] += keyword_score * 0.3 details["scores"][f"{rule.role}_keywords"] = keyword_score - # 基于正则模式匹配 for rule in self.ROLE_RULES: if rule.role in self._compiled_patterns: pattern_score = sum( - 1 for pattern in self._compiled_patterns[rule.role] + 1 + for pattern in self._compiled_patterns[rule.role] if pattern.search(signal_content) ) if pattern_score > 0: scores[rule.role] += pattern_score * 0.5 details["scores"][f"{rule.role}_patterns"] = pattern_score - # 基于触发词匹配 for rule in self.ROLE_RULES: - trigger_score = sum( - 1 for trigger in rule.triggers if trigger.lower() in content_lower - ) + trigger_score = sum(1 for trigger in rule.triggers if trigger.lower() in content_lower) if trigger_score > 0: scores[rule.role] += trigger_score * 0.7 details["scores"][f"{rule.role}_triggers"] = trigger_score - # 基于优先级加权 for rule in self.ROLE_RULES: if scores[rule.role] > 0: - scores[rule.role] *= (1 + rule.priority * 0.01) + scores[rule.role] *= 1 + rule.priority * 0.01 - # 3. 找出最高分的角色 - max_role = Role.AI_ENGINEER # 默认 AI 工程师 + max_role = Role.AI_ENGINEER max_score = 0.0 for role, score in scores.items(): @@ -284,7 +82,8 @@ class RoleRouter: max_score = score max_role = role elif score == max_score and score > 0: - # 分数相同时,优先级高的胜出 + role_priority = 0 + max_role_priority = 0 for rule in self.ROLE_RULES: if rule.role == role: role_priority = rule.priority @@ -293,39 +92,34 @@ class RoleRouter: if role_priority > max_role_priority: max_role = role - # 归一化置信度 confidence = min(max_score / 3.0, 1.0) if max_score > 0 else 0.5 - # 记录详情 details["matched_rules"] = [ - rule.name for rule in self.ROLE_RULES if scores[rule.role] > 0 + rule.name + for rule in self.ROLE_RULES + if scores[rule.role] > 0 ] details["final_scores"] = {role.value: score for role, score in scores.items()} - details["reasons"].append(f"基于关键词和模式匹配") + details["reasons"].append("基于关键词和模式匹配") return max_role, confidence, details - def _should_escalate_to_boss(self, analysis: Dict[str, Any]) -> bool: - """检查是否需要升级到老板决策""" - # 检查开发成本 + def _should_escalate_to_boss(self, analysis: dict[str, Any]) -> bool: dev_days = analysis.get("resource_cost", {}).get("dev_days", 0) if dev_days >= 5: return True - # 检查风险等级 risk_level = analysis.get("risk_level", "") if risk_level in ["CRITICAL", "HIGH"]: return True - # 检查是否涉及架构变更 is_architecture = analysis.get("is_architecture_change", False) if is_architecture: return True return False - def get_role_info(self, role: Role) -> Dict[str, Any]: - """获取角色信息""" + def get_role_info(self, role: Role) -> dict[str, Any]: for rule in self.ROLE_RULES: if rule.role == role: return { @@ -337,8 +131,7 @@ class RoleRouter: } return {} - def get_all_roles(self) -> List[Dict[str, Any]]: - """获取所有角色信息""" + def get_all_roles(self) -> list[dict[str, Any]]: return [ { "role": rule.role.value, @@ -346,52 +139,39 @@ class RoleRouter: "description": rule.description, "priority": rule.priority, } - for rule in sorted(self.ROLE_RULES, key=lambda r: -r.priority) + for rule in sorted(self.ROLE_RULES, key=lambda item: -item.priority) ] class RoutingResult(BaseModel): - """路由结果模型""" + """路由结果模型。""" role: str confidence: float - details: Dict[str, Any] + details: dict[str, Any] signal_id: Optional[str] = None timestamp: float = Field(default_factory=lambda: now_cn_aware().timestamp()) -# 全局路由器实例 _global_router: Optional[RoleRouter] = None def get_router() -> RoleRouter: - """获取全局路由器实例""" + """获取全局路由器实例。""" global _global_router if _global_router is None: _global_router = RoleRouter() return _global_router -# 便捷函数 def route_signal( signal_content: str, - analysis: Optional[Dict[str, Any]] = None, + analysis: Optional[dict[str, Any]] = None, signal_id: Optional[str] = None, ) -> RoutingResult: - """ - 路由信号到对应角色 - - Args: - signal_content: 信号内容 - analysis: AI 分析结果 - signal_id: 信号 ID - - Returns: - 路由结果 - """ + """路由信号到对应角色。""" router = get_router() role, confidence, details = router.route(signal_content, analysis) - return RoutingResult( role=role.value, confidence=confidence, diff --git a/backend/app/dispatcher_rules.py b/backend/app/dispatcher_rules.py new file mode 100644 index 00000000..e6a90b43 --- /dev/null +++ b/backend/app/dispatcher_rules.py @@ -0,0 +1,179 @@ +"""Static rule definitions for role dispatcher.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Optional + + +class Role(str, Enum): + """系统角色定义""" + + BOSS = "boss" + AI_ENGINEER = "ai_engineer" + BACKEND = "backend" + FRONTEND = "frontend" + + +@dataclass +class RoutingRule: + """路由规则定义""" + + role: Role + name: str + description: str + triggers: list[str] = field(default_factory=list) + keywords: list[str] = field(default_factory=list) + patterns: list[str] = field(default_factory=list) + threshold: Optional[dict[str, Any]] = None + priority: int = 0 + + +ROLE_RULES: list[RoutingRule] = [ + RoutingRule( + role=Role.BOSS, + name="战略决策", + description="需要老板决策的高影响任务", + triggers=["cost_estimate", "architecture_change", "high_risk", "security"], + keywords=["预算", "架构", "安全", "风险", "审批", "决策"], + threshold={"dev_days": 5, "risk_level": ["CRITICAL", "HIGH"]}, + priority=100, + ), + RoutingRule( + role=Role.AI_ENGINEER, + name="AI 工程", + description="AI 模型和数据处理相关任务", + triggers=["prompt", "model", "embedding", "rag", "llm"], + keywords=[ + "ollama", + "openai", + "claude", + "prompt", + "embedding", + "rag", + "llm", + "向量", + "模型", + "训练", + "数据清洗", + "token", + "context", + ], + patterns=[r"(prompt|embedding|rag|llm|vector|model)", r"(ai|machine.?learning|ml)"], + priority=50, + ), + RoutingRule( + role=Role.BACKEND, + name="后端开发", + description="API、数据库、爬虫等后端任务", + triggers=["api", "database", "crawler", "performance", "docker"], + keywords=[ + "fastapi", + "sqlalchemy", + "postgres", + "sql", + "async", + "docker", + "kubernetes", + "redis", + "celery", + "爬虫", + "api", + "接口", + "数据库", + "后端", + "性能", + "缓存", + ], + patterns=[ + r"(api|endpoint|route)", + r"(sql|database|db)", + r"(docker|k8s|kubernetes)", + r"(async|await|celery)", + ], + priority=30, + ), + RoutingRule( + role=Role.FRONTEND, + name="前端开发", + description="UI、可视化、交互等前端任务", + triggers=["ui", "visualization", "interaction", "responsive", "chart"], + keywords=[ + "vue", + "react", + "typescript", + "component", + "chart", + "echarts", + "d3", + "可视化", + "ui", + "ux", + "样式", + "动画", + "响应式", + "组件", + "交互", + "页面", + "布局", + ], + patterns=[ + r"(vue|react|tsx?|jsx?)", + r"(css|scss|tailwind)", + r"(component|props|emit)", + r"(chart|graph|visualization)", + ], + priority=30, + ), +] + +TECH_KEYWORDS = { + "frontend": [ + "vue", + "react", + "typescript", + "javascript", + "css", + "scss", + "tailwind", + "component", + "props", + "emit", + "template", + "style", + ], + "backend": [ + "fastapi", + "flask", + "django", + "sqlalchemy", + "postgres", + "mysql", + "redis", + "celery", + "async", + "await", + "docker", + "kubernetes", + "k8s", + "api", + "endpoint", + "route", + ], + "ai": [ + "llm", + "ollama", + "openai", + "claude", + "prompt", + "embedding", + "rag", + "vector", + "model", + "training", + "inference", + "token", + "context", + ], +} diff --git a/backend/app/events.py b/backend/app/events.py index fc7afdbb..ca3cc2b0 100644 --- a/backend/app/events.py +++ b/backend/app/events.py @@ -1,113 +1,24 @@ -""" -Neural Sync - Event Bus System +"""Neural Sync - Event Bus System。""" -实现事件驱动架构的核心组件,支持异步事件发布/订阅模式。 -""" +from __future__ import annotations -from enum import Enum -from dataclasses import dataclass, field -from datetime import datetime -from typing import Callable, Dict, List, Any, Optional import asyncio import logging from contextlib import asynccontextmanager +from typing import Any, Callable, Optional + +from .events_models import Event, EventType -from .utils.timezone import now_cn_aware logger = logging.getLogger(__name__) -class EventType(Enum): - """系统事件类型定义""" - - # 信号事件 - SIGNAL_INGESTED = "signal.ingested" - SIGNAL_ANALYZED = "signal.analyzed" - SIGNAL_FEEDBACK = "signal.feedback" - SIGNAL_PRIORITY_CHANGED = "signal.priority_changed" - - # 任务事件 - TASK_ASSIGNED = "task.assigned" - TASK_STARTED = "task.started" - TASK_COMPLETED = "task.completed" - TASK_FAILED = "task.failed" - - # 代码事件 - CODE_COMMIT = "code.commit" - CODE_REVIEW_REQUESTED = "code.review_requested" - CODE_DRAFT_GENERATED = "code.draft_generated" - - # 审计事件 - AUDIT_STARTED = "audit.started" - AUDIT_COMPLETED = "audit.completed" - AUDIT_FAILED = "audit.failed" - - # 合并事件 - AUTO_MERGE_REQUESTED = "auto_merge.requested" - AUTO_MERGE_SUCCESS = "auto_merge.success" - AUTO_MERGE_FAILED = "auto_merge.failed" - AUTO_MERGE_COOLDOWN = "auto_merge.cooldown" - - # 集成事件 - PLANE_SYNC_SUCCESS = "integration.plane.success" - PLANE_SYNC_FAILED = "integration.plane.failed" - LARK_NOTIFY_SUCCESS = "integration.lark.success" - LARK_NOTIFY_FAILED = "integration.lark.failed" - - # 项目域事件 - DOMAIN_CREATED = "domain.created" - DOMAIN_UPDATED = "domain.updated" - DOMAIN_DELETED = "domain.deleted" - DOMAIN_SWITCHED = "domain.switched" - DOMAIN_SYNCING = "domain.syncing" - DOMAIN_SYNCED = "domain.synced" - - # 项目事件 - PROJECT_CREATED = "project.created" - PROJECT_UPDATED = "project.updated" - PROJECT_DELETED = "project.deleted" - SIGNAL_LINKED = "signal.linked" - SIGNAL_UNLINKED = "signal.unlinked" - - -@dataclass -class Event: - """事件数据结构""" - - type: EventType - data: Dict[str, Any] - timestamp: float = field(default_factory=lambda: now_cn_aware().timestamp()) - source: Optional[str] = None - correlation_id: Optional[str] = None - metadata: Dict[str, Any] = field(default_factory=dict) - - def to_dict(self) -> Dict[str, Any]: - """转换为字典格式""" - return { - "type": self.type.value, - "data": self.data, - "timestamp": self.timestamp, - "source": self.source, - "correlation_id": self.correlation_id, - "metadata": self.metadata, - } - - class EventBus: - """ - 事件总线 - 核心事件分发系统 - - 特性: - - 异步事件发布 - - 多订阅者支持 - - 事件过滤 - - 错误处理 - - 性能监控 - """ + """事件总线核心。""" def __init__(self): - self._subscribers: Dict[EventType, List[Callable]] = {} - self._wildcard_subscribers: List[Callable] = [] - self._event_history: List[Event] = [] + self._subscribers: dict[EventType, list[Callable]] = {} + self._wildcard_subscribers: list[Callable] = [] + self._event_history: list[Event] = [] self._max_history = 1000 self._metrics = { "published": 0, @@ -118,22 +29,18 @@ class EventBus: self._running = False @property - def metrics(self) -> Dict[str, int]: - """获取事件指标""" + def metrics(self) -> dict[str, int]: return self._metrics.copy() @property - def history(self) -> List[Dict[str, Any]]: - """获取事件历史""" - return [e.to_dict() for e in self._event_history] + def history(self) -> list[dict[str, Any]]: + return [event.to_dict() for event in self._event_history] async def start(self): - """启动事件总线""" self._running = True logger.info("EventBus started") async def stop(self): - """停止事件总线""" self._running = False logger.info("EventBus stopped") @@ -143,33 +50,17 @@ class EventBus: handler: Callable, filter_func: Optional[Callable[[Event], bool]] = None, ) -> Callable: - """ - 订阅事件 - - Args: - event_type: 事件类型 - handler: 事件处理函数 (async def handler(event: Event) -> None) - filter_func: 事件过滤函数 (def filter(event: Event) -> bool) - - Returns: - 取消订阅的函数 - """ - async def wrapped_handler(event: Event): if filter_func and not filter_func(event): return try: await handler(event) self._metrics["delivered"] += 1 - except Exception as e: - logger.error( - f"Error in event handler for {event_type.value}: {e}", - exc_info=True, - ) + except Exception as exc: + logger.error(f"Error in event handler for {event_type.value}: {exc}", exc_info=True) self._metrics["failed"] += 1 - # 包装为原始 handler 用于取消订阅 - wrapped_handler.original = handler # type: ignore + wrapped_handler.original = handler # type: ignore[attr-defined] async def _subscribe(): async with self._lock: @@ -189,22 +80,17 @@ class EventBus: handler: Callable, filter_func: Optional[Callable[[Event], bool]] = None, ) -> Callable: - """订阅所有事件(通配符订阅)""" - async def wrapped_handler(event: Event): if filter_func and not filter_func(event): return try: await handler(event) self._metrics["delivered"] += 1 - except Exception as e: - logger.error( - f"Error in wildcard event handler: {e}", - exc_info=True, - ) + except Exception as exc: + logger.error(f"Error in wildcard event handler: {exc}", exc_info=True) self._metrics["failed"] += 1 - wrapped_handler.original = handler # type: ignore + wrapped_handler.original = handler # type: ignore[attr-defined] async def _subscribe(): async with self._lock: @@ -218,77 +104,51 @@ class EventBus: return unsubscribe async def _unsubscribe(self, event_type: EventType, handler: Callable): - """取消订阅""" async with self._lock: if event_type in self._subscribers: self._subscribers[event_type] = [ - h for h in self._subscribers[event_type] if h != handler + subscribed + for subscribed in self._subscribers[event_type] + if subscribed != handler ] async def _unsubscribe_wildcard(self, handler: Callable): - """取消通配符订阅""" async with self._lock: self._wildcard_subscribers = [ - h for h in self._wildcard_subscribers if h != handler + subscribed + for subscribed in self._wildcard_subscribers + if subscribed != handler ] async def publish(self, event: Event) -> bool: - """ - 发布事件 - - Args: - event: 事件对象 - - Returns: - 是否成功发布 - """ if not self._running: logger.warning(f"EventBus not running, discarding event: {event.type.value}") return False self._metrics["published"] += 1 - # 记录历史 self._event_history.append(event) if len(self._event_history) > self._max_history: self._event_history.pop(0) - # 通知订阅者 tasks = [] - - # 特定事件订阅者 if event.type in self._subscribers: for handler in self._subscribers[event.type]: tasks.append(handler(event)) - # 通配符订阅者 for handler in self._wildcard_subscribers: tasks.append(handler(event)) if tasks: results = await asyncio.gather(*tasks, return_exceptions=True) - failed = sum(1 for r in results if isinstance(r, Exception)) + failed = sum(1 for result in results if isinstance(result, Exception)) if failed > 0: self._metrics["failed"] += failed - logger.debug( - f"Published event {event.type.value} to {len(tasks)} subscribers" - ) + logger.debug(f"Published event {event.type.value} to {len(tasks)} subscribers") return True - async def publish_and_wait( - self, event: Event, timeout: float = 5.0 - ) -> List[Any]: - """ - 发布事件并等待所有处理完成 - - Args: - event: 事件对象 - timeout: 超时时间(秒) - - Returns: - 所有处理器的返回值列表 - """ + async def publish_and_wait(self, event: Event, timeout: float = 5.0) -> list[Any]: if not self._running: raise RuntimeError("EventBus not running") @@ -296,7 +156,6 @@ class EventBus: self._event_history.append(event) tasks = [] - if event.type in self._subscribers: tasks.extend(self._subscribers[event.type]) @@ -307,36 +166,27 @@ class EventBus: return [] try: - results = await asyncio.wait_for( + return await asyncio.wait_for( asyncio.gather(*tasks, return_exceptions=True), timeout=timeout, ) - return results except asyncio.TimeoutError: - logger.warning( - f"Event {event.type.value} processing timed out after {timeout}s" - ) + logger.warning(f"Event {event.type.value} processing timed out after {timeout}s") raise def clear_history(self): - """清空事件历史""" self._event_history.clear() def get_subscriber_count(self, event_type: Optional[EventType] = None) -> int: - """获取订阅者数量""" if event_type: return len(self._subscribers.get(event_type, [])) - return sum(len(subs) for subs in self._subscribers.values()) + len( - self._wildcard_subscribers - ) + return sum(len(subs) for subs in self._subscribers.values()) + len(self._wildcard_subscribers) -# 全局事件总线实例 _global_event_bus: Optional[EventBus] = None def get_event_bus() -> EventBus: - """获取全局事件总线实例""" global _global_event_bus if _global_event_bus is None: _global_event_bus = EventBus() @@ -345,7 +195,6 @@ def get_event_bus() -> EventBus: @asynccontextmanager async def event_bus_context(): - """事件总线上下文管理器""" bus = get_event_bus() await bus.start() try: @@ -354,20 +203,26 @@ async def event_bus_context(): await bus.stop() -# 便捷函数 -async def publish_event(event_type: EventType, data: Dict[str, Any], **kwargs) -> bool: - """发布事件的便捷函数""" +async def publish_event(event_type: EventType, data: dict[str, Any], **kwargs) -> bool: event = Event(type=event_type, data=data, **kwargs) return await get_event_bus().publish(event) -def subscribe_event( - event_type: EventType, handler: Callable -) -> Callable: - """订阅事件的便捷函数""" +def subscribe_event(event_type: EventType, handler: Callable) -> Callable: return get_event_bus().subscribe(event_type, handler) def subscribe_all_events(handler: Callable) -> Callable: - """订阅所有事件的便捷函数""" return get_event_bus().subscribe_wildcard(handler) + + +__all__ = [ + "Event", + "EventBus", + "EventType", + "event_bus_context", + "get_event_bus", + "publish_event", + "subscribe_all_events", + "subscribe_event", +] diff --git a/backend/app/events_models.py b/backend/app/events_models.py new file mode 100644 index 00000000..2cf5bb47 --- /dev/null +++ b/backend/app/events_models.py @@ -0,0 +1,76 @@ +"""Core event models and enums.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Optional + +from .utils.timezone import now_cn_aware + + +class EventType(Enum): + """系统事件类型定义""" + + SIGNAL_INGESTED = "signal.ingested" + SIGNAL_ANALYZED = "signal.analyzed" + SIGNAL_FEEDBACK = "signal.feedback" + SIGNAL_PRIORITY_CHANGED = "signal.priority_changed" + + TASK_ASSIGNED = "task.assigned" + TASK_STARTED = "task.started" + TASK_COMPLETED = "task.completed" + TASK_FAILED = "task.failed" + + CODE_COMMIT = "code.commit" + CODE_REVIEW_REQUESTED = "code.review_requested" + CODE_DRAFT_GENERATED = "code.draft_generated" + + AUDIT_STARTED = "audit.started" + AUDIT_COMPLETED = "audit.completed" + AUDIT_FAILED = "audit.failed" + + AUTO_MERGE_REQUESTED = "auto_merge.requested" + AUTO_MERGE_SUCCESS = "auto_merge.success" + AUTO_MERGE_FAILED = "auto_merge.failed" + AUTO_MERGE_COOLDOWN = "auto_merge.cooldown" + + PLANE_SYNC_SUCCESS = "integration.plane.success" + PLANE_SYNC_FAILED = "integration.plane.failed" + LARK_NOTIFY_SUCCESS = "integration.lark.success" + LARK_NOTIFY_FAILED = "integration.lark.failed" + + DOMAIN_CREATED = "domain.created" + DOMAIN_UPDATED = "domain.updated" + DOMAIN_DELETED = "domain.deleted" + DOMAIN_SWITCHED = "domain.switched" + DOMAIN_SYNCING = "domain.syncing" + DOMAIN_SYNCED = "domain.synced" + + PROJECT_CREATED = "project.created" + PROJECT_UPDATED = "project.updated" + PROJECT_DELETED = "project.deleted" + SIGNAL_LINKED = "signal.linked" + SIGNAL_UNLINKED = "signal.unlinked" + + +@dataclass +class Event: + """事件数据结构""" + + type: EventType + data: dict[str, Any] + timestamp: float = field(default_factory=lambda: now_cn_aware().timestamp()) + source: Optional[str] = None + correlation_id: Optional[str] = None + metadata: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "type": self.type.value, + "data": self.data, + "timestamp": self.timestamp, + "source": self.source, + "correlation_id": self.correlation_id, + "metadata": self.metadata, + } diff --git a/backend/app/main.py b/backend/app/main.py index 0d367f6b..a7ac47c8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,393 +4,44 @@ from __future__ import annotations import asyncio import os -import time from contextlib import asynccontextmanager, suppress -from datetime import datetime, timedelta, timezone from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -import sqlalchemy as sa -from sqlalchemy.exc import OperationalError, ProgrammingError from .api.router import api_router from .core.config import settings from .core.logging import configure_logging, get_logger from .core.response import success_response from .database import AsyncSessionLocal, engine +from .main_db_guard import check_database_dialect_guard +from .main_feishu_outbox import feishu_outbox_drain_loop +from .main_model_probe import probe_model_connectivity_on_startup, startup_log +from .main_warmup import warmup_world_models from .middleware import AuditContextMiddleware -from .models import Project, WorkItem -from .services.scheduler_center import WorkloadRecalcScheduler -from .services.scheduler_center import KnowledgeUrlRefreshScheduler -from .services.scheduler_center import KnowledgeGraphIncrementalScheduler -from .services.scheduler_center import AIPMOCadenceScheduler from .services.ai_orchestration_run_service import AIOrchestrationRunService -from .services.ai_pmo_kernel_service import AIPMOKernelService -from .services.skill_service import init_system_skill_tags -from .services.feishu_long_connection import init_feishu_long_connection_manager -from .services.feishu_integration import dispatch_pending_feishu_replies from .services.context_cache import register_context_cache_invalidation_hooks +from .services.feishu_long_connection import init_feishu_long_connection_manager +from .services.scheduler_center import ( + AIPMOCadenceScheduler, + KnowledgeGraphIncrementalScheduler, + KnowledgeUrlRefreshScheduler, + WorkloadRecalcScheduler, +) +from .services.skill_service import init_system_skill_tags -from .utils.timezone import now_cn_aware configure_logging(settings.debug) logger = get_logger(__name__) register_context_cache_invalidation_hooks() -ACTIVE_PROJECT_STATUSES = ("active", "planning", "todo", "doing") - - -def _is_missing_table_error(exc: Exception) -> bool: - text = str(exc).lower() - return ( - "no such table" in text - or "undefined table" in text - or "relation" in text and "does not exist" in text - ) - - -def _normalize_dialect(value: str) -> str: - raw = str(value or "").strip().lower() - if not raw: - return "" - if raw.startswith("postgres"): - return "postgresql" - if raw.startswith("mysql"): - return "mysql" - if "+" in raw: - raw = raw.split("+", 1)[0] - if ":" in raw: - raw = raw.split(":", 1)[0] - return raw - - -def _mask_db_url(url: str) -> str: - value = str(url or "").strip() - if "@" not in value: - return value - prefix, suffix = value.split("@", 1) - if "://" not in prefix: - return f"***@{suffix}" - scheme, creds = prefix.split("://", 1) - user = creds.split(":", 1)[0] if ":" in creds else creds - return f"{scheme}://{user}:***@{suffix}" - - -def _check_database_dialect_guard(sync_conn) -> None: - configured = _normalize_dialect(settings.database_url) - actual = _normalize_dialect(sync_conn.dialect.name) - expected = _normalize_dialect(os.getenv("PMFLOW_EXPECTED_DB_DIALECT", "postgresql")) or "postgresql" - - env_urls = [ - os.getenv("DATABASE_URL", ""), - os.getenv("SQLALCHEMY_DATABASE_URL", ""), - os.getenv("SYNC_DATABASE_URL", ""), - ] - configured_families = {_normalize_dialect(item) for item in env_urls if str(item or "").strip()} - configured_families.discard("") - - logger.info( - "database_dialect_guard", - configured=configured, - actual=actual, - expected=expected or "unset", - database_url=_mask_db_url(settings.database_url), - ) - print( - "[DB-Guard] " - f"configured={configured}, actual={actual}, expected={expected or 'unset'}, " - f"url={_mask_db_url(settings.database_url)}" - ) - if configured != "postgresql": - raise RuntimeError(f"DATABASE_URL 必须为 PostgreSQL,当前 configured={configured or 'unset'}。") - if actual != "postgresql": - raise RuntimeError(f"运行时数据库必须为 PostgreSQL,当前 actual={actual or 'unset'}。") - if configured and actual and configured != actual: - raise RuntimeError( - f"数据库方言不一致: configured={configured}, actual={actual}。" - "请确保同一服务只连接单一数据库方言。" - ) - if expected and actual and expected != actual: - raise RuntimeError( - f"数据库方言与 PMFLOW_EXPECTED_DB_DIALECT 不一致: expected={expected}, actual={actual}。" - ) - if len(configured_families) > 1: - raise RuntimeError( - f"检测到多种数据库方言配置: {sorted(configured_families)}。" - "禁止同一服务混用多数据库方言。" - ) - - -def _is_postgresql_runtime() -> bool: - return _normalize_dialect(engine.dialect.name) == "postgresql" - - -async def _feishu_outbox_polling_loop() -> None: - while True: - try: - dispatched = await dispatch_pending_feishu_replies(limit=50) - await asyncio.sleep(0.3 if dispatched > 0 else 2.0) - except asyncio.CancelledError: - break - except Exception as exc: - logger.warning("feishu_outbox_drain_failed", error=str(exc)) - await asyncio.sleep(2.0) - - -async def _feishu_outbox_listen_loop() -> None: - while True: - listener_callback = None - driver_connection = None - try: - async with engine.connect() as conn: - await conn.execute(sa.text("LISTEN feishu_outbox_ready")) - await conn.commit() - raw_conn = await conn.get_raw_connection() - driver_connection = getattr(raw_conn, "driver_connection", None) - if driver_connection is None or not hasattr(driver_connection, "add_listener"): - logger.warning("feishu_outbox_listen_unsupported_driver", driver=str(type(driver_connection))) - await _feishu_outbox_polling_loop() - return - - notify_queue: asyncio.Queue[str] = asyncio.Queue(maxsize=1024) - - def _on_notify(_connection, _pid, _channel, payload) -> None: - try: - notify_queue.put_nowait(str(payload or "")) - except asyncio.QueueFull: - pass - - listener_callback = _on_notify - await driver_connection.add_listener("feishu_outbox_ready", listener_callback) - logger.info("feishu_outbox_listen_ready") - - while True: - dispatched = await dispatch_pending_feishu_replies(limit=50) - if dispatched > 0: - continue - try: - await asyncio.wait_for(notify_queue.get(), timeout=2.0) - while not notify_queue.empty(): - notify_queue.get_nowait() - except asyncio.TimeoutError: - continue - except asyncio.CancelledError: - break - except Exception as exc: - logger.warning("feishu_outbox_listen_failed", error=str(exc)) - await asyncio.sleep(2.0) - finally: - if driver_connection is not None and listener_callback is not None: - with suppress(Exception): - await driver_connection.remove_listener("feishu_outbox_ready", listener_callback) - - -async def _feishu_outbox_drain_loop() -> None: - if _is_postgresql_runtime(): - await _feishu_outbox_listen_loop() - return - await _feishu_outbox_polling_loop() - - -async def _get_recent_active_projects( - *, - days: int, - limit_per_tenant: int, - max_projects: int, -) -> list[tuple[int, int]]: - since = (now_cn_aware() - timedelta(days=max(1, int(days)))).replace(tzinfo=None) - per_tenant_limit = max(1, int(limit_per_tenant)) - total_limit = max(per_tenant_limit, int(max_projects)) - - async with AsyncSessionLocal() as db: - try: - rows = ( - await db.execute( - sa.select( - WorkItem.tenant_id.label("tenant_id"), - WorkItem.project_id.label("project_id"), - sa.func.max(WorkItem.created_at).label("last_seen_at"), - ) - .join( - Project, - sa.and_( - Project.tenant_id == WorkItem.tenant_id, - Project.id == WorkItem.project_id, - ), - ) - .where( - sa.and_( - WorkItem.project_id.is_not(None), - WorkItem.created_at >= since, - Project.status.in_(ACTIVE_PROJECT_STATUSES), - ) - ) - .group_by(WorkItem.tenant_id, WorkItem.project_id) - ) - ).all() - except (OperationalError, ProgrammingError) as exc: - if not _is_missing_table_error(exc): - raise - logger.warning("[WM_WARMUP] 活跃项目查询跳过", reason="missing_table", error=str(exc)) - return [] - - bucket: dict[int, list[tuple[datetime, int]]] = {} - for row in rows: - tenant_id = int(getattr(row, "tenant_id")) - project_id = int(getattr(row, "project_id")) - last_seen = getattr(row, "last_seen_at") - last_seen_at = last_seen if isinstance(last_seen, datetime) else datetime.min - bucket.setdefault(tenant_id, []).append((last_seen_at, project_id)) - - picked: list[tuple[datetime, int, int]] = [] - for tenant_id, entries in bucket.items(): - entries.sort(key=lambda item: item[0], reverse=True) - for last_seen_at, project_id in entries[:per_tenant_limit]: - picked.append((last_seen_at, tenant_id, project_id)) - - picked.sort(key=lambda item: item[0], reverse=True) - return [(tenant_id, project_id) for _, tenant_id, project_id in picked[:total_limit]] - - -async def _warmup_world_models() -> None: - days = max(1, int(os.getenv("WORLD_MODEL_WARMUP_DAYS", "7"))) - limit_per_tenant = max(1, int(os.getenv("WORLD_MODEL_WARMUP_LIMIT_PER_TENANT", "5"))) - max_projects = max(limit_per_tenant, int(os.getenv("WORLD_MODEL_WARMUP_MAX_PROJECTS", "50"))) - try: - active_projects = await _get_recent_active_projects( - days=days, - limit_per_tenant=limit_per_tenant, - max_projects=max_projects, - ) - warmed = 0 - for tenant_id, project_id in active_projects: - try: - async with AsyncSessionLocal() as db: - kernel = AIPMOKernelService(db, tenant_id=int(tenant_id)) - await kernel.build_world_model( - tenant_id=int(tenant_id), - project_id=int(project_id), - sender="wm_warmup", - ) - warmed += 1 - except Exception as exc: - logger.warning( - "[WM_WARMUP] 项目预热失败", - tenant_id=tenant_id, - project_id=project_id, - error=str(exc), - ) - logger.info( - "[WM_WARMUP] 预热完成", - count=warmed, - total=len(active_projects), - days=days, - limit_per_tenant=limit_per_tenant, - max_projects=max_projects, - ) - except Exception as exc: - logger.warning("[WM_WARMUP] 预热失败", error=str(exc)) - - -def _startup_log(message: str, **fields) -> None: - logger.info(f"[后端] {message}", **fields) - - -async def _probe_model_connectivity_on_startup() -> None: - from .services.llm import llm_service - - low_provider = str(os.getenv("LLM_ROUTING_LOW_PROVIDER", os.getenv("LLM_PROVIDER", "glm")) or "").strip() or "glm" - low_model = str(os.getenv("LLM_ROUTING_LOW_MODEL", os.getenv("GLM_MODEL", "GLM-4.7")) or "").strip() or "GLM-4.7" - - high_provider = str(os.getenv("LLM_ROUTING_HIGH_PROVIDER", os.getenv("LLM_PROVIDER", "openai")) or "").strip() or "openai" - high_model = str(os.getenv("LLM_ROUTING_HIGH_MODEL", os.getenv("OPENAI_MODEL", "GLM-5")) or "").strip() or "GLM-5" - - probe_targets: list[tuple[str, str, str]] = [ - ("LOW", low_provider, low_model), - ("HIGH", high_provider, high_model), - ] - deduped_targets: list[tuple[str, str, str]] = [] - seen: set[tuple[str, str]] = set() - for level, provider, model in probe_targets: - key = (provider.lower(), model) - if key in seen: - continue - seen.add(key) - deduped_targets.append((level, provider, model)) - - lines: list[str] = [ - "┌──────────────────────────────┐", - "│ 模型连通性检测 │", - ] - - for level, provider, model in deduped_targets: - try: - runtime = llm_service._env_provider_runtime(provider) - runtime["model"] = model - except Exception as exc: - logger.error( - "startup_model_probe_runtime_invalid", - level=level, - provider=provider, - model=model, - error=str(exc), - ) - lines.append(f"│ {model:<12} ✗ 失败 (0.0s) │") - continue - started_at = time.perf_counter() - ok = False - error_text = "" - try: - result = await asyncio.wait_for( - llm_service._chat_completion( - provider=str(runtime.get("provider") or provider), - prompt="ping", - system_prompt="你是连通性检测助手,只回复 OK。", - api_key=str(runtime.get("api_key") or ""), - base_url=str(runtime.get("base_url") or ""), - model=str(runtime.get("model") or model), - temperature=0.0, - max_tokens=1, - function_name="startup_model_probe", - response_mode="fast", - ), - timeout=8.0, - ) - ok = bool(str(result or "").strip()) - if not ok: - error_text = "empty_response" - except Exception as exc: - error_text = str(exc) - logger.error( - "startup_model_probe_failed", - level=level, - provider=provider, - model=model, - error=error_text, - ) - - elapsed = time.perf_counter() - started_at - if ok: - lines.append(f"│ {model:<12} ✓ 正常 ({elapsed:.1f}s) │") - else: - lines.append(f"│ {model:<12} ✗ 失败 ({elapsed:.1f}s) │") - if error_text: - _startup_log("模型连通性失败(不阻断启动)", model=model, provider=provider, error=error_text) - - lines.append("└──────────────────────────────┘") - for line in lines: - logger.info(line) - @asynccontextmanager async def lifespan(_: FastAPI): - """应用生命周期:初始化表与系统技能标签。""" + """应用生命周期:初始化依赖并启动后台任务。""" backend_port = str(os.getenv("BACKEND_PORT", "8000") or "8000").strip() or "8000" - _startup_log("服务启动初始化中") - auto_create_tables = os.getenv("AUTO_CREATE_TABLES", "false").lower() in { - "1", - "true", - "yes", - } + startup_log("服务启动初始化中") + + auto_create_tables = os.getenv("AUTO_CREATE_TABLES", "false").lower() in {"1", "true", "yes"} if auto_create_tables: logger.error( "runtime_ddl_blocked", @@ -398,10 +49,12 @@ async def lifespan(_: FastAPI): reason="runtime ddl is forbidden, run alembic migration instead", ) raise RuntimeError("AUTO_CREATE_TABLES=true 已被禁用;请使用 Alembic migration 变更数据库。") + async with engine.begin() as conn: - await conn.run_sync(_check_database_dialect_guard) - _startup_log("数据库连接成功") - await _probe_model_connectivity_on_startup() + await conn.run_sync(check_database_dialect_guard) + startup_log("数据库连接成功") + + await probe_model_connectivity_on_startup() async with AsyncSessionLocal() as db: try: @@ -410,6 +63,7 @@ async def lifespan(_: FastAPI): logger.info("system_skill_tags_seeded", created=created) except Exception as exc: # pragma: no cover logger.warning("seed_system_skills_failed", error=str(exc)) + try: interrupted = await AIOrchestrationRunService(db).mark_stale_runs_interrupted(older_than_minutes=10) if interrupted: @@ -426,36 +80,35 @@ async def lifespan(_: FastAPI): kg_scheduler.start() ai_pmo_cadence_scheduler = AIPMOCadenceScheduler(AsyncSessionLocal) ai_pmo_cadence_scheduler.start() + feishu_lc = init_feishu_long_connection_manager(AsyncSessionLocal) await feishu_lc.start() - outbox_task = asyncio.create_task( - _feishu_outbox_drain_loop(), - name="feishu-outbox-drain-loop", - ) - wm_warmup_task = asyncio.create_task( - _warmup_world_models(), - name="wm-world-model-warmup", - ) - _startup_log("后端运行中", url=f"http://127.0.0.1:{backend_port}") + + outbox_task = asyncio.create_task(feishu_outbox_drain_loop(), name="feishu-outbox-drain-loop") + wm_warmup_task = asyncio.create_task(warmup_world_models(), name="wm-world-model-warmup") + startup_log("后端运行中", url=f"http://127.0.0.1:{backend_port}") yield - _startup_log("服务关闭中") + + startup_log("服务关闭中") outbox_task.cancel() try: await outbox_task except asyncio.CancelledError: pass + if not wm_warmup_task.done(): wm_warmup_task.cancel() with suppress(asyncio.CancelledError): await wm_warmup_task + await feishu_lc.stop() await ai_pmo_cadence_scheduler.stop() await kg_scheduler.stop() await knowledge_scheduler.stop() await scheduler.stop() await engine.dispose() - _startup_log("服务已关闭") + startup_log("服务已关闭") app = FastAPI( diff --git a/backend/app/main_db_guard.py b/backend/app/main_db_guard.py new file mode 100644 index 00000000..29dbbd1c --- /dev/null +++ b/backend/app/main_db_guard.py @@ -0,0 +1,91 @@ +"""Database dialect guard helpers for app startup.""" + +from __future__ import annotations + +import os + +from .core.config import settings +from .core.logging import get_logger + +logger = get_logger(__name__) + + +def is_missing_table_error(exc: Exception) -> bool: + text = str(exc).lower() + return ( + "no such table" in text + or "undefined table" in text + or "relation" in text and "does not exist" in text + ) + + +def normalize_dialect(value: str) -> str: + raw = str(value or "").strip().lower() + if not raw: + return "" + if raw.startswith("postgres"): + return "postgresql" + if raw.startswith("mysql"): + return "mysql" + if "+" in raw: + raw = raw.split("+", 1)[0] + if ":" in raw: + raw = raw.split(":", 1)[0] + return raw + + +def mask_db_url(url: str) -> str: + value = str(url or "").strip() + if "@" not in value: + return value + prefix, suffix = value.split("@", 1) + if "://" not in prefix: + return f"***@{suffix}" + scheme, creds = prefix.split("://", 1) + user = creds.split(":", 1)[0] if ":" in creds else creds + return f"{scheme}://{user}:***@{suffix}" + + +def check_database_dialect_guard(sync_conn) -> None: + configured = normalize_dialect(settings.database_url) + actual = normalize_dialect(sync_conn.dialect.name) + expected = normalize_dialect(os.getenv("PMFLOW_EXPECTED_DB_DIALECT", "postgresql")) or "postgresql" + + env_urls = [ + os.getenv("DATABASE_URL", ""), + os.getenv("SQLALCHEMY_DATABASE_URL", ""), + os.getenv("SYNC_DATABASE_URL", ""), + ] + configured_families = {normalize_dialect(item) for item in env_urls if str(item or "").strip()} + configured_families.discard("") + + logger.info( + "database_dialect_guard", + configured=configured, + actual=actual, + expected=expected or "unset", + database_url=mask_db_url(settings.database_url), + ) + print( + "[DB-Guard] " + f"configured={configured}, actual={actual}, expected={expected or 'unset'}, " + f"url={mask_db_url(settings.database_url)}" + ) + if configured != "postgresql": + raise RuntimeError(f"DATABASE_URL 必须为 PostgreSQL,当前 configured={configured or 'unset'}。") + if actual != "postgresql": + raise RuntimeError(f"运行时数据库必须为 PostgreSQL,当前 actual={actual or 'unset'}。") + if configured and actual and configured != actual: + raise RuntimeError( + f"数据库方言不一致: configured={configured}, actual={actual}。" + "请确保同一服务只连接单一数据库方言。" + ) + if expected and actual and expected != actual: + raise RuntimeError( + f"数据库方言与 PMFLOW_EXPECTED_DB_DIALECT 不一致: expected={expected}, actual={actual}。" + ) + if len(configured_families) > 1: + raise RuntimeError( + f"检测到多种数据库方言配置: {sorted(configured_families)}。" + "禁止同一服务混用多数据库方言。" + ) diff --git a/backend/app/main_feishu_outbox.py b/backend/app/main_feishu_outbox.py new file mode 100644 index 00000000..ee99dacb --- /dev/null +++ b/backend/app/main_feishu_outbox.py @@ -0,0 +1,89 @@ +"""Feishu outbox draining loops for app lifecycle.""" + +from __future__ import annotations + +import asyncio +from contextlib import suppress + +import sqlalchemy as sa + +from .core.logging import get_logger +from .database import engine +from .services.feishu_integration import dispatch_pending_feishu_replies +from .main_db_guard import normalize_dialect + +logger = get_logger(__name__) + + +def is_postgresql_runtime() -> bool: + return normalize_dialect(engine.dialect.name) == "postgresql" + + +async def feishu_outbox_polling_loop() -> None: + while True: + try: + dispatched = await dispatch_pending_feishu_replies(limit=50) + await asyncio.sleep(0.3 if dispatched > 0 else 2.0) + except asyncio.CancelledError: + break + except Exception as exc: + logger.warning("feishu_outbox_drain_failed", error=str(exc)) + await asyncio.sleep(2.0) + + +async def feishu_outbox_listen_loop() -> None: + while True: + listener_callback = None + driver_connection = None + try: + async with engine.connect() as conn: + await conn.execute(sa.text("LISTEN feishu_outbox_ready")) + await conn.commit() + raw_conn = await conn.get_raw_connection() + driver_connection = getattr(raw_conn, "driver_connection", None) + if driver_connection is None or not hasattr(driver_connection, "add_listener"): + logger.warning( + "feishu_outbox_listen_unsupported_driver", + driver=str(type(driver_connection)), + ) + await feishu_outbox_polling_loop() + return + + notify_queue: asyncio.Queue[str] = asyncio.Queue(maxsize=1024) + + def _on_notify(_connection, _pid, _channel, payload) -> None: + try: + notify_queue.put_nowait(str(payload or "")) + except asyncio.QueueFull: + pass + + listener_callback = _on_notify + await driver_connection.add_listener("feishu_outbox_ready", listener_callback) + logger.info("feishu_outbox_listen_ready") + + while True: + dispatched = await dispatch_pending_feishu_replies(limit=50) + if dispatched > 0: + continue + try: + await asyncio.wait_for(notify_queue.get(), timeout=2.0) + while not notify_queue.empty(): + notify_queue.get_nowait() + except asyncio.TimeoutError: + continue + except asyncio.CancelledError: + break + except Exception as exc: + logger.warning("feishu_outbox_listen_failed", error=str(exc)) + await asyncio.sleep(2.0) + finally: + if driver_connection is not None and listener_callback is not None: + with suppress(Exception): + await driver_connection.remove_listener("feishu_outbox_ready", listener_callback) + + +async def feishu_outbox_drain_loop() -> None: + if is_postgresql_runtime(): + await feishu_outbox_listen_loop() + return + await feishu_outbox_polling_loop() diff --git a/backend/app/main_model_probe.py b/backend/app/main_model_probe.py new file mode 100644 index 00000000..ae105fe3 --- /dev/null +++ b/backend/app/main_model_probe.py @@ -0,0 +1,102 @@ +"""Startup model connectivity probe helpers.""" + +from __future__ import annotations + +import asyncio +import os +import time + +from .core.logging import get_logger + +logger = get_logger(__name__) + + +def startup_log(message: str, **fields) -> None: + logger.info(f"[后端] {message}", **fields) + + +async def probe_model_connectivity_on_startup() -> None: + from .services.llm import llm_service + + low_provider = str(os.getenv("LLM_ROUTING_LOW_PROVIDER", os.getenv("LLM_PROVIDER", "glm")) or "").strip() or "glm" + low_model = str(os.getenv("LLM_ROUTING_LOW_MODEL", os.getenv("GLM_MODEL", "GLM-4.7")) or "").strip() or "GLM-4.7" + + high_provider = str(os.getenv("LLM_ROUTING_HIGH_PROVIDER", os.getenv("LLM_PROVIDER", "openai")) or "").strip() or "openai" + high_model = str(os.getenv("LLM_ROUTING_HIGH_MODEL", os.getenv("OPENAI_MODEL", "GLM-5")) or "").strip() or "GLM-5" + + probe_targets: list[tuple[str, str, str]] = [ + ("LOW", low_provider, low_model), + ("HIGH", high_provider, high_model), + ] + deduped_targets: list[tuple[str, str, str]] = [] + seen: set[tuple[str, str]] = set() + for level, provider, model in probe_targets: + key = (provider.lower(), model) + if key in seen: + continue + seen.add(key) + deduped_targets.append((level, provider, model)) + + lines: list[str] = [ + "┌──────────────────────────────┐", + "│ 模型连通性检测 │", + ] + + for level, provider, model in deduped_targets: + try: + runtime = llm_service._env_provider_runtime(provider) + runtime["model"] = model + except Exception as exc: + logger.error( + "startup_model_probe_runtime_invalid", + level=level, + provider=provider, + model=model, + error=str(exc), + ) + lines.append(f"│ {model:<12} ✗ 失败 (0.0s) │") + continue + + started_at = time.perf_counter() + ok = False + error_text = "" + try: + result = await asyncio.wait_for( + llm_service._chat_completion( + provider=str(runtime.get("provider") or provider), + prompt="ping", + system_prompt="你是连通性检测助手,只回复 OK。", + api_key=str(runtime.get("api_key") or ""), + base_url=str(runtime.get("base_url") or ""), + model=str(runtime.get("model") or model), + temperature=0.0, + max_tokens=1, + function_name="startup_model_probe", + response_mode="fast", + ), + timeout=8.0, + ) + ok = bool(str(result or "").strip()) + if not ok: + error_text = "empty_response" + except Exception as exc: + error_text = str(exc) + logger.error( + "startup_model_probe_failed", + level=level, + provider=provider, + model=model, + error=error_text, + ) + + elapsed = time.perf_counter() - started_at + if ok: + lines.append(f"│ {model:<12} ✓ 正常 ({elapsed:.1f}s) │") + else: + lines.append(f"│ {model:<12} ✗ 失败 ({elapsed:.1f}s) │") + if error_text: + startup_log("模型连通性失败(不阻断启动)", model=model, provider=provider, error=error_text) + + lines.append("└──────────────────────────────┘") + for line in lines: + logger.info(line) diff --git a/backend/app/main_warmup.py b/backend/app/main_warmup.py new file mode 100644 index 00000000..9e7a32ad --- /dev/null +++ b/backend/app/main_warmup.py @@ -0,0 +1,119 @@ +"""World model warmup helpers for startup.""" + +from __future__ import annotations + +import os +from datetime import datetime, timedelta + +import sqlalchemy as sa +from sqlalchemy.exc import OperationalError, ProgrammingError + +from .core.logging import get_logger +from .database import AsyncSessionLocal +from .main_db_guard import is_missing_table_error +from .models import Project, WorkItem +from .services.ai_pmo_kernel_service import AIPMOKernelService +from .utils.timezone import now_cn_aware + +logger = get_logger(__name__) +ACTIVE_PROJECT_STATUSES = ("active", "planning", "todo", "doing") + + +async def get_recent_active_projects( + *, + days: int, + limit_per_tenant: int, + max_projects: int, +) -> list[tuple[int, int]]: + since = (now_cn_aware() - timedelta(days=max(1, int(days)))).replace(tzinfo=None) + per_tenant_limit = max(1, int(limit_per_tenant)) + total_limit = max(per_tenant_limit, int(max_projects)) + + async with AsyncSessionLocal() as db: + try: + rows = ( + await db.execute( + sa.select( + WorkItem.tenant_id.label("tenant_id"), + WorkItem.project_id.label("project_id"), + sa.func.max(WorkItem.created_at).label("last_seen_at"), + ) + .join( + Project, + sa.and_( + Project.tenant_id == WorkItem.tenant_id, + Project.id == WorkItem.project_id, + ), + ) + .where( + sa.and_( + WorkItem.project_id.is_not(None), + WorkItem.created_at >= since, + Project.status.in_(ACTIVE_PROJECT_STATUSES), + ) + ) + .group_by(WorkItem.tenant_id, WorkItem.project_id) + ) + ).all() + except (OperationalError, ProgrammingError) as exc: + if not is_missing_table_error(exc): + raise + logger.warning("[WM_WARMUP] 活跃项目查询跳过", reason="missing_table", error=str(exc)) + return [] + + bucket: dict[int, list[tuple[datetime, int]]] = {} + for row in rows: + tenant_id = int(getattr(row, "tenant_id")) + project_id = int(getattr(row, "project_id")) + last_seen = getattr(row, "last_seen_at") + last_seen_at = last_seen if isinstance(last_seen, datetime) else datetime.min + bucket.setdefault(tenant_id, []).append((last_seen_at, project_id)) + + picked: list[tuple[datetime, int, int]] = [] + for tenant_id, entries in bucket.items(): + entries.sort(key=lambda item: item[0], reverse=True) + for last_seen_at, project_id in entries[:per_tenant_limit]: + picked.append((last_seen_at, tenant_id, project_id)) + + picked.sort(key=lambda item: item[0], reverse=True) + return [(tenant_id, project_id) for _, tenant_id, project_id in picked[:total_limit]] + + +async def warmup_world_models() -> None: + days = max(1, int(os.getenv("WORLD_MODEL_WARMUP_DAYS", "7"))) + limit_per_tenant = max(1, int(os.getenv("WORLD_MODEL_WARMUP_LIMIT_PER_TENANT", "5"))) + max_projects = max(limit_per_tenant, int(os.getenv("WORLD_MODEL_WARMUP_MAX_PROJECTS", "50"))) + try: + active_projects = await get_recent_active_projects( + days=days, + limit_per_tenant=limit_per_tenant, + max_projects=max_projects, + ) + warmed = 0 + for tenant_id, project_id in active_projects: + try: + async with AsyncSessionLocal() as db: + kernel = AIPMOKernelService(db, tenant_id=int(tenant_id)) + await kernel.build_world_model( + tenant_id=int(tenant_id), + project_id=int(project_id), + sender="wm_warmup", + ) + warmed += 1 + except Exception as exc: + logger.warning( + "[WM_WARMUP] 项目预热失败", + tenant_id=tenant_id, + project_id=project_id, + error=str(exc), + ) + logger.info( + "[WM_WARMUP] 预热完成", + count=warmed, + total=len(active_projects), + days=days, + limit_per_tenant=limit_per_tenant, + max_projects=max_projects, + ) + except Exception as exc: + logger.warning("[WM_WARMUP] 预热失败", error=str(exc)) diff --git a/backend/app/seed_projects.py b/backend/app/seed_projects.py index fb2957d8..2b6696f5 100644 --- a/backend/app/seed_projects.py +++ b/backend/app/seed_projects.py @@ -1,27 +1,28 @@ -""" -Seed Projects and Signals +"""Seed projects and signals, including Pixelle SSTC bootstrap.""" -初始化示例项目和信号数据(新模型:BigInteger id) -含 SSTC 宪章:Pixelle-Video 项目关联宪章与任务 -""" +from __future__ import annotations import asyncio -from datetime import datetime from typing import Optional -from sqlalchemy import func, select, or_ + +from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import sessionmaker -from app.models import Signal, Project, ProjectSignalLink, TruthNode, Task, User -from app.services.auth import AuthService +from app.models import Project, ProjectSignalLink, Signal, TruthNode +from app.seed_projects_helpers import ( + build_ai_signal, + build_pixelle_signals, + create_pixelle_charter_and_tasks, + ensure_seed_user, + pick_signal_ids, +) PIXELLE_NAME = "Pixelle-Video 集成" async def seed_pixelle_project(db: Optional[AsyncSession] = None): - """ - 创建或恢复 Pixelle-Video 示例项目:无项目则全量创建;有项目但无宪章/无信号链接则补全 SSTC 数据。 - """ + """创建或恢复 Pixelle-Video 示例项目与 SSTC 数据。""" from app.database import engine if db is None: @@ -33,21 +34,18 @@ async def seed_pixelle_project(db: Optional[AsyncSession] = None): async def _ensure_pixelle_sstc_data(session: AsyncSession, pixelle_project: Project) -> bool: - """ - 若「Pixelle-Video 集成」已存在但无宪章或无项目-信号链接,则补全信号、链接、宪章与任务。返回是否做了变更。 - """ - import json - + """为已有项目补齐关联信号、宪章和任务。""" pid = pixelle_project.id - # 是否已有项目-信号链接 + link_count = await session.execute( select(func.count()).select_from(ProjectSignalLink).where(ProjectSignalLink.project_id == pid) ) has_links = (link_count.scalar() or 0) > 0 - # 是否已有宪章 + charter_count = await session.execute( select(func.count()).select_from(TruthNode).where( - TruthNode.project_id == pid, TruthNode.node_type == "charter" + TruthNode.project_id == pid, + TruthNode.node_type == "charter", ) ) has_charters = (charter_count.scalar() or 0) > 0 @@ -55,135 +53,53 @@ async def _ensure_pixelle_sstc_data(session: AsyncSession, pixelle_project: Proj return False changed = False - pixelle_signals = [] + pixelle_signals: list[Signal] = [] + if not has_links: - pixelle_signals = [ - Signal( - source="HackerNews", - content="Pixelle-Video: An AI-powered automated short video generation platform with script-to-video automation", - impact_score=9.2, - priority="critical", - processed=True, - ), - Signal( - source="ProductHunt", - content="Pixelle-Video launches API for programmatic video content generation", - impact_score=8.5, - priority="high", - processed=True, - ), - Signal( - source="TechCrunch", - content="Pixelle-Video raises Series A funding to scale AI video infrastructure", - impact_score=7.8, - priority="high", - processed=True, - ), - ] - for signal in pixelle_signals: - signal.strategic_analysis = json.dumps({ - "decision_proposal": { - "is_strategic": True, - "threat_level": "HIGH", - "lead_time_days": 14, - "impact_score": getattr(signal, "impact_score", 0), - "recommendation": "立即集成 Pixelle-Video API 到主平台" - }, - "competitiveness_radar": { - "innovation": 85, - "market_potential": 90, - "technical_complexity": 75, - "resource_intensity": 70 - }, - "detected_tech_stacks": ["AI/ML", "Video Processing", "REST API"], - "related_knowledge_nodes": [], - "rag_context_used": 0 - }) - signal.tasks_generated = json.dumps([ - {"task_name": "Integrate Pixelle-Video API", "type": "backend", "estimated_hours": 8.0}, - {"task_name": "Design Video Workflow UI", "type": "frontend", "estimated_hours": 12.0}, - {"task_name": "Optimize Video Pipeline", "type": "ai_engineer", "estimated_hours": 16.0}, - ]) + pixelle_signals = build_pixelle_signals() for signal in pixelle_signals: session.add(signal) await session.flush() - for i, signal in enumerate(pixelle_signals): - link = ProjectSignalLink( - project_id=pid, - signal_id=signal.id, - link_type="related", - relevance_score=1.0, + + for signal in pixelle_signals: + session.add( + ProjectSignalLink( + project_id=pid, + signal_id=signal.id, + link_type="related", + relevance_score=1.0, + ) ) - session.add(link) + pixelle_project.signal_count = len(pixelle_signals) changed = True await session.flush() if not has_charters: - user_result = await session.execute(select(User).limit(1)) - seed_user = user_result.scalar_one_or_none() - if not seed_user: - seed_user = User( - username="boss", - email="boss@pmflow.local", - password_hash=AuthService.hash_password("boss123"), - name="负责人", - role="BOSS", - status="active", - ) - session.add(seed_user) - await session.flush() + seed_user = await ensure_seed_user(session) if pixelle_signals: - signal_ids = [s.id for s in pixelle_signals] + signal_ids = pick_signal_ids(pixelle_signals) else: link_rows = await session.execute( select(ProjectSignalLink.signal_id).where(ProjectSignalLink.project_id == pid) ) - signal_ids = [r[0] for r in link_rows.fetchall()] - charter = TruthNode( - node_type="charter", - parent_id=None, + signal_ids = [row[0] for row in link_rows.fetchall()] + + await create_pixelle_charter_and_tasks( + session, project_id=pid, created_by=seed_user.id, - title="Pixelle-Video 集成需求宪章", - content={ - "rationale": "基于 HackerNews/ProductHunt/TechCrunch 信号,决定集成 Pixelle-Video API 到主平台", - "signal_ids": signal_ids, - "decision": "立即集成 Pixelle-Video API,支持脚本到视频的自动化生成", - }, - version=1, - status="aligned", + signal_ids=signal_ids, ) - session.add(charter) - await session.flush() - for idx, task_item in enumerate([ - {"title": "Integrate Pixelle-Video API", "priority": "high", "hours": 8.0}, - {"title": "Design Video Workflow UI", "priority": "medium", "hours": 12.0}, - {"title": "Optimize Video Pipeline", "priority": "medium", "hours": 16.0}, - ]): - task = Task( - truth_node_id=charter.id, - project_id=pid, - title=task_item["title"], - required_skill_tag_ids=[], - status="todo", - priority=task_item["priority"], - estimated_hours=task_item["hours"], - ) - session.add(task) + changed = True print(f"[SSTC] 已为「{PIXELLE_NAME}」恢复宪章 1 个、任务 3 个") + return changed async def _seed_projects(session: AsyncSession): - import json - - result = await session.execute( - select(Project).where( - or_(Project.title == PIXELLE_NAME, Project.name == PIXELLE_NAME) - ) - ) + result = await session.execute(select(Project).where(or_(Project.title == PIXELLE_NAME, Project.name == PIXELLE_NAME))) existing = result.scalar_one_or_none() if existing: restored = await _ensure_pixelle_sstc_data(session, existing) @@ -193,61 +109,14 @@ async def _seed_projects(session: AsyncSession): print("示例项目已存在且 SSTC 数据完整,跳过") return - pixelle_signals = [ - Signal( - source="HackerNews", - content="Pixelle-Video: An AI-powered automated short video generation platform with script-to-video automation", - impact_score=9.2, - priority="critical", - processed=True, - ), - Signal( - source="ProductHunt", - content="Pixelle-Video launches API for programmatic video content generation", - impact_score=8.5, - priority="high", - processed=True, - ), - Signal( - source="TechCrunch", - content="Pixelle-Video raises Series A funding to scale AI video infrastructure", - impact_score=7.8, - priority="high", - processed=True, - ), - ] - + pixelle_signals = build_pixelle_signals() for signal in pixelle_signals: - signal.strategic_analysis = json.dumps({ - "decision_proposal": { - "is_strategic": True, - "threat_level": "HIGH", - "lead_time_days": 14, - "impact_score": signal.impact_score, - "recommendation": "立即集成 Pixelle-Video API 到主平台" - }, - "competitiveness_radar": { - "innovation": 85, - "market_potential": 90, - "technical_complexity": 75, - "resource_intensity": 70 - }, - "detected_tech_stacks": ["AI/ML", "Video Processing", "REST API"], - "related_knowledge_nodes": [], - "rag_context_used": 0 - }) - signal.tasks_generated = json.dumps([ - {"task_name": "Integrate Pixelle-Video API", "type": "backend", "estimated_hours": 8.0}, - {"task_name": "Design Video Workflow UI", "type": "frontend", "estimated_hours": 12.0}, - {"task_name": "Optimize Video Pipeline", "type": "ai_engineer", "estimated_hours": 16.0}, - ]) session.add(signal) - await session.flush() pixelle_project = Project( - name="Pixelle-Video 集成", - title="Pixelle-Video 集成", + name=PIXELLE_NAME, + title=PIXELLE_NAME, description="集成 Pixelle-Video AI 视频生成平台到主系统", status="active", assigned_role="backend", @@ -257,13 +126,14 @@ async def _seed_projects(session: AsyncSession): await session.flush() for signal in pixelle_signals: - link = ProjectSignalLink( - project_id=pixelle_project.id, - signal_id=signal.id, - link_type="related", - relevance_score=1.0, + session.add( + ProjectSignalLink( + project_id=pixelle_project.id, + signal_id=signal.id, + link_type="related", + relevance_score=1.0, + ) ) - session.add(link) ai_analyst_project = Project( name="AI 分析引擎优化", @@ -276,94 +146,34 @@ async def _seed_projects(session: AsyncSession): session.add(ai_analyst_project) await session.flush() - ai_signal = Signal( - source="ArXiv", - content="New paper: Multi-Agent LLM Voting System improves accuracy by 23%", - impact_score=7.5, - priority="medium", - processed=True, - ) - ai_signal.strategic_analysis = json.dumps({ - "decision_proposal": { - "is_strategic": True, - "threat_level": "MEDIUM", - "lead_time_days": 30, - "impact_score": 7.5, - "recommendation": "实现多模型投票机制提升 AI 分析准确性" - }, - "detected_tech_stacks": ["LLM", "Voting", "Multi-Agent"] - }) + ai_signal = build_ai_signal() session.add(ai_signal) await session.flush() - ai_link = ProjectSignalLink( - project_id=ai_analyst_project.id, - signal_id=ai_signal.id, - link_type="related", - relevance_score=0.9, - ) - session.add(ai_link) - - await session.flush() - - # --- SSTC: 获取或创建默认用户,用于宪章 created_by --- - user_result = await session.execute(select(User).limit(1)) - seed_user = user_result.scalar_one_or_none() - if not seed_user: - seed_user = User( - username="boss", - email="boss@pmflow.local", - password_hash=AuthService.hash_password("boss123"), - name="负责人", - role="BOSS", - status="active", + session.add( + ProjectSignalLink( + project_id=ai_analyst_project.id, + signal_id=ai_signal.id, + link_type="related", + relevance_score=0.9, ) - session.add(seed_user) - await session.flush() + ) - # --- SSTC: 为 Pixelle-Video 创建宪章(关联信号)--- - pixelle_signal_ids = [s.id for s in pixelle_signals] - charter = TruthNode( - node_type="charter", - parent_id=None, + seed_user = await ensure_seed_user(session) + await create_pixelle_charter_and_tasks( + session, project_id=pixelle_project.id, created_by=seed_user.id, - title="Pixelle-Video 集成需求宪章", - content={ - "rationale": "基于 HackerNews/ProductHunt/TechCrunch 信号,决定集成 Pixelle-Video API 到主平台", - "signal_ids": pixelle_signal_ids, - "decision": "立即集成 Pixelle-Video API,支持脚本到视频的自动化生成", - }, - version=1, - status="aligned", + signal_ids=pick_signal_ids(pixelle_signals), ) - session.add(charter) - await session.flush() - - # --- SSTC: 创建任务(绑定到宪章)--- - for task_item in [ - {"title": "Integrate Pixelle-Video API", "priority": "high", "hours": 8.0}, - {"title": "Design Video Workflow UI", "priority": "medium", "hours": 12.0}, - {"title": "Optimize Video Pipeline", "priority": "medium", "hours": 16.0}, - ]: - task = Task( - truth_node_id=charter.id, - project_id=pixelle_project.id, - title=task_item["title"], - required_skill_tag_ids=[], - status="todo", - priority=task_item["priority"], - estimated_hours=task_item["hours"], - ) - session.add(task) await session.commit() - print(f"创建示例项目:") + print("创建示例项目:") print(f" - {pixelle_project.title} ({pixelle_project.assigned_role})") print(f" - {ai_analyst_project.title} ({ai_analyst_project.assigned_role})") print(f"\n关联信号: {len(pixelle_signals) + 1} 条") - print(f"[SSTC] Pixelle-Video 宪章 1 个,任务 3 个") + print("[SSTC] Pixelle-Video 宪章 1 个,任务 3 个") async def main(): diff --git a/backend/app/seed_projects_helpers.py b/backend/app/seed_projects_helpers.py new file mode 100644 index 00000000..cc0f25be --- /dev/null +++ b/backend/app/seed_projects_helpers.py @@ -0,0 +1,172 @@ +"""Helpers for seed project creation and SSTC recovery.""" + +from __future__ import annotations + +import json +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import Signal, Task, TruthNode, User +from app.services.auth import AuthService + +PIXELLE_SIGNAL_CONTENTS = [ + ( + "HackerNews", + "Pixelle-Video: An AI-powered automated short video generation platform with script-to-video automation", + 9.2, + "critical", + ), + ( + "ProductHunt", + "Pixelle-Video launches API for programmatic video content generation", + 8.5, + "high", + ), + ( + "TechCrunch", + "Pixelle-Video raises Series A funding to scale AI video infrastructure", + 7.8, + "high", + ), +] + +PIXELLE_TASK_BLUEPRINT = [ + {"title": "Integrate Pixelle-Video API", "priority": "high", "hours": 8.0}, + {"title": "Design Video Workflow UI", "priority": "medium", "hours": 12.0}, + {"title": "Optimize Video Pipeline", "priority": "medium", "hours": 16.0}, +] + + +def _build_signal_payload(impact_score: float) -> tuple[str, str]: + strategic_analysis = json.dumps( + { + "decision_proposal": { + "is_strategic": True, + "threat_level": "HIGH", + "lead_time_days": 14, + "impact_score": impact_score, + "recommendation": "立即集成 Pixelle-Video API 到主平台", + }, + "competitiveness_radar": { + "innovation": 85, + "market_potential": 90, + "technical_complexity": 75, + "resource_intensity": 70, + }, + "detected_tech_stacks": ["AI/ML", "Video Processing", "REST API"], + "related_knowledge_nodes": [], + "rag_context_used": 0, + } + ) + tasks_generated = json.dumps( + [ + {"task_name": "Integrate Pixelle-Video API", "type": "backend", "estimated_hours": 8.0}, + {"task_name": "Design Video Workflow UI", "type": "frontend", "estimated_hours": 12.0}, + {"task_name": "Optimize Video Pipeline", "type": "ai_engineer", "estimated_hours": 16.0}, + ] + ) + return strategic_analysis, tasks_generated + + +def build_pixelle_signals() -> list[Signal]: + signals: list[Signal] = [] + for source, content, impact_score, priority in PIXELLE_SIGNAL_CONTENTS: + signal = Signal( + source=source, + content=content, + impact_score=impact_score, + priority=priority, + processed=True, + ) + strategic_analysis, tasks_generated = _build_signal_payload(impact_score) + signal.strategic_analysis = strategic_analysis + signal.tasks_generated = tasks_generated + signals.append(signal) + return signals + + +async def ensure_seed_user(session: AsyncSession) -> User: + user_result = await session.execute(select(User).limit(1)) + seed_user = user_result.scalar_one_or_none() + if seed_user: + return seed_user + + seed_user = User( + username="boss", + email="boss@pmflow.local", + password_hash=AuthService.hash_password("boss123"), + name="负责人", + role="BOSS", + status="active", + ) + session.add(seed_user) + await session.flush() + return seed_user + + +async def create_pixelle_charter_and_tasks( + session: AsyncSession, + *, + project_id: int, + created_by: int, + signal_ids: list[int], +) -> TruthNode: + charter = TruthNode( + node_type="charter", + parent_id=None, + project_id=project_id, + created_by=created_by, + title="Pixelle-Video 集成需求宪章", + content={ + "rationale": "基于 HackerNews/ProductHunt/TechCrunch 信号,决定集成 Pixelle-Video API 到主平台", + "signal_ids": signal_ids, + "decision": "立即集成 Pixelle-Video API,支持脚本到视频的自动化生成", + }, + version=1, + status="aligned", + ) + session.add(charter) + await session.flush() + + for task_item in PIXELLE_TASK_BLUEPRINT: + task = Task( + truth_node_id=charter.id, + project_id=project_id, + title=task_item["title"], + required_skill_tag_ids=[], + status="todo", + priority=task_item["priority"], + estimated_hours=task_item["hours"], + ) + session.add(task) + + return charter + + +def build_ai_signal() -> Signal: + signal = Signal( + source="ArXiv", + content="New paper: Multi-Agent LLM Voting System improves accuracy by 23%", + impact_score=7.5, + priority="medium", + processed=True, + ) + signal.strategic_analysis = json.dumps( + { + "decision_proposal": { + "is_strategic": True, + "threat_level": "MEDIUM", + "lead_time_days": 30, + "impact_score": 7.5, + "recommendation": "实现多模型投票机制提升 AI 分析准确性", + }, + "detected_tech_stacks": ["LLM", "Voting", "Multi-Agent"], + } + ) + return signal + + +def pick_signal_ids(signals: Optional[list[Signal]]) -> list[int]: + return [signal.id for signal in (signals or []) if signal.id is not None] diff --git a/backend/app/services/adaptive_rag_service.py b/backend/app/services/adaptive_rag_service.py index 1db7df50..9bc8a5c9 100644 --- a/backend/app/services/adaptive_rag_service.py +++ b/backend/app/services/adaptive_rag_service.py @@ -1,6737 +1,5 @@ -"""全自适应 RAG 服务。 - -能力: -1. 意图识别(question / statement / mixed) -2. 多域路由(结构化 DB + 向量检索) -3. 回答生成与来源引用 -4. QA 记录、反馈、JSONL 导出 -5. 知识库文档/URL/飞书消息入库向量化 -""" - -from __future__ import annotations - -import ast -import asyncio -import hashlib -import json -import math -import os -from pathlib import Path -import re -import time -from dataclasses import dataclass -from datetime import datetime, timezone -from typing import Any, Optional - -import httpx -from sqlalchemy import and_, bindparam, delete, func, or_, select, text -from sqlalchemy.ext.asyncio import AsyncSession - -from ..config.ai_prompts import PROMPT_RAG_ANSWER, PROMPT_RAG_DB_PROJECT_QA, PROMPT_RAG_INTENT_CLASSIFICATION -from ..database import AsyncSessionLocal -from ..models import ( - KnowledgeBaseChunk, - KnowledgeBaseDoc, - KGEvidence, - KGEntity, - KGRelation, - Project, - ProjectSignalLink, - QAFeedback, - QARecord, - QASession, - QASessionState, - Signal, - Task, - TruthNode, - User, - UserProject, - UserWorkload, -) -from .context_cache import context_cache_service -from .llm import LLMService -from .signal_source_orchestration_service import SignalSourceOrchestrationService -from .symbol_index_service import SymbolIndexService - - -from ..utils.timezone import CN_TZ, now_cn_aware, now_cn_naive -def _env_int(name: str, default: int, *, minimum: int, maximum: int) -> int: - raw = str(os.getenv(name, "") or "").strip() - if not raw: - value = int(default) - else: - try: - value = int(raw) - except Exception: - value = int(default) - return max(minimum, min(maximum, value)) - -QUESTION_HINTS = ( - "吗", - "么", - "呢", - "呀", - "啊", - "?", - "?", - "如何", - "怎么", - "为什么", - "为何", - "谁", - "哪里", - "有没有", - "有无", - "有吗", - "是否", - "是否有", - "是否存在", - "哪些", - "哪个", - "什么", - "是什么", - "多少", - "几", -) -PROJECT_EXISTENCE_RE = re.compile(r"(有没有|是否有|有).*?(项目|系统)|项目.*?(有没有|是否有|有).*(吗|\?)") -PROJECT_PROGRESS_RE = re.compile(r"(进度|阶段|完成度|完成率|推进|里程碑|状态)") -QUESTION_NOISE_RE = re.compile( - r"(目前|当前|现在|请问|一下|系统中|项目中|系统内|项目内|是否|有没有|有无|有|吗|呢|呀|啊|什么|哪个|多少|查询|看看)" -) -STATEMENT_HINTS = ( - "需要", - "请", - "新增", - "优化", - "修复", - "安排", - "立项", - "开发", - "上线", -) -PROJECT_KEYWORDS = ( - "项目", - "信号", - "任务", - "宪章", - "进度", - "状态", - "工作量", - "负责人", -) -PEOPLE_KEYWORDS = ( - "成员", - "人员", - "同事", - "负责人", - "owner", - "assignee", - "谁负责", - "谁在做", - "名单", -) -DOC_KEYWORDS = ( - "文档", - "规范", - "spec", - "说明", - "流程", - "模板", - "手册", - "how to", - "怎么做", -) -FEATURE_QUERY_KEYWORDS = ( - "功能", - "用途", - "是干嘛", - "做什么", - "有什么用", - "页面", - "菜单", - "入口", - "在哪", - "ai配置", - "智能配置", - "配置中心", - "能力管理", - "用户管理", - "项目列表", - "团队负载", - "ai管家", -) -FEATURE_PURPOSE_HINTS = ( - "用途", - "作用", - "干嘛", - "做什么", - "是什么", - "入口", - "在哪", - "页面", -) -CORRECTION_CUE_KEYWORDS = ( - "我说的是", - "我说的不是", - "不是这个", - "不是系统架构", - "不是啊", - "不对", - "错了", - "答错", - "跑偏", - "偏了", - "不是这个意思", - "你理解错", - "你答偏了", - "答非所问", - "请纠正", - "纠正一下", - "请更正", - "上一条", - "上一个回答", - "不要模板", - "模板化", - "我要的是", - "我问的是", - "不是在问", -) -ENGINE_PROCESS_KEYWORDS = ( - "ai引擎", - "引擎驱动", - "驱动方式", - "调用链", - "调用流程", - "调用路径", - "模型路由", - "模型调用", - "路由策略", - "openclaw", - "网关", - "ai架构", - "ai 管家", - "ai管家", - "第一层", - "第二层", - "第三层", - "fallback", - "兜底", -) -DEEP_EXPLICIT_KEYWORDS = ( - "深入", - "深度", - "详细分析", - "证据链", - "调用链", - "逐层", - "逐步", - "详细展开", - "deep", -) -BRIEF_NEGATIVE_FEEDBACK_RE = re.compile( - r"^(不是啊?|不对|错了|答错了?|跑偏了?|偏了|不是这个(意思)?|不(是|对)啊)$" -) -HISTORY_KEYWORDS = ("之前", "上次", "历史", "曾经", "之前说", "last time") -PMFLOW_KEYWORDS = ("复盘", "charter", "宪章", "task", "任务描述") -FOLLOWUP_REFERENTIAL_KEYWORDS = ( - "这些", - "它们", - "这个", - "那个", - "上面", - "前面", - "刚才", - "分别", - "具体", - "都有哪些", - "分别是什么", -) -FOLLOWUP_ENUMERATION_KEYWORDS = ( - "分别是什么", - "分别是啥", - "都有哪些", - "具体有哪些", - "列出来", - "清单", - "名单", -) -IDENTITY_KEYWORDS = ( - "你是谁", - "你是", - "自我介绍", - "介绍你自己", - "你能做什么", - "你会什么", - "你负责什么", - "你叫什么", -) -ARCHITECTURE_KEYWORDS = ( - "系统架构", - "架构", - "运行机制", - "技术栈", - "模块", - "服务组成", - "docker", - "数据库", - "redis", - "后端", - "前端", -) -CODE_KEYWORDS = ( - "代码", - "函数", - "方法", - "类", - "模块", - "文件", - "api", - "接口实现", - "service", - "model", - "schema", - "where defined", -) -URL_RE = re.compile(r"https?://\S+", flags=re.IGNORECASE) -SENTENCE_SPLIT_RE = re.compile(r"(?<=[。!?!?\n])") -PAGE_MARKER_RE = re.compile(r"\[\[PAGE:(\d{1,6})\]\]") - -MAX_CONTEXT_TOKENS = 8000 -DEFAULT_TOP_K = 5 -DB_QUERY_TIMEOUT_SECONDS = 5.0 -VECTOR_SEARCH_TIMEOUT_SECONDS = 10.0 -GRAPH_QUERY_TIMEOUT_SECONDS = 4.0 -AGENT_MAX_RETRIEVAL_STEPS = 3 -SESSION_GAP_MINUTES = 30 -SESSION_DEFAULT_TITLE = "新会话" -SESSION_TITLE_MIN_LEN = 5 - -THINKING_MODE_NORMAL = "normal" -THINKING_MODE_DEEP = "deep" -DEEP_RETRIEVAL_STEPS = _env_int("RAG_DEEP_RETRIEVAL_STEPS", 5, minimum=2, maximum=8) -DEEP_VECTOR_TOP_K = _env_int("RAG_DEEP_VECTOR_TOP_K", 12, minimum=6, maximum=40) -DEEP_CONTEXT_BLOCKS = _env_int("RAG_DEEP_CONTEXT_BLOCKS", 16, minimum=8, maximum=32) -DEEP_ANSWER_MAX_TOKENS = _env_int("RAG_DEEP_ANSWER_MAX_TOKENS", 1400, minimum=900, maximum=3000) - -QUERY_TYPE_COUNT = "count" -QUERY_TYPE_LIST = "list" -QUERY_TYPE_STATUS = "status" -QUERY_TYPE_EXPLAIN = "explain" -QUERY_TYPE_UNKNOWN = "unknown" - -ACTION_MODE_AUTO = "auto" -ACTION_MODE_READ = "read" -ACTION_MODE_WRITE = "write" -ACTION_MODE_READ_WRITE = "read_write" - -REQUIREMENT_WRITE_VERB_KEYWORDS = ( - "新增", - "增加", - "添加", - "接入", - "支持", - "实现", - "创建", - "新建", - "记录", - "入库", - "同步", - "补充", -) -REQUIREMENT_WRITE_OBJECT_KEYWORDS = ( - "信号", - "需求", - "任务", - "项目", - "功能", - "接口", - "页面", - "按钮", - "规则", - "模块", - "卡路里", - "食谱", -) -WRITE_EXPRESSIONS = ( - "给", - "帮我", - "请帮", - "请给", - "麻烦", -) - -COUNT_HINTS = ("多少", "几个", "数量", "总数", "count") -LIST_HINTS = ("哪些", "列表", "清单", "列出", "分别", "名单", "都有哪些") -STATUS_HINTS = ("进度", "状态", "阶段", "完成率", "完成度", "情况") - -ENTITY_ALIAS_MAP: dict[str, str] = { - "能力": "skill", - "技能": "skill", - "ability": "skill", - "abilities": "skill", - "skill": "skill", - "skills": "skill", - "负责人": "owner", - "owner": "owner", - "assignee": "owner", - "成员": "member", - "人员": "member", - "member": "member", - "任务": "task", - "task": "task", - "tasks": "task", - "项目": "project", - "project": "project", - "projects": "project", - "信号": "signal", - "signal": "signal", - "signals": "signal", - "函数": "function", - "function": "function", - "method": "function", - "类": "class", - "class": "class", - "模块": "module", - "module": "module", - "智能配置": "ai_config", - "ai配置": "ai_config", - "配置中心": "ai_config", - "能力管理": "skills", - "技能管理": "skills", - "用户管理": "users", - "项目列表": "projects", - "团队负载": "workloads", - "ai管家": "ai_steward", -} -TS_SYMBOL_RE = re.compile( - r"(?:export\s+)?(?:async\s+)?(?:function|class|interface|type|const)\s+([A-Za-z_][A-Za-z0-9_]*)" -) - - -@dataclass(slots=True) -class RetrievedItem: - source: str - content: str - score: float - source_label: str - metadata: dict[str, Any] - - -class AdaptiveRAGService: - """项目内统一 RAG 服务。""" - - _symbol_snapshot_cache: dict[str, tuple[float, dict[str, Any]]] = {} - - def __init__(self, db: AsyncSession, *, tenant_id: int) -> None: - self.db = db - self.tenant_id = int(tenant_id) - self.llm = LLMService() - self.context_cache = context_cache_service - - @staticmethod - def _normalize_thinking_mode(value: Optional[str]) -> str: - text = str(value or "").strip().lower() - if text == THINKING_MODE_DEEP: - return THINKING_MODE_DEEP - return THINKING_MODE_NORMAL - - @staticmethod - def _normalize_conversation_key(value: Optional[str]) -> str: - raw = str(value or "").strip() - if not raw: - return "" - cleaned = re.sub(r"\s+", "", raw.lower()) - cleaned = re.sub(r"[^a-z0-9:_\-\.]", "", cleaned) - return cleaned[:160] - - def _build_external_conversation_key( - self, - *, - asked_via: str, - source_chat_id: Optional[str], - conversation_key: Optional[str], - ) -> str: - explicit = self._normalize_conversation_key(conversation_key) - if explicit: - return explicit - channel = str(asked_via or "").strip().lower() - if channel == "feishu": - chat = str(source_chat_id or "").strip() - if chat: - return self._normalize_conversation_key(f"feishu:{chat}") - return "" - - @staticmethod - def _external_session_id_from_key(conversation_key: str) -> Optional[int]: - key = str(conversation_key or "").strip() - if not key: - return None - digest = hashlib.sha1(key.encode("utf-8")).hexdigest() - bucket = int(digest[:10], 16) % 500_000_000 - return 1_500_000_000 + bucket - - # ------------------------------------------------------------------ - # Intent - # ------------------------------------------------------------------ - async def classify_intent(self, message: str) -> dict[str, Any]: - text = str(message or "").strip() - if not text: - return { - "intent": "statement", - "confidence": 1.0, - "question_part": "", - "statement_part": "", - } - - heuristic = self._classify_intent_rule(text) - if heuristic["confidence"] >= 0.86: - return heuristic - - prompt = PROMPT_RAG_INTENT_CLASSIFICATION.format(message=text[:1200]).strip() - try: - raw = await self.llm.generate( - prompt, - db=self.db, - tenant_id=self.tenant_id, - function_name="rag_intent_classification", - temperature=0.0, - max_tokens=220, - use_prompt_profile=False, - ) - parsed = self._parse_json_object(raw) - intent = str(parsed.get("intent") or heuristic["intent"]).strip().lower() - if intent not in {"question", "statement", "mixed"}: - intent = heuristic["intent"] - confidence = self._to_float(parsed.get("confidence"), heuristic["confidence"]) - question_part = str(parsed.get("question_part") or "").strip() - statement_part = str(parsed.get("statement_part") or "").strip() - if not question_part and intent in {"question", "mixed"}: - question_part = heuristic["question_part"] - if not statement_part and intent in {"statement", "mixed"}: - statement_part = heuristic["statement_part"] - return { - "intent": intent, - "confidence": max(0.0, min(1.0, confidence)), - "question_part": question_part, - "statement_part": statement_part, - } - except Exception: - return heuristic - - def _classify_intent_rule(self, text: str) -> dict[str, Any]: - question_score = 0.0 - statement_score = 0.0 - lowered = text.lower() - - if "?" in text or "?" in text: - question_score += 0.35 - if lowered.endswith(("吗", "么", "呢", "呀", "啊")): - question_score += 0.24 - for hint in QUESTION_HINTS: - if hint in lowered: - question_score += 0.08 - if PROJECT_EXISTENCE_RE.search(text): - question_score += 0.26 - if re.search(r"(现在|当前|目前|请问).*(项目|任务|负责人|状态|进度|列表|清单)", text): - question_score += 0.18 - for hint in STATEMENT_HINTS: - if hint in lowered: - statement_score += 0.10 - - sentences = [s.strip() for s in SENTENCE_SPLIT_RE.split(text) if s.strip()] - question_sentences = [s for s in sentences if "?" in s or "?" in s or any(k in s.lower() for k in QUESTION_HINTS)] - statement_sentences = [s for s in sentences if s not in question_sentences] - - if question_sentences and statement_sentences: - return { - "intent": "mixed", - "confidence": 0.88, - "question_part": " ".join(question_sentences)[:1000], - "statement_part": " ".join(statement_sentences)[:1000], - } - - if question_score >= 0.28 and question_score >= statement_score: - return { - "intent": "question", - "confidence": min(0.9, 0.62 + question_score), - "question_part": text[:1000], - "statement_part": "", - } - - return { - "intent": "statement", - "confidence": min(0.9, 0.66 + statement_score), - "question_part": "", - "statement_part": text[:1000], - } - - # ------------------------------------------------------------------ - # Public ask flow - # ------------------------------------------------------------------ - async def answer_question( - self, - *, - question: str, - asked_by: Optional[int], - asked_via: str, - project_id: Optional[int] = None, - source_chat_id: Optional[str] = None, - session_id: Optional[int] = None, - conversation_key: Optional[str] = None, - force_new_session: bool = False, - thinking_mode: str = THINKING_MODE_NORMAL, - action_mode: str = ACTION_MODE_AUTO, - sync_requirement: bool = True, - ) -> dict[str, Any]: - started = time.perf_counter() - clean_q = str(question or "").strip() - external_conversation_key = self._build_external_conversation_key( - asked_via=asked_via, - source_chat_id=source_chat_id, - conversation_key=conversation_key, - ) - active_thinking_mode = self._normalize_thinking_mode(thinking_mode) - deep_thinking = active_thinking_mode == THINKING_MODE_DEEP - asked_via_lower = str(asked_via or "").strip().lower() - deep_retrieval_steps = DEEP_RETRIEVAL_STEPS - deep_vector_top_k = DEEP_VECTOR_TOP_K - if asked_via_lower == "feishu": - # 飞书链路优先低时延,保留 deep 能力但收紧预算。 - deep_retrieval_steps = min(DEEP_RETRIEVAL_STEPS, 3) - deep_vector_top_k = min(DEEP_VECTOR_TOP_K, 8) - if not clean_q: - return self._build_response_payload( - answer="问题为空,无法检索。", - sources=[], - domains_used=[], - response_time_ms=0, - db_query_ms=0, - vector_search_ms=0, - db_timeout=False, - vector_timeout=False, - qa_id=None, - session_id=None, - turn_index=1, - relevance_max=0.0, - query_type=QUERY_TYPE_UNKNOWN, - confidence=0.0, - missing_fields=["question"], - thinking_mode=active_thinking_mode, - ) - - session_id, turn_index = await self._touch_session_for_new_record( - asked_by=asked_by, - asked_via=asked_via, - project_id=project_id, - question=clean_q, - preferred_session_id=session_id, - force_new_session=bool(force_new_session), - ) - context_session_id: Optional[int] - if session_id is not None: - context_session_id = int(session_id) - elif asked_by is None: - context_session_id = self._external_session_id_from_key(external_conversation_key) - else: - context_session_id = None - conversation = await self._load_recent_conversation( - asked_by=asked_by, - asked_via=asked_via, - project_id=project_id, - session_id=session_id, - external_session_id=context_session_id, - limit=10, - ) - state_row = await self._ensure_session_state(session_id=session_id) - effective_q, rewrite_meta = self._rewrite_question_with_state( - question=clean_q, - conversation=conversation, - session_state=state_row, - ) - query_type = self._classify_query_type(effective_q) - requested_action_mode = self._normalize_action_mode(action_mode) - effective_action_mode = self._resolve_action_mode( - requested_mode=requested_action_mode, - question=clean_q, - effective_question=effective_q, - ) - if ( - bool(sync_requirement) - and asked_via_lower != "feishu" - and effective_action_mode in {ACTION_MODE_WRITE, ACTION_MODE_READ_WRITE} - ): - ack_result = await self._handle_ack_first_requirement( - question=clean_q, - effective_question=effective_q, - asked_by=asked_by, - asked_via=asked_via, - project_id=project_id, - session_id=session_id, - turn_index=turn_index, - state_row=state_row, - rewrite_meta=rewrite_meta, - context_session_id=context_session_id, - query_type=query_type, - action_mode=effective_action_mode, - started_at=started, - ) - if ack_result is not None: - return ack_result - forced_deep_reason = "" - if not deep_thinking: - forced_deep_reason = self._forced_deep_reason( - question=clean_q, - effective_question=effective_q, - conversation=conversation, - ) - if forced_deep_reason and self._should_force_deep_for_channel( - asked_via=asked_via, - question=clean_q, - effective_question=effective_q, - reason=forced_deep_reason, - ): - deep_thinking = True - active_thinking_mode = THINKING_MODE_DEEP - else: - forced_deep_reason = "" - - clarification_answer = self._build_clarification_question( - original_question=clean_q, - effective_question=effective_q, - session_state=state_row, - ) - if clarification_answer: - elapsed = int((time.perf_counter() - started) * 1000) - clarify_sources = [ - { - "source": "system", - "label": "Session State", - "score": 0.95, - "metadata": {"mode": "clarification", "reason": "missing_subject"}, - } - ] - qa = QARecord( - tenant_id=self.tenant_id, - question=clean_q, - answer=clarification_answer, - sources=clarify_sources, - intent="question", - domains_used=["system_context"], - response_time_ms=elapsed, - asked_by=asked_by, - asked_via=(asked_via or "pmflow")[:20], - project_id=project_id, - session_id=session_id, - turn_index=turn_index, - ) - self.db.add(qa) - await self._update_session_state_after_turn( - session_state=state_row, - question=clean_q, - effective_question=effective_q, - answer=clarification_answer, - query_type=query_type, - ambiguity="needs_clarification", - rewrite_meta=rewrite_meta, - sources=clarify_sources, - ) - await self.db.commit() - await self.db.refresh(qa) - await self.context_cache.append_session_messages( - tenant_id=self.tenant_id, - session_id=context_session_id, - question=clean_q, - answer=clarification_answer, - ) - return self._build_response_payload( - answer=clarification_answer, - sources=clarify_sources, - domains_used=["system_context"], - response_time_ms=elapsed, - db_query_ms=0, - vector_search_ms=0, - db_timeout=False, - vector_timeout=False, - qa_id=int(qa.id), - session_id=int(session_id) if session_id is not None else None, - turn_index=int(turn_index), - relevance_max=0.95, - query_type=query_type, - confidence=0.92, - missing_fields=["subject"], - thinking_mode=active_thinking_mode, - ) - - system_snapshot = await self.context_cache.get_system_snapshot( - db=self.db, - tenant_id=self.tenant_id, - ) - user_snapshot = await self.context_cache.get_user_snapshot( - db=self.db, - tenant_id=self.tenant_id, - user_id=asked_by, - ) - - if not deep_thinking: - deterministic_result = await self._run_deterministic_query( - question=effective_q, - project_id=project_id, - query_type=query_type, - ) - if deterministic_result is not None: - return await self._persist_fast_answer( - question=clean_q, - asked_by=asked_by, - asked_via=asked_via, - project_id=project_id, - fast_result=deterministic_result, - session_id=session_id, - external_session_id=context_session_id, - turn_index=turn_index, - query_type=str(deterministic_result.get("query_type") or query_type), - missing_fields=list(deterministic_result.get("missing_fields") or []), - state_row=state_row, - effective_question=effective_q, - rewrite_meta=rewrite_meta, - thinking_mode=active_thinking_mode, - ) - - direct_answer = None - if not deep_thinking: - direct_answer = self._build_snapshot_direct_answer( - effective_q, - system_snapshot=system_snapshot, - user_snapshot=user_snapshot, - ) - if direct_answer: - elapsed = int((time.perf_counter() - started) * 1000) - direct_sources = [ - { - "source": "system", - "label": "System Snapshot", - "score": 0.99, - "metadata": {"mode": "snapshot_direct", "topic": "identity_or_architecture"}, - } - ] - if user_snapshot: - direct_sources.append( - { - "source": "system", - "label": "User Snapshot", - "score": 0.98, - "metadata": {"mode": "snapshot_direct", "topic": "current_user"}, - } - ) - - qa = QARecord( - tenant_id=self.tenant_id, - question=clean_q, - answer=direct_answer, - sources=direct_sources, - intent="question", - domains_used=["system_context"], - response_time_ms=elapsed, - asked_by=asked_by, - asked_via=(asked_via or "pmflow")[:20], - project_id=project_id, - session_id=session_id, - turn_index=turn_index, - ) - self.db.add(qa) - await self._update_session_state_after_turn( - session_state=state_row, - question=clean_q, - effective_question=effective_q, - answer=direct_answer, - query_type=QUERY_TYPE_EXPLAIN, - ambiguity="clear", - rewrite_meta=rewrite_meta, - sources=direct_sources, - ) - await self.db.commit() - await self.db.refresh(qa) - await self.context_cache.append_session_messages( - tenant_id=self.tenant_id, - session_id=context_session_id, - question=clean_q, - answer=direct_answer, - ) - return self._build_response_payload( - answer=direct_answer, - sources=direct_sources, - domains_used=["system_context"], - response_time_ms=elapsed, - db_query_ms=0, - vector_search_ms=0, - db_timeout=False, - vector_timeout=False, - qa_id=int(qa.id), - session_id=int(session_id) if session_id is not None else None, - turn_index=int(turn_index), - relevance_max=1.0, - query_type=QUERY_TYPE_EXPLAIN, - confidence=0.99, - missing_fields=[], - thinking_mode=active_thinking_mode, - ) - - is_snapshot_direct_question = self._is_snapshot_direct_question(effective_q) - if not deep_thinking and not is_snapshot_direct_question: - glm_semantic_result = await self._try_glm_project_semantic_answer(clean_q=effective_q, project_id=project_id) - if glm_semantic_result is not None: - return await self._persist_fast_answer( - question=clean_q, - asked_by=asked_by, - asked_via=asked_via, - project_id=project_id, - fast_result=glm_semantic_result, - session_id=session_id, - external_session_id=context_session_id, - turn_index=turn_index, - query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_EXPLAIN, - missing_fields=[], - state_row=state_row, - effective_question=effective_q, - rewrite_meta=rewrite_meta, - thinking_mode=active_thinking_mode, - ) - - domains = self._route_domains(effective_q) - if str(asked_via or "").strip().lower() == "feishu" and source_chat_id and "feishu" not in domains: - domains.append("feishu") - retrieved, search_meta = await self._multi_step_search( - question=effective_q, - domains=domains, - project_id=project_id, - source_chat_id=source_chat_id, - session_state=state_row, - max_steps_override=deep_retrieval_steps if deep_thinking else None, - vector_top_k=deep_vector_top_k if deep_thinking else None, - ) - db_query_ms = int(search_meta.get("db_query_ms") or 0) - vector_search_ms = int(search_meta.get("vector_search_ms") or 0) - graph_query_ms = int(search_meta.get("graph_query_ms") or 0) - db_timeout = bool(search_meta.get("db_timeout")) - vector_timeout = bool(search_meta.get("vector_timeout")) - graph_timeout = bool(search_meta.get("graph_timeout")) - tool_trace = list(search_meta.get("tool_trace") or []) - tool_trace.insert( - 0, - { - "step": 0, - "tool": "thinking_mode", - "mode": active_thinking_mode, - "fast_path_bypassed": bool(deep_thinking), - "reason": forced_deep_reason or ("manual_deep" if self._normalize_thinking_mode(thinking_mode) == THINKING_MODE_DEEP else "normal"), - }, - ) - final_domains = [str(x) for x in (search_meta.get("domains_used") or domains)] - - max_score = retrieved[0].score if retrieved else 0.0 - answer_text = await self._generate_answer( - effective_q, - retrieved, - conversation, - project_id, - system_snapshot=system_snapshot, - user_snapshot=user_snapshot, - relevance_max=max_score, - thinking_mode=active_thinking_mode, - context_block_limit=DEEP_CONTEXT_BLOCKS if deep_thinking else None, - answer_max_tokens=DEEP_ANSWER_MAX_TOKENS if deep_thinking else None, - ) - used_sources = self._dedupe_sources(retrieved) - if not used_sources: - used_sources = [ - { - "source": "system", - "label": "System Snapshot", - "score": round(max(max_score, 0.6), 4), - "metadata": {"mode": "snapshot_only", "relevance_max": round(max_score, 4)}, - } - ] - if user_snapshot: - used_sources.append( - { - "source": "system", - "label": "User Snapshot", - "score": round(max(max_score, 0.58), 4), - "metadata": {"mode": "snapshot_only", "relevance_max": round(max_score, 4)}, - } - ) - - auto_retry_reason = "" - if not deep_thinking: - auto_retry_reason = self._should_auto_deep_retry( - question=clean_q, - effective_question=effective_q, - sources=used_sources, - relevance_max=max_score, - ) - if auto_retry_reason and not self._allow_auto_deep_retry_for_channel( - asked_via=asked_via, - question=clean_q, - effective_question=effective_q, - reason=auto_retry_reason, - ): - auto_retry_reason = "" - if auto_retry_reason: - deep_retrieved, deep_meta = await self._multi_step_search( - question=effective_q, - domains=domains, - project_id=project_id, - source_chat_id=source_chat_id, - session_state=state_row, - max_steps_override=deep_retrieval_steps, - vector_top_k=deep_vector_top_k, - ) - deep_max_score = deep_retrieved[0].score if deep_retrieved else 0.0 - deep_answer_text = await self._generate_answer( - effective_q, - deep_retrieved, - conversation, - project_id, - system_snapshot=system_snapshot, - user_snapshot=user_snapshot, - relevance_max=deep_max_score, - thinking_mode=THINKING_MODE_DEEP, - context_block_limit=DEEP_CONTEXT_BLOCKS, - answer_max_tokens=DEEP_ANSWER_MAX_TOKENS, - ) - deep_sources = self._dedupe_sources(deep_retrieved) - if not deep_sources: - deep_sources = [ - { - "source": "system", - "label": "System Snapshot", - "score": round(max(deep_max_score, 0.6), 4), - "metadata": {"mode": "snapshot_only", "relevance_max": round(deep_max_score, 4)}, - } - ] - if user_snapshot: - deep_sources.append( - { - "source": "system", - "label": "User Snapshot", - "score": round(max(deep_max_score, 0.58), 4), - "metadata": {"mode": "snapshot_only", "relevance_max": round(deep_max_score, 4)}, - } - ) - - merged_metrics = self._merge_search_metrics( - { - "db_query_ms": db_query_ms, - "vector_search_ms": vector_search_ms, - "graph_query_ms": graph_query_ms, - "db_timeout": db_timeout, - "vector_timeout": vector_timeout, - "graph_timeout": graph_timeout, - }, - deep_meta, - ) - db_query_ms = int(merged_metrics.get("db_query_ms") or 0) - vector_search_ms = int(merged_metrics.get("vector_search_ms") or 0) - graph_query_ms = int(merged_metrics.get("graph_query_ms") or 0) - db_timeout = bool(merged_metrics.get("db_timeout")) - vector_timeout = bool(merged_metrics.get("vector_timeout")) - graph_timeout = bool(merged_metrics.get("graph_timeout")) - - deep_trace = list(deep_meta.get("tool_trace") or []) - tool_trace.append( - { - "step": 98, - "tool": "auto_deep_retry", - "reason": auto_retry_reason, - "prev_relevance_max": round(float(max_score or 0.0), 4), - "prev_snapshot_only": self._is_snapshot_only_sources(used_sources), - } - ) - for row in deep_trace: - tool_trace.append(row) - - final_domains = [str(x) for x in (deep_meta.get("domains_used") or final_domains or domains)] - retrieved = deep_retrieved - max_score = deep_max_score - answer_text = deep_answer_text - used_sources = deep_sources - deep_thinking = True - active_thinking_mode = THINKING_MODE_DEEP - - evidence_guard_triggered = self._should_block_answer_with_guard( - question=clean_q, - effective_question=effective_q, - sources=used_sources, - relevance_max=max_score, - ) - if evidence_guard_triggered: - answer_text = self._build_evidence_guard_answer(clean_q) - used_sources = [ - { - "source": "system", - "label": "Evidence Guard", - "score": 0.99, - "metadata": {"mode": "guard", "reason": "snapshot_only_process_query"}, - } - ] - max_score = min(float(max_score or 0.0), 0.35) - if "system_context" not in final_domains: - final_domains.append("system_context") - tool_trace.append( - { - "step": 99, - "tool": "answer_guard", - "reason": "insufficient_call_chain_evidence", - } - ) - - elapsed = int((time.perf_counter() - started) * 1000) - qa = QARecord( - tenant_id=self.tenant_id, - question=clean_q, - answer=answer_text, - sources=used_sources, - intent="question", - domains_used=final_domains, - response_time_ms=elapsed, - asked_by=asked_by, - asked_via=(asked_via or "pmflow")[:20], - project_id=project_id, - session_id=session_id, - turn_index=turn_index, - ) - self.db.add(qa) - confidence = round(max(0.2, min(1.0, max_score if max_score > 0 else 0.55)), 4) - if evidence_guard_triggered: - confidence = min(confidence, 0.35) - missing_fields = [] - if max_score < 0.45: - missing_fields = ["high_relevance_evidence"] - if evidence_guard_triggered: - missing_fields = sorted(set([*missing_fields, "call_chain_evidence", "target_scope"])) - await self._update_session_state_after_turn( - session_state=state_row, - question=clean_q, - effective_question=effective_q, - answer=answer_text, - query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_EXPLAIN, - ambiguity="clear" if not missing_fields else "low_evidence", - rewrite_meta=rewrite_meta, - sources=used_sources, - ) - await self.db.commit() - await self.db.refresh(qa) - await self.context_cache.append_session_messages( - tenant_id=self.tenant_id, - session_id=context_session_id, - question=clean_q, - answer=answer_text, - ) - - return self._build_response_payload( - answer=answer_text, - sources=used_sources, - domains_used=final_domains, - response_time_ms=elapsed, - db_query_ms=db_query_ms, - vector_search_ms=vector_search_ms, - graph_query_ms=graph_query_ms, - db_timeout=db_timeout, - vector_timeout=vector_timeout, - graph_timeout=graph_timeout, - tool_trace=tool_trace, - qa_id=int(qa.id), - session_id=int(session_id) if session_id is not None else None, - turn_index=int(turn_index), - relevance_max=round(max_score, 4), - query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_EXPLAIN, - confidence=confidence, - missing_fields=missing_fields, - thinking_mode=active_thinking_mode, - ) - - def _build_response_payload( - self, - *, - answer: str, - sources: list[dict[str, Any]], - domains_used: list[str], - response_time_ms: int, - db_query_ms: int, - vector_search_ms: int, - graph_query_ms: int = 0, - db_timeout: bool = False, - vector_timeout: bool = False, - graph_timeout: bool = False, - qa_id: Optional[int] = None, - session_id: Optional[int] = None, - turn_index: int = 1, - relevance_max: float = 0.0, - query_type: str = QUERY_TYPE_UNKNOWN, - confidence: float = 0.0, - missing_fields: Optional[list[str]] = None, - tool_trace: Optional[list[dict[str, Any]]] = None, - thinking_mode: str = THINKING_MODE_NORMAL, - action_taken: Optional[str] = None, - signal_id: Optional[int] = None, - reply_required: bool = False, - reply_text: Optional[str] = None, - ack_latency_ms: Optional[int] = None, - trace_id: Optional[str] = None, - processing_stage: Optional[str] = None, - ) -> dict[str, Any]: - evidence: list[dict[str, Any]] = [] - for item in sources[:8]: - if not isinstance(item, dict): - continue - evidence.append( - { - "source": str(item.get("source") or "unknown"), - "label": str(item.get("label") or "unknown"), - "score": round(self._to_float(item.get("score"), 0.0), 4), - "metadata": item.get("metadata") if isinstance(item.get("metadata"), dict) else {}, - } - ) - return { - "answer": str(answer or "").strip(), - "evidence": evidence, - "confidence": round(max(0.0, min(1.0, self._to_float(confidence, 0.0))), 4), - "thinking_mode": self._normalize_thinking_mode(thinking_mode), - "missing_fields": [str(x) for x in (missing_fields or []) if str(x).strip()], - "query_type": str(query_type or QUERY_TYPE_UNKNOWN), - "sources": sources, - "domains_used": domains_used, - "response_time_ms": int(response_time_ms or 0), - "retrieval_metrics": { - "db_query_ms": int(db_query_ms or 0), - "vector_search_ms": int(vector_search_ms or 0), - "graph_query_ms": int(graph_query_ms or 0), - "db_timeout": bool(db_timeout), - "vector_timeout": bool(vector_timeout), - "graph_timeout": bool(graph_timeout), - }, - "tool_trace": tool_trace if isinstance(tool_trace, list) else [], - "qa_id": int(qa_id) if qa_id is not None else None, - "session_id": int(session_id) if session_id is not None else None, - "turn_index": int(turn_index or 1), - "relevance_max": round(max(0.0, self._to_float(relevance_max, 0.0)), 4), - "action_taken": str(action_taken).strip() if action_taken else None, - "signal_id": int(signal_id) if signal_id is not None else None, - "reply_required": bool(reply_required), - "reply_text": str(reply_text or "").strip() if reply_text else None, - "ack_latency_ms": int(ack_latency_ms) if ack_latency_ms is not None else None, - "trace_id": str(trace_id or "").strip() or None, - "processing_stage": str(processing_stage or "").strip() or None, - } - - @staticmethod - def _extract_ack_metadata_from_sources( - sources: Any, - *, - answer: str = "", - ) -> dict[str, Any]: - rows = sources if isinstance(sources, list) else [] - action_taken = "" - signal_id: Optional[int] = None - reply_required = False - ack_latency_ms: Optional[int] = None - trace_id = "" - processing_stage = "" - for source in rows: - if not isinstance(source, dict): - continue - meta = source.get("metadata") if isinstance(source.get("metadata"), dict) else {} - candidate_action = str(meta.get("action_taken") or "").strip() - if not candidate_action: - continue - action_taken = candidate_action - try: - signal_id = int(meta.get("signal_id")) if meta.get("signal_id") is not None else None - except Exception: - signal_id = None - trace_id = str(meta.get("trace_id") or "").strip() - processing_stage = str(meta.get("processing_stage") or meta.get("action_mode") or "").strip() - try: - ack_latency_ms = ( - int(meta.get("ack_latency_ms")) if meta.get("ack_latency_ms") is not None else None - ) - except Exception: - ack_latency_ms = None - reply_required = True - break - reply_text = str(answer or "").strip() if reply_required else "" - normalized_stage = processing_stage - if normalized_stage == "write": - normalized_stage = "ack_only" - return { - "action_taken": action_taken or None, - "signal_id": signal_id, - "reply_required": bool(reply_required), - "reply_text": reply_text or None, - "ack_latency_ms": ack_latency_ms, - "trace_id": trace_id or None, - "processing_stage": normalized_stage or None, - } - - @staticmethod - def _normalize_action_mode(value: Optional[str]) -> str: - text = str(value or "").strip().lower() - if text in {ACTION_MODE_READ, ACTION_MODE_WRITE, ACTION_MODE_READ_WRITE}: - return text - return ACTION_MODE_AUTO - - @classmethod - def _looks_like_write_intent(cls, question: str) -> bool: - text = str(question or "").strip().lower() - if not text: - return False - has_verb = any(k in text for k in REQUIREMENT_WRITE_VERB_KEYWORDS) - has_object = any(k in text for k in REQUIREMENT_WRITE_OBJECT_KEYWORDS) - if not has_verb or not has_object: - return False - if any(prefix in text for prefix in WRITE_EXPRESSIONS): - return True - if "新增" in text or "增加" in text or "记录" in text or "入库" in text: - return True - return False - - @classmethod - def _resolve_action_mode( - cls, - *, - requested_mode: str, - question: str, - effective_question: str, - ) -> str: - mode = cls._normalize_action_mode(requested_mode) - if mode != ACTION_MODE_AUTO: - return mode - - merged = f"{question} {effective_question}".strip() - if not cls._looks_like_write_intent(merged): - return ACTION_MODE_READ - has_question_form = ("?" in merged) or ("?" in merged) or cls._contains_any(merged, QUESTION_HINTS) - if has_question_form: - return ACTION_MODE_READ_WRITE - return ACTION_MODE_WRITE - - @staticmethod - def _normalize_requirement_summary(text: str) -> str: - normalized = re.sub(r"\s+", " ", str(text or "").strip()) - normalized = normalized.strip(",,。.!!??::;;") - return normalized[:300] - - @staticmethod - def _build_ack_trace_id(question: str) -> str: - base = str(question or "").strip() - digest = hashlib.sha1(base.encode("utf-8")).hexdigest()[:10] if base else "empty" - return f"rag-ack-{int(time.time() * 1000)}-{digest}" - - async def _ensure_project_signal_link( - self, - *, - signal_id: int, - project_id: int, - link_type: str = "related", - ) -> None: - existing = ( - await self.db.execute( - select(ProjectSignalLink.id).where( - and_( - ProjectSignalLink.tenant_id == self.tenant_id, - ProjectSignalLink.project_id == int(project_id), - ProjectSignalLink.signal_id == int(signal_id), - ) - ).limit(1) - ) - ).scalar_one_or_none() - if existing is not None: - return - self.db.add( - ProjectSignalLink( - tenant_id=self.tenant_id, - project_id=int(project_id), - signal_id=int(signal_id), - link_type=link_type, - relevance_score=1.0, - ) - ) - - async def _create_or_reuse_requirement_signal( - self, - *, - question: str, - effective_question: str, - asked_by: Optional[int], - project_id: Optional[int], - ) -> tuple[str, Optional[int], Optional[int], str]: - summary = self._normalize_requirement_summary(effective_question or question) - resolved_project_id: Optional[int] = project_id - if resolved_project_id is None: - scoped_id, _, _ = await self._resolve_project_scope_for_question( - question=effective_question or question, - project_id=project_id, - ) - resolved_project_id = scoped_id - - existing_stmt = select(Signal).where( - and_( - Signal.tenant_id == self.tenant_id, - Signal.source == "PMFlow", - Signal.summary == summary, - ) - ) - if resolved_project_id is None: - existing_stmt = existing_stmt.where(Signal.matched_project_id.is_(None)) - else: - existing_stmt = existing_stmt.where(Signal.matched_project_id == int(resolved_project_id)) - existing = ( - await self.db.execute( - existing_stmt.order_by(Signal.created_at.desc(), Signal.id.desc()).limit(1) - ) - ).scalar_one_or_none() - if existing is not None: - if resolved_project_id is not None: - try: - await self._ensure_project_signal_link( - signal_id=int(existing.id), - project_id=int(resolved_project_id), - ) - await self.db.commit() - except Exception as exc: - await self.db.rollback() - return ("signal_failed", None, resolved_project_id, str(exc)[:240]) - return ("signal_deduped", int(existing.id), resolved_project_id, "") - - actor = f"PMFlow用户#{int(asked_by)}" if asked_by is not None else "PMFlow用户" - content = f"[{actor}]: {summary}" - strategic_payload = { - "source": "pmflow_qa", - "original_question": str(question or "")[:4000], - "normalized_question": summary, - "write_mode": "ack_first", - "created_at": now_cn_aware().isoformat(), - } - signal = Signal( - source="PMFlow", - content=content[:2000], - impact_score=0.65, - priority="medium", - processed=False, - strategic_analysis=json.dumps(strategic_payload, ensure_ascii=False), - tasks_generated=json.dumps([], ensure_ascii=False), - matched_project_id=int(resolved_project_id) if resolved_project_id is not None else None, - title=summary[:120] or "PMFlow 新需求信号", - signal_type="新需求", - summary=summary, - reasoning=json.dumps(["来自 PMFlow 问答写入意图,按 ACK-first 策略快速入库。"], ensure_ascii=False), - confidence=0.74, - recommended_action="enter_only", - status="待处理", - tenant_id=self.tenant_id, - ) - try: - self.db.add(signal) - await self.db.flush() - if resolved_project_id is not None: - await self._ensure_project_signal_link( - signal_id=int(signal.id), - project_id=int(resolved_project_id), - ) - await self.db.commit() - await self.db.refresh(signal) - return ("signal_created", int(signal.id), resolved_project_id, "") - except Exception as exc: - await self.db.rollback() - return ("signal_failed", None, resolved_project_id, str(exc)[:240]) - - @staticmethod - def _build_requirement_ack_text( - *, - action_taken: str, - signal_id: Optional[int], - project_id: Optional[int], - error_reason: str, - ) -> str: - project_text = f"项目#{int(project_id)}" if project_id is not None else "未指定项目" - if action_taken == "signal_created" and signal_id is not None: - return f"已入库:已为{project_text}创建信号 #{int(signal_id)},并进入待处理队列。" - if action_taken == "signal_deduped" and signal_id is not None: - return f"已入库:检测到重复需求,已复用{project_text}的信号 #{int(signal_id)}。" - if action_taken == "signal_failed": - reason = error_reason or "写入异常" - return f"未完成入库:{reason}。我已记录本次失败,请稍后重试。" - return "已收到需求,正在处理中。" - - async def _handle_ack_first_requirement( - self, - *, - question: str, - effective_question: str, - asked_by: Optional[int], - asked_via: str, - project_id: Optional[int], - session_id: Optional[int], - turn_index: int, - state_row: Optional[QASessionState], - rewrite_meta: dict[str, Any], - context_session_id: Optional[int], - query_type: str, - action_mode: str, - started_at: float, - ) -> Optional[dict[str, Any]]: - action_taken, linked_signal_id, resolved_project_id, error_reason = await self._create_or_reuse_requirement_signal( - question=question, - effective_question=effective_question, - asked_by=asked_by, - project_id=project_id, - ) - if not action_taken: - return None - - ack_latency_ms = int((time.perf_counter() - started_at) * 1000) - trace_id = self._build_ack_trace_id(question) - reply_text = self._build_requirement_ack_text( - action_taken=action_taken, - signal_id=linked_signal_id, - project_id=resolved_project_id, - error_reason=error_reason, - ) - source_orchestration_result: Optional[dict[str, Any]] = None - if ( - linked_signal_id is not None - and resolved_project_id is not None - and action_taken in {"signal_created", "signal_deduped"} - and SignalSourceOrchestrationService.looks_like_signal_source_request(effective_question or question) - ): - try: - source_orchestration_result = await SignalSourceOrchestrationService(self.db).orchestrate_from_signal( - tenant_id=self.tenant_id, - project_id=int(resolved_project_id), - signal_id=int(linked_signal_id), - initiating_text=effective_question or question, - requested_via="pmflow", - operator_user_id=asked_by, - dry_run=False, - ) - except Exception as exc: - source_orchestration_result = { - "status": "failed", - "reason": str(exc), - } - orchestration_status = str(source_orchestration_result.get("status") or "").lower() - if orchestration_status in {"completed", "partial", "planned", "pending_manual"}: - source_payload = source_orchestration_result.get("signal_source") if isinstance(source_orchestration_result.get("signal_source"), dict) else {} - worker_executions = ( - source_orchestration_result.get("worker_executions") - if isinstance(source_orchestration_result.get("worker_executions"), list) - else [] - ) - if orchestration_status == "pending_manual": - reply_text = ( - f"{reply_text}" - f" 已记录信号源“{str(source_payload.get('source_name') or '未命名信号源')}”的接入请求," - "但当前仍缺少可直接执行的接入配置,已转待确认。" - ) - else: - reply_text = ( - f"{reply_text}" - f" 已登记信号源“{str(source_payload.get('source_name') or '未命名信号源')}”," - f"并已下发 {len(worker_executions)} 个 AI worker 执行。" - ) - answer_text = reply_text - sources = [ - { - "source": "pmflow", - "label": "Signal Queue", - "score": 0.98 if action_taken != "signal_failed" else 0.42, - "metadata": { - "action_taken": action_taken, - "signal_id": linked_signal_id, - "project_id": resolved_project_id, - "action_mode": action_mode, - "processing_stage": "ack_only", - "ack_latency_ms": ack_latency_ms, - "trace_id": trace_id, - "source_orchestration": source_orchestration_result, - }, - } - ] - - qa_id: Optional[int] = None - try: - qa = QARecord( - tenant_id=self.tenant_id, - question=question, - answer=answer_text, - sources=sources, - intent="statement", - domains_used=["signal_write"], - response_time_ms=ack_latency_ms, - asked_by=asked_by, - asked_via=(asked_via or "pmflow")[:20], - project_id=resolved_project_id if resolved_project_id is not None else project_id, - session_id=session_id, - turn_index=int(turn_index or 1), - ) - self.db.add(qa) - await self._update_session_state_after_turn( - session_state=state_row, - question=question, - effective_question=effective_question, - answer=answer_text, - query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_STATUS, - ambiguity="clear" if action_taken != "signal_failed" else "low_evidence", - rewrite_meta=rewrite_meta, - sources=sources, - ) - await self.db.commit() - await self.db.refresh(qa) - qa_id = int(qa.id) - except Exception: - await self.db.rollback() - - try: - await self.context_cache.append_session_messages( - tenant_id=self.tenant_id, - session_id=context_session_id, - question=question, - answer=answer_text, - ) - except Exception: - pass - - confidence = 0.94 if action_taken in {"signal_created", "signal_deduped"} else 0.35 - missing_fields = [] if resolved_project_id is not None else ["project_id"] - return self._build_response_payload( - answer=answer_text, - sources=sources, - domains_used=["signal_write"], - response_time_ms=ack_latency_ms, - db_query_ms=0, - vector_search_ms=0, - db_timeout=False, - vector_timeout=False, - qa_id=qa_id, - session_id=int(session_id) if session_id is not None else None, - turn_index=int(turn_index or 1), - relevance_max=0.99 if action_taken != "signal_failed" else 0.30, - query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_STATUS, - confidence=confidence, - missing_fields=missing_fields, - thinking_mode=THINKING_MODE_NORMAL, - action_taken=action_taken, - signal_id=linked_signal_id, - reply_required=True, - reply_text=reply_text, - ack_latency_ms=ack_latency_ms, - trace_id=trace_id, - processing_stage="ack_only", - ) - - def _classify_query_type(self, question: str) -> str: - text = str(question or "").strip().lower() - if not text: - return QUERY_TYPE_UNKNOWN - # 若同一句同时出现“多少/几个”和“列出/分别”,优先按 list 处理,避免追问被误判为 count。 - if any(k in text for k in LIST_HINTS): - return QUERY_TYPE_LIST - if any(k in text for k in COUNT_HINTS): - return QUERY_TYPE_COUNT - if any(k in text for k in STATUS_HINTS): - return QUERY_TYPE_STATUS - if "为什么" in text or "如何" in text or "怎么" in text: - return QUERY_TYPE_EXPLAIN - return QUERY_TYPE_UNKNOWN - - async def _run_deterministic_query( - self, - *, - question: str, - project_id: Optional[int], - query_type: str, - ) -> Optional[dict[str, Any]]: - q_type = str(query_type or QUERY_TYPE_UNKNOWN) - if q_type == QUERY_TYPE_COUNT: - return await self._run_count_query(question=question, project_id=project_id) - if q_type == QUERY_TYPE_LIST: - return await self._run_list_query(question=question, project_id=project_id) - if q_type == QUERY_TYPE_STATUS: - progress = await self._try_fast_project_progress_answer(clean_q=question, project_id=project_id) - if progress: - progress["query_type"] = QUERY_TYPE_STATUS - progress.setdefault("confidence", 0.98) - progress.setdefault("missing_fields", []) - return progress - existence = await self._try_fast_project_existence_answer(clean_q=question, project_id=project_id) - if existence: - existence["query_type"] = QUERY_TYPE_STATUS - existence.setdefault("confidence", 0.96) - existence.setdefault("missing_fields", []) - return existence - return None - - async def _resolve_project_scope_for_question( - self, - *, - question: str, - project_id: Optional[int], - ) -> tuple[Optional[int], Optional[str], str]: - if project_id is not None: - row = ( - await self.db.execute( - select(Project).where( - and_( - Project.tenant_id == self.tenant_id, - Project.id == int(project_id), - ) - ) - ) - ).scalar_one_or_none() - if row is None: - return (None, None, "") - return (int(row.id), str(row.title or row.name or f"项目#{int(row.id)}"), "") - - direct_project_id, direct_project_name = await self._match_project_name_in_question(question=question) - if direct_project_id is not None and direct_project_name: - return (int(direct_project_id), str(direct_project_name), str(direct_project_name)[:24]) - - keyword = self._extract_project_lookup_keyword(question) - terms = self._extract_query_terms(question, preferred=keyword) - for term in terms: - matched = await self._search_projects_by_keyword(keyword=term, project_id=None, limit=5) - if matched: - target = matched[0] - return (int(target.id), str(target.title or target.name or f"项目#{int(target.id)}"), term) - return (None, None, keyword[:24] if keyword else "") - - @staticmethod - def _normalize_project_subject_text(text: str) -> str: - clean = str(text or "").strip().lower() - if not clean: - return "" - clean = clean.replace("—", "-").replace("_", " ") - clean = re.sub(r"[^\w\u4e00-\u9fff\- ]+", " ", clean) - clean = re.sub( - r"(我说的是|我说的|请问|请|帮我|一下|这个里面|这里面|里面|这个|那个|一下子|目前|当前|现在|有多少个|多少个|信号源|信号|项目|系统)", - " ", - clean, - ) - clean = re.sub(r"[-]+", " ", clean) - clean = re.sub(r"\s+", " ", clean).strip() - return clean - - async def _match_project_name_in_question(self, *, question: str) -> tuple[Optional[int], Optional[str]]: - normalized_question = self._normalize_project_subject_text(question) - if not normalized_question: - return (None, None) - - projects = ( - await self.db.execute( - select(Project) - .where(Project.tenant_id == self.tenant_id) - .order_by(Project.updated_at.desc(), Project.id.desc()) - .limit(200) - ) - ).scalars().all() - - best_id: Optional[int] = None - best_name: Optional[str] = None - best_score = 0 - for row in projects: - candidates = [ - str(row.title or "").strip(), - str(row.name or "").strip(), - ] - for name in candidates: - if len(name) < 2: - continue - normalized_name = self._normalize_project_subject_text(name) - if len(normalized_name) < 2: - continue - - score = 0 - if normalized_name in normalized_question: - score = len(normalized_name) - else: - tokens = [t for t in normalized_name.split(" ") if len(t) >= 2] - if tokens and all(t in normalized_question for t in tokens): - score = sum(len(t) for t in tokens) - - if score > best_score: - best_score = score - best_id = int(row.id) - best_name = str(row.title or row.name or f"项目#{int(row.id)}") - - return (best_id, best_name) - - async def _run_count_query(self, *, question: str, project_id: Optional[int]) -> Optional[dict[str, Any]]: - text = str(question or "").lower() - started = time.perf_counter() - has_project = "项目" in text - has_task = "任务" in text - has_signal = "信号" in text or "信号源" in text - has_people = self._is_people_query(text) - - # 先处理更具体的对象(信号/任务),避免“xxx项目里有多少信号”误落到项目总数分支。 - if has_signal: - scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question( - question=text, - project_id=project_id, - ) - if has_project and scoped_project_id is None and scoped_keyword: - elapsed = int((time.perf_counter() - started) * 1000) - return { - "answer": f"未检索到名称或描述包含“{scoped_keyword}”的项目,无法统计信号数。", - "sources": [ - { - "source": "db", - "label": "Project database", - "score": 0.94, - "metadata": {"table": "projects", "query_keyword": scoped_keyword, "match_count": 0}, - } - ], - "db_query_ms": elapsed, - "response_time_ms": elapsed, - "query_type": QUERY_TYPE_COUNT, - "confidence": 0.94, - "missing_fields": ["project_id"], - } - - stmt = select(func.count(Signal.id)).where(Signal.tenant_id == self.tenant_id) - if scoped_project_id is not None: - stmt = stmt.where(Signal.matched_project_id == int(scoped_project_id)) - total = int((await self.db.execute(stmt)).scalar() or 0) - elapsed = int((time.perf_counter() - started) * 1000) - if scoped_project_id is not None and scoped_project_name: - answer = f"项目「{scoped_project_name}」当前关联信号总数为 {total} 条。" - else: - answer = f"当前可见信号总数为 {total} 条。" - return { - "answer": answer, - "sources": [ - { - "source": "db", - "label": "Project database", - "score": 0.99, - "metadata": { - "table": "signals", - "count": total, - "project_id": scoped_project_id, - "project_name": scoped_project_name, - }, - } - ], - "db_query_ms": elapsed, - "response_time_ms": elapsed, - "query_type": QUERY_TYPE_COUNT, - "confidence": 0.99, - "missing_fields": [], - } - - if has_task: - scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question( - question=text, - project_id=project_id, - ) - if has_project and scoped_project_id is None and scoped_keyword: - elapsed = int((time.perf_counter() - started) * 1000) - return { - "answer": f"未检索到名称或描述包含“{scoped_keyword}”的项目,无法统计任务数。", - "sources": [ - { - "source": "db", - "label": "Project database", - "score": 0.94, - "metadata": {"table": "projects", "query_keyword": scoped_keyword, "match_count": 0}, - } - ], - "db_query_ms": elapsed, - "response_time_ms": elapsed, - "query_type": QUERY_TYPE_COUNT, - "confidence": 0.94, - "missing_fields": ["project_id"], - } - - stmt = select(func.count(Task.id)).where(Task.tenant_id == self.tenant_id) - if scoped_project_id is not None: - stmt = stmt.where(Task.project_id == int(scoped_project_id)) - total = int((await self.db.execute(stmt)).scalar() or 0) - elapsed = int((time.perf_counter() - started) * 1000) - if scoped_project_id is not None and scoped_project_name: - answer = f"项目「{scoped_project_name}」当前任务总数为 {total} 条。" - else: - answer = f"当前可见任务总数为 {total} 条。" - return { - "answer": answer, - "sources": [ - { - "source": "db", - "label": "Project database", - "score": 0.99, - "metadata": { - "table": "tasks", - "count": total, - "project_id": scoped_project_id, - "project_name": scoped_project_name, - }, - } - ], - "db_query_ms": elapsed, - "response_time_ms": elapsed, - "query_type": QUERY_TYPE_COUNT, - "confidence": 0.99, - "missing_fields": [], - } - - if has_people: - scoped_project_id: Optional[int] = None - scoped_project_name: Optional[str] = None - scoped_keyword = "" - if has_project or project_id is not None: - scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question( - question=text, - project_id=project_id, - ) - if has_project and scoped_project_id is None and scoped_keyword and not self._is_people_query(scoped_keyword): - elapsed = int((time.perf_counter() - started) * 1000) - return { - "answer": f"未检索到名称或描述包含“{scoped_keyword}”的项目,无法统计成员人数。", - "sources": [ - { - "source": "db", - "label": "Project database", - "score": 0.94, - "metadata": {"table": "projects", "query_keyword": scoped_keyword, "match_count": 0}, - } - ], - "db_query_ms": elapsed, - "response_time_ms": elapsed, - "query_type": QUERY_TYPE_COUNT, - "confidence": 0.94, - "missing_fields": ["project_id"], - } - - user_stmt = select(func.count(User.id)).where( - and_( - User.tenant_id == self.tenant_id, - User.is_active.is_(True), - ) - ) - if scoped_project_id is not None: - member_subq = select(UserProject.user_id).where( - and_( - UserProject.tenant_id == self.tenant_id, - UserProject.project_id == int(scoped_project_id), - ) - ) - user_stmt = user_stmt.where(User.id.in_(member_subq)) - total = int((await self.db.execute(user_stmt)).scalar() or 0) - elapsed = int((time.perf_counter() - started) * 1000) - if scoped_project_id is not None and scoped_project_name: - answer = f"项目「{scoped_project_name}」当前活跃成员总数为 {total} 人。" - else: - answer = f"当前系统里活跃成员总数为 {total} 人。" - return { - "answer": answer, - "sources": [ - { - "source": "db", - "label": "User database", - "score": 0.99, - "metadata": { - "table": "users", - "count": total, - "project_id": scoped_project_id, - "project_name": scoped_project_name, - }, - } - ], - "db_query_ms": elapsed, - "response_time_ms": elapsed, - "query_type": QUERY_TYPE_COUNT, - "confidence": 0.99, - "missing_fields": [], - } - - if has_project: - stmt = select(func.count(Project.id)).where(Project.tenant_id == self.tenant_id) - if project_id is not None: - stmt = stmt.where(Project.id == int(project_id)) - total = int((await self.db.execute(stmt)).scalar() or 0) - elapsed = int((time.perf_counter() - started) * 1000) - return { - "answer": f"当前系统里项目总数为 {total} 个。", - "sources": [ - { - "source": "db", - "label": "Project database", - "score": 0.99, - "metadata": {"table": "projects", "count": total}, - } - ], - "db_query_ms": elapsed, - "response_time_ms": elapsed, - "query_type": QUERY_TYPE_COUNT, - "confidence": 0.99, - "missing_fields": [], - } - return None - - async def _run_list_query(self, *, question: str, project_id: Optional[int]) -> Optional[dict[str, Any]]: - text = str(question or "").lower() - started = time.perf_counter() - if self._is_people_query(text): - scoped_project_id: Optional[int] = None - scoped_project_name: Optional[str] = None - scoped_keyword = "" - if "项目" in text or project_id is not None: - scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question( - question=text, - project_id=project_id, - ) - if "项目" in text and scoped_project_id is None and scoped_keyword and not self._is_people_query(scoped_keyword): - elapsed = int((time.perf_counter() - started) * 1000) - return { - "answer": f"未检索到名称或描述包含“{scoped_keyword}”的项目,无法列出成员清单。", - "sources": [ - { - "source": "db", - "label": "Project database", - "score": 0.94, - "metadata": {"table": "projects", "query_keyword": scoped_keyword, "match_count": 0}, - } - ], - "db_query_ms": elapsed, - "response_time_ms": elapsed, - "query_type": QUERY_TYPE_LIST, - "confidence": 0.94, - "missing_fields": ["project_id"], - } - - user_stmt = select(User).where( - and_( - User.tenant_id == self.tenant_id, - User.is_active.is_(True), - ) - ) - if scoped_project_id is not None: - member_subq = select(UserProject.user_id).where( - and_( - UserProject.tenant_id == self.tenant_id, - UserProject.project_id == int(scoped_project_id), - ) - ) - user_stmt = user_stmt.where(User.id.in_(member_subq)) - members = ( - await self.db.execute( - user_stmt.order_by(User.updated_at.desc(), User.id.desc()).limit(30) - ) - ).scalars().all() - elapsed = int((time.perf_counter() - started) * 1000) - if not members: - answer = ( - f"项目「{scoped_project_name}」当前没有可列举的成员。" - if scoped_project_name - else "当前没有可列举的成员。" - ) - return { - "answer": answer, - "sources": [ - { - "source": "db", - "label": "User database", - "score": 0.95, - "metadata": { - "table": "users", - "count": 0, - "project_id": scoped_project_id, - "project_name": scoped_project_name, - }, - } - ], - "db_query_ms": elapsed, - "response_time_ms": elapsed, - "query_type": QUERY_TYPE_LIST, - "confidence": 0.95, - "missing_fields": [], - } - - member_ids = [int(row.id) for row in members] - workload_rows = ( - await self.db.execute( - select(UserWorkload.user_id, UserWorkload.workload_ratio).where( - and_( - UserWorkload.tenant_id == self.tenant_id, - UserWorkload.user_id.in_(member_ids), - ) - ) - ) - ).all() - workload_map = {int(uid): float(ratio or 0.0) for uid, ratio in workload_rows} - - project_count_rows = ( - await self.db.execute( - select(UserProject.user_id, func.count(UserProject.project_id)) - .where( - and_( - UserProject.tenant_id == self.tenant_id, - UserProject.user_id.in_(member_ids), - ) - ) - .group_by(UserProject.user_id) - ) - ).all() - project_count_map = {int(uid): int(cnt or 0) for uid, cnt in project_count_rows} - - lines = [] - for row in members: - uid = int(row.id) - ratio = workload_map.get(uid) - if ratio is None: - ratio = round(float(row.workload or 0) / 100.0, 2) - role = str(row.role or "unknown") - project_count = project_count_map.get(uid, 0) - lines.append( - f"#{uid} {str(row.name or f'User#{uid}')}(角色:{role},负载:{float(ratio):.2f},关联项目:{project_count})" - ) - elapsed = int((time.perf_counter() - started) * 1000) - header = f"项目「{scoped_project_name}」成员清单如下:" if scoped_project_name else "当前团队成员清单如下:" - return { - "answer": header + "\n" + "\n".join(lines), - "sources": [ - { - "source": "db", - "label": "User database", - "score": 0.99, - "metadata": { - "table": "users", - "count": len(members), - "project_id": scoped_project_id, - "project_name": scoped_project_name, - }, - } - ], - "db_query_ms": elapsed, - "response_time_ms": elapsed, - "query_type": QUERY_TYPE_LIST, - "confidence": 0.99, - "missing_fields": [], - } - - if "信号" in text or "信号源" in text: - scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question( - question=text, - project_id=project_id, - ) - if "项目" in text and scoped_project_id is None and scoped_keyword: - elapsed = int((time.perf_counter() - started) * 1000) - return { - "answer": f"未检索到名称或描述包含“{scoped_keyword}”的项目,无法列出信号清单。", - "sources": [ - { - "source": "db", - "label": "Project database", - "score": 0.94, - "metadata": {"table": "projects", "query_keyword": scoped_keyword, "match_count": 0}, - } - ], - "db_query_ms": elapsed, - "response_time_ms": elapsed, - "query_type": QUERY_TYPE_LIST, - "confidence": 0.94, - "missing_fields": ["project_id"], - } - - stmt = select(Signal).where(Signal.tenant_id == self.tenant_id) - if scoped_project_id is not None: - stmt = stmt.where(Signal.matched_project_id == int(scoped_project_id)) - signals = ( - await self.db.execute(stmt.order_by(Signal.updated_at.desc(), Signal.id.desc()).limit(30)) - ).scalars().all() - elapsed = int((time.perf_counter() - started) * 1000) - if not signals: - answer = ( - f"项目「{scoped_project_name}」当前没有可列举的信号。" - if scoped_project_name - else "当前没有可列举的信号。" - ) - return { - "answer": answer, - "sources": [ - { - "source": "db", - "label": "Project database", - "score": 0.95, - "metadata": { - "table": "signals", - "count": 0, - "project_id": scoped_project_id, - "project_name": scoped_project_name, - }, - } - ], - "db_query_ms": elapsed, - "response_time_ms": elapsed, - "query_type": QUERY_TYPE_LIST, - "confidence": 0.95, - "missing_fields": [], - } - - lines = [] - for row in signals: - title = str(row.title or row.summary or row.content or f"Signal#{int(row.id)}").replace("\n", " ").strip() - status = str(row.status or "unknown") - lines.append(f"#{int(row.id)} {title[:90]}({status})") - header = f"项目「{scoped_project_name}」信号清单如下:" if scoped_project_name else "当前信号清单如下:" - return { - "answer": header + "\n" + "\n".join(lines), - "sources": [ - { - "source": "db", - "label": "Project database", - "score": 0.99, - "metadata": { - "table": "signals", - "count": len(signals), - "project_id": scoped_project_id, - "project_name": scoped_project_name, - }, - } - ], - "db_query_ms": elapsed, - "response_time_ms": elapsed, - "query_type": QUERY_TYPE_LIST, - "confidence": 0.99, - "missing_fields": [], - } - - if "项目" in text: - stmt = select(Project).where(Project.tenant_id == self.tenant_id) - if project_id is not None: - stmt = stmt.where(Project.id == int(project_id)) - projects = ( - await self.db.execute(stmt.order_by(Project.updated_at.desc(), Project.id.desc()).limit(20)) - ).scalars().all() - if not projects: - elapsed = int((time.perf_counter() - started) * 1000) - return { - "answer": "当前没有可列举的项目。", - "sources": [ - { - "source": "db", - "label": "Project database", - "score": 0.95, - "metadata": {"table": "projects", "count": 0}, - } - ], - "db_query_ms": elapsed, - "response_time_ms": elapsed, - "query_type": QUERY_TYPE_LIST, - "confidence": 0.95, - "missing_fields": [], - } - - lines = [f"#{int(p.id)} {str(p.title or p.name or '未命名项目')}({str(p.status or 'unknown')})" for p in projects] - elapsed = int((time.perf_counter() - started) * 1000) - return { - "answer": "当前项目列表如下:\n" + "\n".join(lines), - "sources": [ - { - "source": "db", - "label": "Project database", - "score": 0.99, - "metadata": {"table": "projects", "count": len(projects)}, - } - ], - "db_query_ms": elapsed, - "response_time_ms": elapsed, - "query_type": QUERY_TYPE_LIST, - "confidence": 0.99, - "missing_fields": [], - } - return None - - async def _ensure_session_state(self, *, session_id: Optional[int]) -> Optional[QASessionState]: - if session_id is None: - return None - row = ( - await self.db.execute( - select(QASessionState).where( - and_( - QASessionState.tenant_id == self.tenant_id, - QASessionState.session_id == int(session_id), - ) - ) - ) - ).scalar_one_or_none() - if row is None: - row = QASessionState( - tenant_id=self.tenant_id, - session_id=int(session_id), - last_subject=None, - last_entity_ids=[], - last_intent=None, - ambiguity="unknown", - rewrite_history=[], - ) - self.db.add(row) - await self.db.flush() - return row - - def _rewrite_question_with_state( - self, - *, - question: str, - conversation: list[dict[str, str]], - session_state: Optional[QASessionState], - ) -> tuple[str, dict[str, Any]]: - expanded = self._expand_followup_question(question, conversation) - current = str(expanded or question or "").strip() - if not current: - return current, {"mode": "empty"} - - lower = current.lower() - has_referential = any(k in lower for k in FOLLOWUP_REFERENTIAL_KEYWORDS) - if not has_referential: - return current, {"mode": "direct"} - - state_subject = "" - state_entity_ids: list[int] = [] - if session_state is not None: - state_subject = str(session_state.last_subject or "").strip() - if isinstance(session_state.last_entity_ids, list): - for value in session_state.last_entity_ids: - try: - state_entity_ids.append(int(value)) - except Exception: - continue - if state_subject: - rewritten = f"围绕“{state_subject}”回答:{current}" - return rewritten[:1200], {"mode": "state_subject", "subject": state_subject} - if state_entity_ids: - anchor = ",".join(str(x) for x in state_entity_ids[:6]) - rewritten = f"围绕会话实体ID[{anchor}]回答:{current}" - return rewritten[:1200], {"mode": "state_entities", "entity_ids": state_entity_ids[:6]} - return current, {"mode": "referential_without_subject"} - - def _build_clarification_question( - self, - *, - original_question: str, - effective_question: str, - session_state: Optional[QASessionState], - ) -> Optional[str]: - text = str(original_question or "").strip().lower() - if not text: - return None - if self._is_correction_query(text): - if self._has_explicit_target_scope(text): - return None - return ( - "收到,你在纠正上一条回答。请直接说你要的目标范围,例如:" - "`我问的是 AI 引擎驱动方式,第一层是否调用 openclaw?`" - ) - has_referential = any(k in text for k in FOLLOWUP_REFERENTIAL_KEYWORDS) - if not has_referential: - return None - state_subject = str(getattr(session_state, "last_subject", "") or "").strip() - state_entity_ids = getattr(session_state, "last_entity_ids", None) - has_state_entities = isinstance(state_entity_ids, list) and any(str(x).strip() for x in state_entity_ids) - if state_subject or has_state_entities: - return None - if str(effective_question or "").strip() != text and "补充问题" in str(effective_question): - return None - return "你这句缺少明确主语。请先指明对象,例如:`分别是哪些项目?` 或 `请列出这些项目的名称和ID`。" - - async def _update_session_state_after_turn( - self, - *, - session_state: Optional[QASessionState], - question: str, - effective_question: str, - answer: str, - query_type: str, - ambiguity: str, - rewrite_meta: dict[str, Any], - sources: list[dict[str, Any]], - ) -> None: - if session_state is None: - return - subject = self._extract_state_subject(question=question, answer=answer, sources=sources) - if subject: - session_state.last_subject = subject[:255] - session_state.last_intent = "question" - session_state.ambiguity = str(ambiguity or "clear")[:20] - session_state.last_entity_ids = self._collect_entity_ids_from_sources(sources) - - history = session_state.rewrite_history if isinstance(session_state.rewrite_history, list) else [] - history.append( - { - "at": now_cn_aware().isoformat(), - "question": question[:500], - "effective_question": effective_question[:500], - "query_type": str(query_type or QUERY_TYPE_UNKNOWN), - "ambiguity": str(ambiguity or "clear"), - "rewrite_meta": rewrite_meta if isinstance(rewrite_meta, dict) else {}, - } - ) - session_state.rewrite_history = history[-20:] - await self.db.flush() - - def _collect_entity_ids_from_sources(self, sources: list[dict[str, Any]]) -> list[int]: - ids: list[int] = [] - for source in sources: - if not isinstance(source, dict): - continue - meta = source.get("metadata") if isinstance(source.get("metadata"), dict) else {} - for key in ( - "project_id", - "task_id", - "signal_id", - "user_id", - "entity_id", - "subject_entity_id", - "object_entity_id", - ): - value = meta.get(key) - try: - if value is not None: - ids.append(int(value)) - except Exception: - continue - matched = meta.get("matched_project_ids") - if isinstance(matched, list): - for value in matched: - try: - ids.append(int(value)) - except Exception: - continue - uniq: list[int] = [] - for value in ids: - if value not in uniq: - uniq.append(value) - return uniq[:20] - - def _extract_state_subject( - self, - *, - question: str, - answer: str, - sources: list[dict[str, Any]], - ) -> str: - for source in sources: - if not isinstance(source, dict): - continue - meta = source.get("metadata") if isinstance(source.get("metadata"), dict) else {} - project_keyword = str(meta.get("query_keyword") or "").strip() - if project_keyword: - return project_keyword[:255] - subject_name = str(meta.get("subject_name") or "").strip() - object_name = str(meta.get("object_name") or "").strip() - if subject_name and object_name: - return f"{subject_name}->{object_name}"[:255] - - text = str(question or "").strip() - keyword = self._extract_project_lookup_keyword(text) - if len(keyword) >= 2: - return keyword[:255] - - answer_text = str(answer or "").strip() - m = re.search(r"项目[「\"]?([^」\"\n]{2,40})", answer_text) - if m: - return str(m.group(1)).strip()[:255] - return "" - - async def get_session_state_replay(self, *, session_id: int, user_id: int) -> dict[str, Any]: - session = ( - await self.db.execute( - select(QASession).where( - and_( - QASession.tenant_id == self.tenant_id, - QASession.id == int(session_id), - QASession.user_id == int(user_id), - ) - ) - ) - ).scalar_one_or_none() - if session is None: - raise ValueError("qa session 不存在") - - state = await self._ensure_session_state(session_id=int(session_id)) - history = state.rewrite_history if isinstance(state.rewrite_history, list) else [] - return { - "session_id": int(session_id), - "last_subject": str(state.last_subject or "") if state else "", - "last_entity_ids": state.last_entity_ids if state else [], - "last_intent": str(state.last_intent or "") if state else "", - "ambiguity": str(state.ambiguity or "") if state else "", - "rewrite_history": history[-20:], - "updated_at": state.updated_at.isoformat() if state and state.updated_at else None, - } - - async def _try_glm_project_semantic_answer( - self, - *, - clean_q: str, - project_id: Optional[int], - ) -> Optional[dict[str, Any]]: - question = str(clean_q or "").strip() - if not question: - return None - if self._is_people_query(question): - return None - if not any(k in question for k in ("项目", "系统", "进度", "状态", "有没有", "是否", "存在", "立项", "任务")): - return None - - db_started = time.perf_counter() - stmt = select(Project).where(Project.tenant_id == self.tenant_id) - if project_id is not None: - stmt = stmt.where(Project.id == int(project_id)) - projects = (await self.db.execute(stmt.order_by(Project.updated_at.desc()).limit(120))).scalars().all() - if not projects: - db_query_ms = int((time.perf_counter() - db_started) * 1000) - return { - "answer": "当前项目库为空,无法检索到匹配项目。", - "sources": [ - { - "source": "db", - "label": "Project database", - "score": 0.99, - "metadata": {"table": "projects", "match_count": 0, "retrieval_mode": "glm_semantic"}, - } - ], - "db_query_ms": db_query_ms, - "response_time_ms": db_query_ms, - } - - project_ids = [int(p.id) for p in projects] - task_rows = [] - if project_ids: - task_rows = ( - await self.db.execute( - select(Task).where( - and_( - Task.tenant_id == self.tenant_id, - Task.project_id.in_(project_ids), - ) - ) - ) - ).scalars().all() - db_query_ms = int((time.perf_counter() - db_started) * 1000) - - done_statuses = {"done", "completed", "已完成", "完成", "closed", "关闭"} - doing_statuses = {"in_progress", "doing", "进行中", "开发中", "处理中", "processing"} - pending_statuses = {"todo", "pending", "待处理", "未开始", "open"} - - task_stats: dict[int, dict[str, Any]] = {} - for pid in project_ids: - task_stats[pid] = { - "task_total": 0, - "done_count": 0, - "doing_count": 0, - "pending_count": 0, - "latest_task_updated_at": None, - } - - for row in task_rows: - pid = int(row.project_id) - if pid not in task_stats: - continue - stat = task_stats[pid] - stat["task_total"] += 1 - status = str(row.status or "").strip().lower() - if status in done_statuses: - stat["done_count"] += 1 - elif status in doing_statuses: - stat["doing_count"] += 1 - elif status in pending_statuses: - stat["pending_count"] += 1 - else: - stat["pending_count"] += 1 - row_time = row.updated_at or row.created_at - latest = stat.get("latest_task_updated_at") - if row_time and (latest is None or row_time > latest): - stat["latest_task_updated_at"] = row_time - - project_index: list[dict[str, Any]] = [] - for p in projects: - pid = int(p.id) - stats = task_stats.get(pid) or {} - project_index.append( - { - "id": pid, - "title": str(p.title or p.name or "")[:120], - "name": str(p.name or "")[:120], - "status": str(p.status or "unknown"), - "description": str(p.description or "")[:220], - "task_total": int(stats.get("task_total") or 0), - "done_count": int(stats.get("done_count") or 0), - "doing_count": int(stats.get("doing_count") or 0), - "pending_count": int(stats.get("pending_count") or 0), - "latest_task_updated_at": ( - stats.get("latest_task_updated_at").isoformat() - if stats.get("latest_task_updated_at") is not None - else None - ), - } - ) - - prompt = PROMPT_RAG_DB_PROJECT_QA.format( - question=question[:600], - project_index=json.dumps(project_index, ensure_ascii=False), - ).strip() - llm_started = time.perf_counter() - try: - raw = await self.llm.generate( - prompt, - db=self.db, - tenant_id=self.tenant_id, - function_name="rag_db_project_semantic_retrieval", - temperature=0.0, - max_tokens=520, - use_prompt_profile=False, - ) - parsed = self._parse_json_object(raw) - if not parsed: - return None - handled = bool(parsed.get("handled")) - answer = str(parsed.get("answer") or "").strip() - confidence = self._to_float(parsed.get("confidence"), 0.0) - matched_ids_raw = parsed.get("matched_project_ids") - matched_ids: list[int] = [] - if isinstance(matched_ids_raw, list): - for x in matched_ids_raw: - try: - matched_ids.append(int(x)) - except Exception: - continue - matched_ids = matched_ids[:8] - if not handled or not answer: - return None - response_ms = int((time.perf_counter() - llm_started) * 1000) + db_query_ms - return { - "answer": answer, - "sources": [ - { - "source": "db", - "label": "Project database", - "score": round(max(0.0, min(1.0, confidence or 0.9)), 4), - "metadata": { - "table": "projects", - "matched_project_ids": matched_ids, - "project_index_count": len(project_index), - "retrieval_mode": "glm_semantic", - }, - } - ], - "db_query_ms": db_query_ms, - "response_time_ms": response_ms, - } - except Exception: - return None - - async def _persist_fast_answer( - self, - *, - question: str, - asked_by: Optional[int], - asked_via: str, - project_id: Optional[int], - fast_result: dict[str, Any], - session_id: Optional[int] = None, - external_session_id: Optional[int] = None, - turn_index: Optional[int] = None, - query_type: str = QUERY_TYPE_EXPLAIN, - missing_fields: Optional[list[str]] = None, - state_row: Optional[QASessionState] = None, - effective_question: Optional[str] = None, - rewrite_meta: Optional[dict[str, Any]] = None, - thinking_mode: str = THINKING_MODE_NORMAL, - ) -> dict[str, Any]: - started = time.perf_counter() - answer_text = str(fast_result.get("answer") or "未命中结果") - sources = list(fast_result.get("sources") or []) - active_session_id = session_id - active_turn_index = turn_index - if active_session_id is None or active_turn_index is None: - active_session_id, active_turn_index = await self._touch_session_for_new_record( - asked_by=asked_by, - asked_via=asked_via, - project_id=project_id, - question=question, - ) - active_turn_index = int(active_turn_index or 1) - active_session_id_int = int(active_session_id) if active_session_id is not None else None - - confidence = self._to_float(fast_result.get("confidence"), 0.96) - if missing_fields is None: - missing_fields = list(fast_result.get("missing_fields") or []) - final_query_type = str(fast_result.get("query_type") or query_type or QUERY_TYPE_EXPLAIN) - - qa = QARecord( - tenant_id=self.tenant_id, - question=question, - answer=answer_text, - sources=sources, - intent="question", - domains_used=["db"], - response_time_ms=int(fast_result.get("response_time_ms") or 0), - asked_by=asked_by, - asked_via=(asked_via or "pmflow")[:20], - project_id=project_id, - session_id=active_session_id_int, - turn_index=active_turn_index, - ) - self.db.add(qa) - if state_row is None and active_session_id_int is not None: - state_row = await self._ensure_session_state(session_id=active_session_id_int) - await self._update_session_state_after_turn( - session_state=state_row, - question=question, - effective_question=str(effective_question or question), - answer=answer_text, - query_type=final_query_type, - ambiguity="clear", - rewrite_meta=rewrite_meta or {}, - sources=sources, - ) - await self.db.commit() - await self.db.refresh(qa) - cache_session_id = active_session_id_int if active_session_id_int is not None else ( - int(external_session_id) if external_session_id is not None else None - ) - await self.context_cache.append_session_messages( - tenant_id=self.tenant_id, - session_id=cache_session_id, - question=question, - answer=answer_text, - ) - elapsed = int((time.perf_counter() - started) * 1000) - return self._build_response_payload( - answer=answer_text, - sources=sources, - domains_used=["db"], - response_time_ms=int(fast_result.get("response_time_ms") or elapsed), - db_query_ms=int(fast_result.get("db_query_ms") or 0), - vector_search_ms=0, - db_timeout=False, - vector_timeout=False, - qa_id=int(qa.id), - session_id=active_session_id_int, - turn_index=active_turn_index, - relevance_max=0.99, - query_type=final_query_type, - confidence=confidence, - missing_fields=list(missing_fields or []), - thinking_mode=thinking_mode, - ) - - async def _try_fast_project_progress_answer( - self, - *, - clean_q: str, - project_id: Optional[int], - ) -> Optional[dict[str, Any]]: - question = str(clean_q or "").strip() - if not question: - return None - if not PROJECT_PROGRESS_RE.search(question): - return None - if "项目" not in question and "系统" not in question: - return None - - primary_keyword = self._extract_project_lookup_keyword(question) - query_terms = self._extract_query_terms(question, preferred=primary_keyword) - if not query_terms: - return None - - started = time.perf_counter() - matched: list[Project] = [] - used_keyword = query_terms[0] - for term in query_terms: - rows = await self._search_projects_by_keyword(keyword=term, project_id=project_id, limit=3) - if rows: - matched = rows - used_keyword = term - break - if not matched: - db_query_ms = int((time.perf_counter() - started) * 1000) - return { - "answer": f"未检索到名称或描述包含“{used_keyword}”的项目,无法判断进度。", - "sources": [ - { - "source": "db", - "label": "Project database", - "score": 0.99, - "metadata": {"table": "projects", "query_keyword": used_keyword, "match_count": 0}, - } - ], - "db_query_ms": db_query_ms, - "response_time_ms": db_query_ms, - } - - target = matched[0] - task_rows = ( - await self.db.execute( - select(Task).where( - and_( - Task.tenant_id == self.tenant_id, - Task.project_id == int(target.id), - ) - ) - ) - ).scalars().all() - db_query_ms = int((time.perf_counter() - started) * 1000) - - total = len(task_rows) - done_statuses = {"done", "completed", "已完成", "完成", "closed", "关闭"} - doing_statuses = {"in_progress", "doing", "进行中", "开发中", "处理中", "processing"} - pending_statuses = {"todo", "pending", "待处理", "未开始", "open"} - - done = 0 - doing = 0 - pending = 0 - latest_update: Optional[datetime] = None - for row in task_rows: - status = str(row.status or "").strip().lower() - if status in done_statuses: - done += 1 - elif status in doing_statuses: - doing += 1 - elif status in pending_statuses: - pending += 1 - else: - pending += 1 - - row_time = row.updated_at or row.created_at - if row_time and (latest_update is None or row_time > latest_update): - latest_update = row_time - - project_name = str(target.title or target.name or f"项目#{int(target.id)}") - project_status = str(target.status or "unknown") - if total == 0: - answer = ( - f"项目「{project_name}」当前状态为 {project_status}。" - "目前未检索到该项目的任务拆解记录,因此只能确认项目状态,无法量化阶段进度。" - ) - else: - completion = round((done / total) * 100, 1) - latest_text = latest_update.strftime("%Y-%m-%d %H:%M") if latest_update else "未知" - answer = ( - f"项目「{project_name}」当前状态为 {project_status}。" - f"任务总数 {total},已完成 {done},进行中 {doing},待处理 {pending},完成率 {completion}%。" - f"最近任务更新时间:{latest_text}。" - ) - - if len(matched) > 1: - others = "、".join([str(x.title or x.name or f'#{x.id}') for x in matched[1:]]) - answer += f" 另有 {len(matched) - 1} 个同名候选项目:{others}。" - - return { - "answer": answer, - "sources": [ - { - "source": "db", - "label": "Project database", - "score": 0.99, - "metadata": { - "table": "projects", - "project_id": int(target.id), - "query_keyword": used_keyword, - "match_count": len(matched), - }, - }, - { - "source": "db", - "label": "Project database", - "score": 0.98, - "metadata": { - "table": "tasks", - "project_id": int(target.id), - "task_total": total, - "done_count": done, - "doing_count": doing, - "pending_count": pending, - }, - }, - ], - "db_query_ms": db_query_ms, - "response_time_ms": db_query_ms, - } - - async def _try_fast_project_existence_answer( - self, - *, - clean_q: str, - project_id: Optional[int], - ) -> Optional[dict[str, Any]]: - question = str(clean_q or "").strip() - if not question: - return None - if "项目" not in question and "系统" not in question: - return None - if ( - "吗" not in question - and "?" not in question - and "?" not in question - and "是否有" not in question - and "有没有" not in question - and "有无" not in question - and not PROJECT_EXISTENCE_RE.search(question) - ): - return None - - primary_keyword = self._extract_project_lookup_keyword(question) - query_terms = self._extract_query_terms(question, preferred=primary_keyword) - if not query_terms: - return None - - started = time.perf_counter() - matched: list[Project] = [] - used_keyword = query_terms[0] - for term in query_terms: - rows = await self._search_projects_by_keyword(keyword=term, project_id=project_id, limit=5) - if rows: - matched = rows - used_keyword = term - break - db_query_ms = int((time.perf_counter() - started) * 1000) - - if matched: - lines = [ - f"#{int(p.id)} {str(p.title or p.name or '未命名项目')}(状态:{str(p.status or 'unknown')})" - for p in matched - ] - answer = f"有,当前匹配到 {len(matched)} 个相关项目:\n" + "\n".join(lines) - else: - answer = f"暂未检索到名称或描述包含“{used_keyword}”的项目。" - - return { - "answer": answer, - "sources": [ - { - "source": "db", - "label": "Project database", - "score": 0.99, - "metadata": { - "table": "projects", - "query_keyword": used_keyword, - "match_count": len(matched), - }, - } - ], - "db_query_ms": db_query_ms, - } - - async def _search_projects_by_keyword( - self, - *, - keyword: str, - project_id: Optional[int], - limit: int, - ) -> list[Project]: - term = str(keyword or "").strip() - if not term: - return [] - stmt = select(Project).where(Project.tenant_id == self.tenant_id) - if project_id is not None: - stmt = stmt.where(Project.id == int(project_id)) - like = f"%{term}%" - stmt = stmt.where( - or_( - Project.name.ilike(like), - Project.title.ilike(like), - Project.description.ilike(like), - ) - ) - return (await self.db.execute(stmt.limit(max(1, int(limit))))).scalars().all() - - @staticmethod - def _extract_project_lookup_keyword(question: str) -> str: - text = str(question or "").strip() - if not text: - return "" - text = text.replace("?", "?") - - direct_patterns = [ - r"(?:是否有|有没有|有无)([\u4e00-\u9fffA-Za-z0-9_-]{1,24})(?:项目|系统)?", - r"(?:是否存在|存在)([\u4e00-\u9fffA-Za-z0-9_-]{1,24})(?:项目|系统)?", - ] - for pattern in direct_patterns: - m = re.search(pattern, text) - if m: - value = str(m.group(1) or "").strip(" ,。,.!??::;;“”\"'()()") - value = QUESTION_NOISE_RE.sub("", value).strip() - if len(value) >= 2: - return value[:24] - - anchored_matches = list(re.finditer(r"([\u4e00-\u9fffA-Za-z0-9_-]{1,24})(?:项目|系统)", text)) - for m in reversed(anchored_matches): - value = str(m.group(1) or "").strip(" ,。,.!??::;;“”\"'()()") - value = QUESTION_NOISE_RE.sub("", value).strip() - if len(value) >= 2: - return value[:24] - - patterns = [ - r"有(?:没有)?(.{1,24}?)(?:项目|系统).*\?", - r"项目.*?有(?:没有)?(.{1,24}?)(?:吗|\?)", - r"是否有(.{1,24}?)(?:项目|系统)", - r"(.{1,24}?)(?:项目|系统)(?:有吗|有没有|是否有)", - ] - for pattern in patterns: - m = re.search(pattern, text) - if not m: - continue - kw = str(m.group(1) or "").strip(" ,。,.!??::;;“”\"'()()") - kw = QUESTION_NOISE_RE.sub("", kw).strip() - if kw: - return kw[:24] - - fallback = re.sub(r"[??]", " ", text) - fallback = re.sub( - r"(项目|系统|目前|当前|现在|我们|有没有|是否有|有吗|有|吗|什么|进度|层面|阶段|是在|在|是|请问|一下|多少)", - " ", - fallback, - ) - fallback = re.sub(r"\s+", " ", fallback).strip(" ,。,.!??::;;") - return fallback[:24] - - @staticmethod - def _extract_query_terms(question: str, *, preferred: str = "") -> list[str]: - base = str(question or "").strip() - terms: list[str] = [] - pref = str(preferred or "").strip() - if len(pref) >= 2: - terms.append(pref[:24]) - - clean = re.sub(r"[??,,。!!::;;()()\"“”']", " ", base) - clean = QUESTION_NOISE_RE.sub(" ", clean) - clean = re.sub(r"(项目|系统|进度|状态|阶段|完成率|层面)", " ", clean) - clean = re.sub(r"\s+", " ", clean).strip() - - token_candidates: list[str] = [] - for token in clean.split(" "): - t = token.strip() - if len(t) >= 2: - token_candidates.append(t[:24]) - - for m in re.finditer(r"[\u4e00-\u9fff]{2,10}", clean): - token_candidates.append(m.group(0)[:24]) - - for t in sorted(token_candidates, key=len, reverse=True): - if t and t not in terms: - terms.append(t) - return terms[:6] - - @staticmethod - def _is_people_query(question: str) -> bool: - text = str(question or "").strip().lower() - if not text: - return False - return any(k in text for k in PEOPLE_KEYWORDS) - - # ------------------------------------------------------------------ - # Manifest / Entity resolution / Agentic retrieval - # ------------------------------------------------------------------ - async def get_project_manifest(self, *, project_id: Optional[int] = None) -> dict[str, Any]: - system_snapshot = await self.context_cache.get_system_snapshot( - db=self.db, - tenant_id=self.tenant_id, - ) - symbol_snapshot = await self._get_symbol_snapshot_cached() - - project_info: dict[str, Any] = {} - if project_id is not None: - row = ( - await self.db.execute( - select(Project).where( - and_( - Project.tenant_id == self.tenant_id, - Project.id == int(project_id), - ) - ) - ) - ).scalar_one_or_none() - if row is not None: - project_info = { - "id": int(row.id), - "title": str(row.title or row.name or f"项目#{int(row.id)}"), - "status": str(row.status or "unknown"), - } - - kg_overview = {"entity_total": 0, "relation_total": 0, "evidence_total": 0} - try: - kg_overview = { - "entity_total": int( - (await self.db.execute(select(func.count(KGEntity.id)).where(KGEntity.tenant_id == self.tenant_id))) - .scalar_one() - or 0 - ), - "relation_total": int( - ( - await self.db.execute(select(func.count(KGRelation.id)).where(KGRelation.tenant_id == self.tenant_id)) - ).scalar_one() - or 0 - ), - "evidence_total": int( - ( - await self.db.execute(select(func.count(KGEvidence.id)).where(KGEvidence.tenant_id == self.tenant_id)) - ).scalar_one() - or 0 - ), - } - except Exception: - kg_overview = {"entity_total": 0, "relation_total": 0, "evidence_total": 0} - - platform = ( - system_snapshot.get("platform_profile") - if isinstance(system_snapshot.get("platform_profile"), dict) - else {} - ) - backend = platform.get("backend") if isinstance(platform.get("backend"), dict) else {} - module_counts = backend.get("module_counts") if isinstance(backend.get("module_counts"), dict) else {} - feature_catalog = platform.get("feature_catalog") if isinstance(platform.get("feature_catalog"), list) else [] - feature_summary = { - "feature_total": len(feature_catalog), - "top_features": [ - { - "name": str(item.get("name") or ""), - "route": str(item.get("route") or ""), - } - for item in feature_catalog[:10] - if isinstance(item, dict) - ], - } - - return { - "tenant_id": self.tenant_id, - "project": project_info, - "module_counts": module_counts, - "feature_summary": feature_summary, - "symbol_summary": symbol_snapshot, - "kg_overview": kg_overview, - "manifest_text": self._compose_manifest_text( - project_info=project_info, - module_counts=module_counts, - feature_summary=feature_summary, - symbol_snapshot=symbol_snapshot, - kg_overview=kg_overview, - ), - "generated_at": now_cn_aware().isoformat(), - } - - async def entity_resolution( - self, - *, - question: str, - project_id: Optional[int] = None, - limit: int = 10, - ) -> dict[str, Any]: - normalized = self._normalize_entity_text(question) - alias_terms = self._collect_alias_terms(normalized) - query_terms = self._extract_query_terms(question, preferred=self._extract_project_lookup_keyword(question)) - for term in alias_terms: - if term not in query_terms: - query_terms.append(term) - - candidates: list[dict[str, Any]] = [] - if query_terms: - try: - max_terms = query_terms[:6] - conditions = [] - for term in max_terms: - like = f"%{term}%" - conditions.append(KGEntity.display_name.ilike(like)) - conditions.append(KGEntity.entity_key.ilike(like)) - rows = ( - await self.db.execute( - select(KGEntity) - .where( - and_( - KGEntity.tenant_id == self.tenant_id, - or_(*conditions), - ) - ) - .order_by(KGEntity.last_seen_at.desc(), KGEntity.id.desc()) - .limit(max(20, int(limit) * 4)) - ) - ).scalars().all() - for row in rows: - payload = self._safe_dict(row.payload) - row_project_id = payload.get("project_id") - if project_id is not None and row_project_id is not None and int(row_project_id) != int(project_id): - continue - score = 0.0 - lowered_name = str(row.display_name or "").lower() - lowered_key = str(row.entity_key or "").lower() - for term in max_terms: - term_lower = term.lower() - if term_lower in lowered_name: - score += 0.34 - if term_lower in lowered_key: - score += 0.22 - if score <= 0: - continue - candidates.append( - { - "entity_id": int(row.id), - "entity_type": str(row.entity_type or ""), - "entity_key": str(row.entity_key or ""), - "display_name": str(row.display_name or ""), - "score": round(min(score, 0.99), 4), - } - ) - except Exception: - candidates = [] - candidates.sort(key=lambda x: float(x.get("score") or 0.0), reverse=True) - - return { - "normalized_question": normalized, - "canonical_terms": alias_terms, - "query_terms": query_terms[:8], - "matched_entities": candidates[: max(1, int(limit))], - } - - async def get_feature_catalog( - self, - *, - query: Optional[str] = None, - limit: int = 20, - ) -> dict[str, Any]: - system_snapshot = await self.context_cache.get_system_snapshot( - db=self.db, - tenant_id=self.tenant_id, - ) - catalog = self._feature_catalog_from_snapshot(system_snapshot) - if query and str(query).strip(): - rows = self._match_feature_catalog_entries( - question=str(query), - system_snapshot=system_snapshot, - limit=max(1, min(int(limit), 100)), - ) - return { - "query": str(query), - "count": len(rows), - "items": rows, - } - max_rows = max(1, min(int(limit), 200)) - return { - "query": str(query or ""), - "count": min(len(catalog), max_rows), - "items": catalog[:max_rows], - } - - def _compose_manifest_text( - self, - *, - project_info: dict[str, Any], - module_counts: dict[str, Any], - feature_summary: dict[str, Any], - symbol_snapshot: dict[str, Any], - kg_overview: dict[str, Any], - ) -> str: - lines: list[str] = [] - if project_info: - lines.append( - f"当前项目:#{int(project_info.get('id') or 0)} {str(project_info.get('title') or 'unknown')} " - f"(status={str(project_info.get('status') or 'unknown')})" - ) - lines.append( - "后端模块规模:" - f"api={int(module_counts.get('api', 0))}," - f"services={int(module_counts.get('services', 0))}," - f"models={int(module_counts.get('models', 0))}," - f"schemas={int(module_counts.get('schemas', 0))}" - ) - lines.append(f"产品功能:total={int(feature_summary.get('feature_total', 0))}") - top_features = feature_summary.get("top_features") - if isinstance(top_features, list) and top_features: - feature_brief = ";".join( - f"{str(item.get('name') or '')}({str(item.get('route') or '')})" - for item in top_features[:6] - if isinstance(item, dict) and str(item.get("name") or "").strip() - ) - if feature_brief: - lines.append(f"核心入口:{feature_brief}") - lines.append( - "符号索引:" - f"files={int(symbol_snapshot.get('indexed_files', 0))}," - f"symbols={int(symbol_snapshot.get('symbol_total', 0))}," - f"python={int(symbol_snapshot.get('python_symbols', 0))}," - f"typescript={int(symbol_snapshot.get('typescript_symbols', 0))}" - ) - lines.append( - "知识图谱:" - f"entities={int(kg_overview.get('entity_total', 0))}," - f"relations={int(kg_overview.get('relation_total', 0))}," - f"evidences={int(kg_overview.get('evidence_total', 0))}" - ) - top_modules = symbol_snapshot.get("top_modules") - if isinstance(top_modules, list) and top_modules: - brief = ";".join( - f"{str(item.get('module') or '')}:{int(item.get('symbols') or 0)}" - for item in top_modules[:5] - if isinstance(item, dict) - ) - if brief: - lines.append(f"热点模块:{brief}") - return "\n".join(lines)[:2200] - - @staticmethod - def _normalize_entity_text(text: str) -> str: - normalized = str(text or "").strip().lower() - normalized = normalized.replace("—", "-").replace("_", " ") - normalized = re.sub(r"[^\w\u4e00-\u9fff\- ]+", " ", normalized) - normalized = re.sub(r"\s+", " ", normalized).strip() - return normalized - - def _collect_alias_terms(self, normalized_question: str) -> list[str]: - aliases: list[str] = [] - for token, canonical in ENTITY_ALIAS_MAP.items(): - if token in normalized_question and canonical not in aliases: - aliases.append(canonical) - return aliases[:8] - - @staticmethod - def _feature_catalog_from_snapshot(system_snapshot: Optional[dict[str, Any]]) -> list[dict[str, Any]]: - if not isinstance(system_snapshot, dict): - return [] - platform = system_snapshot.get("platform_profile") - if not isinstance(platform, dict): - return [] - catalog = platform.get("feature_catalog") - if not isinstance(catalog, list): - return [] - return [item for item in catalog if isinstance(item, dict)] - - def _match_feature_catalog_entries( - self, - *, - question: str, - system_snapshot: Optional[dict[str, Any]], - limit: int = 4, - ) -> list[dict[str, Any]]: - text = str(question or "").strip().lower() - if not text: - return [] - catalog = self._feature_catalog_from_snapshot(system_snapshot) - if not catalog: - return [] - query_terms = [x.lower() for x in self._extract_query_terms(question)[:8] if str(x).strip()] - matched: list[dict[str, Any]] = [] - for item in catalog: - name = str(item.get("name") or "").strip() - route = str(item.get("route") or "").strip() - component = str(item.get("page_component") or "").strip() - aliases_raw = item.get("aliases") - alias_values = aliases_raw if isinstance(aliases_raw, list) else [] - alias_pool = [name, route, component, *[str(x) for x in alias_values]] - alias_pool = [a.strip().lower() for a in alias_pool if str(a).strip()] - if not alias_pool: - continue - - score = 0.0 - for alias in alias_pool: - if alias and alias in text: - score += 0.45 - for term in query_terms: - if term and any(term in alias or alias in term for alias in alias_pool): - score += 0.18 - if score <= 0: - continue - matched.append( - { - **item, - "match_score": round(min(score, 0.99), 4), - } - ) - - matched.sort(key=lambda x: float(x.get("match_score") or 0.0), reverse=True) - return matched[: max(1, int(limit))] - - def _retrieve_feature_catalog_context( - self, - *, - question: str, - system_snapshot: Optional[dict[str, Any]], - max_items: int = 4, - ) -> list[RetrievedItem]: - rows = self._match_feature_catalog_entries( - question=question, - system_snapshot=system_snapshot, - limit=max_items, - ) - out: list[RetrievedItem] = [] - for row in rows: - name = str(row.get("name") or "unknown_feature") - route = str(row.get("route") or "") - summary = str(row.get("summary") or "") - page_file = str(row.get("page_file") or "") - page_component = str(row.get("page_component") or "") - apis = row.get("apis") if isinstance(row.get("apis"), list) else [] - api_text = "、".join(str(x) for x in apis[:4] if str(x).strip()) - content = f"功能: {name}\n用途: {summary}\n入口: {route}\n页面: {page_file}" - if api_text: - content += f"\n相关接口: {api_text}" - score = min(0.99, max(0.62, float(row.get("match_score") or 0.62))) - out.append( - RetrievedItem( - source="feature_catalog", - content=content[:1200], - score=score, - source_label="Feature Catalog", - metadata={ - "feature_name": name, - "route": route, - "page_file": page_file, - "page_component": page_component, - "apis": [str(x) for x in apis[:8] if str(x).strip()], - "match_score": round(score, 4), - }, - ) - ) - return out - - def _collect_feature_evidence( - self, - *, - question: str, - retrieved: list[RetrievedItem], - system_snapshot: Optional[dict[str, Any]], - ) -> list[dict[str, Any]]: - rows: list[dict[str, Any]] = [] - for item in retrieved: - if str(item.source or "").strip().lower() != "feature_catalog": - continue - meta = item.metadata if isinstance(item.metadata, dict) else {} - route = str(meta.get("route") or "").strip() - page_file = str(meta.get("page_file") or "").strip() - feature_name = str(meta.get("feature_name") or "").strip() - apis = meta.get("apis") if isinstance(meta.get("apis"), list) else [] - clean_apis = [str(x).strip() for x in apis if str(x).strip()] - if not feature_name and not route and not page_file: - continue - rows.append( - { - "feature_name": feature_name, - "route": route, - "page_file": page_file, - "apis": clean_apis[:6], - } - ) - if rows: - return rows[:4] - - # 若检索结果里没有 feature_catalog,回退到 snapshot 匹配结果 - fallback = self._match_feature_catalog_entries( - question=question, - system_snapshot=system_snapshot, - limit=3, - ) - for row in fallback: - apis = row.get("apis") if isinstance(row.get("apis"), list) else [] - rows.append( - { - "feature_name": str(row.get("name") or ""), - "route": str(row.get("route") or ""), - "page_file": str(row.get("page_file") or ""), - "apis": [str(x).strip() for x in apis[:6] if str(x).strip()], - } - ) - return rows[:3] - - def _enforce_feature_evidence_in_answer( - self, - *, - question: str, - answer_text: str, - retrieved: list[RetrievedItem], - system_snapshot: Optional[dict[str, Any]], - ) -> str: - text = str(question or "").strip().lower() - has_feature_token = any(k in text for k in FEATURE_QUERY_KEYWORDS) - ask_feature = has_feature_token and any(k in text for k in FEATURE_PURPOSE_HINTS) - if not ask_feature: - return answer_text - - evidence_rows = self._collect_feature_evidence( - question=question, - retrieved=retrieved, - system_snapshot=system_snapshot, - ) - if not evidence_rows: - return answer_text - - # 已包含明确入口与接口信息时不重复追加 - lowered_answer = str(answer_text or "").lower() - if ("/" in lowered_answer and "接口" in lowered_answer) or "证据补充" in lowered_answer: - return answer_text - - lines: list[str] = [str(answer_text or "").strip(), "", "证据补充(Feature Catalog):"] - for idx, row in enumerate(evidence_rows, start=1): - feature_name = str(row.get("feature_name") or "未知功能") - route = str(row.get("route") or "") - page_file = str(row.get("page_file") or "") - apis = row.get("apis") if isinstance(row.get("apis"), list) else [] - api_text = "、".join(str(x) for x in apis[:3] if str(x).strip()) - line = f"{idx}. {feature_name}" - if route: - line += f" | 路由: {route}" - if page_file: - line += f" | 页面: {page_file}" - if api_text: - line += f" | API: {api_text}" - lines.append(line) - return "\n".join(lines).strip()[:3200] - - def _symbol_cache_key(self) -> str: - return f"{self.tenant_id}:backend_symbol_snapshot" - - async def _get_symbol_snapshot_cached(self, *, ttl_seconds: int = 300) -> dict[str, Any]: - now = time.time() - key = self._symbol_cache_key() - cached = self._symbol_snapshot_cache.get(key) - if cached and cached[0] > now: - return dict(cached[1]) - try: - service = SymbolIndexService(self.db, tenant_id=self.tenant_id) - built = await service.get_overview(top_n_files=10, auto_sync_if_empty=True) - except Exception: - built = self._build_symbol_index_snapshot() - self._symbol_snapshot_cache[key] = (now + max(60, int(ttl_seconds)), dict(built)) - return built - - def _build_symbol_index_snapshot( - self, - *, - max_files: int = 240, - max_symbols: int = 4000, - ) -> dict[str, Any]: - backend_root = Path(__file__).resolve().parents[2] - app_root = backend_root / "app" - if not app_root.exists(): - return { - "root": str(app_root), - "indexed_files": 0, - "symbol_total": 0, - "python_symbols": 0, - "typescript_symbols": 0, - "top_modules": [], - "generated_at": now_cn_aware().isoformat(), - } - - candidates = [ - p - for p in app_root.rglob("*") - if p.is_file() and p.suffix.lower() in {".py", ".ts", ".tsx", ".js", ".jsx"} - ] - candidates.sort(key=lambda p: str(p)) - file_paths = candidates[: max(1, int(max_files))] - - python_symbols = 0 - ts_symbols = 0 - total_symbols = 0 - module_counter: dict[str, int] = {} - - for path in file_paths: - rel = str(path.relative_to(backend_root)) - module = rel.split("/", 2)[:2] - module_name = "/".join(module) if module else rel - try: - text = path.read_text(encoding="utf-8", errors="ignore") - except Exception: - continue - - symbols_in_file = 0 - if path.suffix.lower() == ".py": - extracted = self._extract_python_symbols(text) - symbols_in_file = len(extracted) - python_symbols += symbols_in_file - else: - extracted = self._extract_text_symbols(text) - symbols_in_file = len(extracted) - ts_symbols += symbols_in_file - if symbols_in_file <= 0: - continue - module_counter[module_name] = int(module_counter.get(module_name, 0)) + symbols_in_file - total_symbols += symbols_in_file - if total_symbols >= max_symbols: - break - - top_modules = [ - {"module": name, "symbols": int(count)} - for name, count in sorted(module_counter.items(), key=lambda item: item[1], reverse=True)[:10] - ] - return { - "root": str(app_root), - "indexed_files": len(file_paths), - "symbol_total": int(total_symbols), - "python_symbols": int(python_symbols), - "typescript_symbols": int(ts_symbols), - "top_modules": top_modules, - "generated_at": now_cn_aware().isoformat(), - } - - @staticmethod - def _extract_python_symbols(text: str) -> list[str]: - raw = str(text or "") - if not raw.strip(): - return [] - try: - tree = ast.parse(raw) - except Exception: - return [] - out: list[str] = [] - for node in tree.body: - if isinstance(node, ast.ClassDef): - out.append(f"class:{node.name}") - elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - out.append(f"function:{node.name}") - return out - - @staticmethod - def _extract_text_symbols(text: str) -> list[str]: - raw = str(text or "") - if not raw.strip(): - return [] - out: list[str] = [] - for match in TS_SYMBOL_RE.finditer(raw): - name = str(match.group(1) or "").strip() - if name: - out.append(name) - return out - - async def _run_single_retrieval_pass( - self, - *, - question: str, - domains: list[str], - project_id: Optional[int], - source_chat_id: Optional[str], - resolved_entities: Optional[dict[str, Any]] = None, - vector_top_k: Optional[int] = None, - ) -> tuple[list[RetrievedItem], dict[str, Any]]: - async def _run_db_retrieval() -> tuple[list[RetrievedItem], int, bool]: - if "db" not in domains: - return ([], 0, False) - started_at = time.perf_counter() - try: - rows = await asyncio.wait_for( - self._retrieve_structured_db(question, project_id=project_id), - timeout=DB_QUERY_TIMEOUT_SECONDS, - ) - return (rows, int((time.perf_counter() - started_at) * 1000), False) - except asyncio.TimeoutError: - return ([], int((time.perf_counter() - started_at) * 1000), True) - - async def _run_vector_retrieval() -> tuple[list[RetrievedItem], int, bool]: - if not any(x in domains for x in {"doc", "feishu", "pmflow", "url"}): - return ([], 0, False) - started_at = time.perf_counter() - try: - rows = await asyncio.wait_for( - self._retrieve_vector_context( - question, - domains=domains, - project_id=project_id, - source_chat_id=source_chat_id, - top_k=vector_top_k, - ), - timeout=VECTOR_SEARCH_TIMEOUT_SECONDS, - ) - return (rows, int((time.perf_counter() - started_at) * 1000), False) - except asyncio.TimeoutError: - return ([], int((time.perf_counter() - started_at) * 1000), True) - - async def _run_graph_retrieval() -> tuple[list[RetrievedItem], int, bool]: - if "graph" not in domains: - return ([], 0, False) - started_at = time.perf_counter() - try: - rows = await asyncio.wait_for( - self._retrieve_graph_context( - question, - project_id=project_id, - resolved_entities=resolved_entities, - ), - timeout=GRAPH_QUERY_TIMEOUT_SECONDS, - ) - return (rows, int((time.perf_counter() - started_at) * 1000), False) - except asyncio.TimeoutError: - return ([], int((time.perf_counter() - started_at) * 1000), True) - - (db_rows, db_ms, db_timeout), (vec_rows, vec_ms, vec_timeout), (graph_rows, graph_ms, graph_timeout) = ( - await asyncio.gather( - _run_db_retrieval(), - _run_vector_retrieval(), - _run_graph_retrieval(), - ) - ) - merged = [*db_rows, *vec_rows, *graph_rows] - merged.sort(key=lambda x: x.score, reverse=True) - return ( - merged, - { - "db_query_ms": int(db_ms), - "vector_search_ms": int(vec_ms), - "graph_query_ms": int(graph_ms), - "db_timeout": bool(db_timeout), - "vector_timeout": bool(vec_timeout), - "graph_timeout": bool(graph_timeout), - }, - ) - - async def _build_second_pass_query( - self, - *, - question: str, - project_id: Optional[int], - ) -> tuple[str, dict[str, Any]]: - resolved = await self.entity_resolution(question=question, project_id=project_id, limit=5) - manifest = await self.get_project_manifest(project_id=project_id) - hints: list[str] = [] - for term in resolved.get("canonical_terms") or []: - if str(term).strip(): - hints.append(str(term).strip()) - for row in resolved.get("matched_entities") or []: - if not isinstance(row, dict): - continue - name = str(row.get("display_name") or "").strip() - if name: - hints.append(name[:30]) - - symbol_names: list[str] = [] - try: - symbol_service = SymbolIndexService(self.db, tenant_id=self.tenant_id) - symbol_hits = await symbol_service.search_symbols(query=question, limit=8) - for row in symbol_hits.get("items") or []: - if not isinstance(row, dict): - continue - name = str(row.get("qualified_name") or row.get("symbol_name") or "").strip() - if name: - symbol_names.append(name[:60]) - except Exception: - symbol_names = [] - for name in symbol_names[:6]: - hints.append(name) - - feature_names: list[str] = [] - feature_routes: list[str] = [] - try: - system_snapshot = await self.context_cache.get_system_snapshot( - db=self.db, - tenant_id=self.tenant_id, - ) - feature_hits = self._match_feature_catalog_entries( - question=question, - system_snapshot=system_snapshot, - limit=6, - ) - for row in feature_hits: - name = str(row.get("name") or "").strip() - route = str(row.get("route") or "").strip() - if name: - feature_names.append(name[:50]) - if route: - feature_routes.append(route[:80]) - except Exception: - feature_names = [] - feature_routes = [] - for name in feature_names[:4]: - hints.append(name) - for route in feature_routes[:3]: - hints.append(route) - - symbol_top = manifest.get("symbol_summary") if isinstance(manifest.get("symbol_summary"), dict) else {} - modules = symbol_top.get("top_modules") if isinstance(symbol_top.get("top_modules"), list) else [] - module_hint = ";".join( - str(item.get("module") or "") - for item in modules[:3] - if isinstance(item, dict) and str(item.get("module") or "").strip() - ) - clean_hints = [] - for item in hints: - if item and item not in clean_hints: - clean_hints.append(item) - if not clean_hints and not module_hint: - return question[:1200], {"mode": "no_hint"} - - revised = str(question or "").strip() - if clean_hints: - revised = f"{revised}。实体线索:{', '.join(clean_hints[:6])}" - if module_hint: - revised = f"{revised}。候选模块:{module_hint}" - if symbol_names: - revised = f"{revised}。候选符号:{', '.join(symbol_names[:4])}" - if feature_names: - revised = f"{revised}。候选功能:{', '.join(feature_names[:3])}" - if feature_routes: - revised = f"{revised}。候选入口:{', '.join(feature_routes[:2])}" - return revised[:1200], { - "mode": "second_pass_query", - "hint_count": len(clean_hints), - "module_hint": module_hint, - "symbol_hit_count": len(symbol_names), - "feature_hit_count": len(feature_names), - } - - @staticmethod - def _merge_retrieved_items( - base: list[RetrievedItem], - extra: list[RetrievedItem], - *, - limit: Optional[int] = None, - ) -> list[RetrievedItem]: - seen: set[str] = set() - merged: list[RetrievedItem] = [] - for item in [*base, *extra]: - key = f"{item.source}|{item.source_label}|{item.content[:120]}" - if key in seen: - continue - seen.add(key) - merged.append(item) - merged.sort(key=lambda x: x.score, reverse=True) - cap = max(DEFAULT_TOP_K * 2, 8) - if limit is not None: - cap = max(4, min(int(limit), 80)) - return merged[:cap] - - @staticmethod - def _should_second_pass(retrieved: list[RetrievedItem]) -> bool: - if not retrieved: - return True - top_score = float(retrieved[0].score or 0.0) - if top_score < 0.55: - return True - return len(retrieved) < 3 - - @staticmethod - def _merge_search_metrics(base: dict[str, Any], delta: dict[str, Any]) -> dict[str, Any]: - return { - "db_query_ms": int(base.get("db_query_ms", 0)) + int(delta.get("db_query_ms", 0)), - "vector_search_ms": int(base.get("vector_search_ms", 0)) + int(delta.get("vector_search_ms", 0)), - "graph_query_ms": int(base.get("graph_query_ms", 0)) + int(delta.get("graph_query_ms", 0)), - "db_timeout": bool(base.get("db_timeout")) or bool(delta.get("db_timeout")), - "vector_timeout": bool(base.get("vector_timeout")) or bool(delta.get("vector_timeout")), - "graph_timeout": bool(base.get("graph_timeout")) or bool(delta.get("graph_timeout")), - } - - @staticmethod - def _summarize_retrieval_hints(rows: list[RetrievedItem], *, max_items: int = 3) -> str: - hints: list[str] = [] - for row in rows[: max(1, int(max_items))]: - label = str(row.source_label or "").strip() - if label: - hints.append(label[:60]) - continue - content = str(row.content or "").strip() - if content: - hints.append(content.replace("\n", " ")[:60]) - uniq: list[str] = [] - for item in hints: - if item and item not in uniq: - uniq.append(item) - return ";".join(uniq[:3]) - - async def _multi_step_search( - self, - *, - question: str, - domains: list[str], - project_id: Optional[int], - source_chat_id: Optional[str], - session_state: Optional[QASessionState], - max_steps_override: Optional[int] = None, - vector_top_k: Optional[int] = None, - ) -> tuple[list[RetrievedItem], dict[str, Any]]: - resolved = await self.entity_resolution(question=question, project_id=project_id, limit=6) - manifest = await self.get_project_manifest(project_id=project_id) - system_snapshot = await self.context_cache.get_system_snapshot( - db=self.db, - tenant_id=self.tenant_id, - ) - symbol_hits: dict[str, Any] = {"count": 0, "items": []} - try: - symbol_service = SymbolIndexService(self.db, tenant_id=self.tenant_id) - symbol_hits = await symbol_service.search_symbols(query=question, limit=6) - except Exception: - symbol_hits = {"count": 0, "items": []} - feature_hits = self._match_feature_catalog_entries( - question=question, - system_snapshot=system_snapshot, - limit=6, - ) - max_steps = max(1, min(int(max_steps_override or AGENT_MAX_RETRIEVAL_STEPS), 8)) - tool_trace: list[dict[str, Any]] = [ - { - "step": 0, - "tool": "entity_resolution", - "matched_entities": len(resolved.get("matched_entities") or []), - "canonical_terms": resolved.get("canonical_terms") if isinstance(resolved.get("canonical_terms"), list) else [], - }, - { - "step": 0, - "tool": "get_project_manifest", - "symbol_total": int( - ( - manifest.get("symbol_summary") - if isinstance(manifest.get("symbol_summary"), dict) - else {} - ).get("symbol_total", 0) - ), - "kg_entity_total": int( - ( - manifest.get("kg_overview") - if isinstance(manifest.get("kg_overview"), dict) - else {} - ).get("entity_total", 0) - ), - }, - { - "step": 0, - "tool": "search_symbols", - "hit_count": int(symbol_hits.get("count") or 0), - }, - { - "step": 0, - "tool": "feature_catalog_lookup", - "hit_count": len(feature_hits), - "feature_names": [str(x.get("name") or "") for x in feature_hits[:4]], - }, - ] - if session_state is not None and isinstance(session_state.last_entity_ids, list): - anchor_ids = [int(x) for x in session_state.last_entity_ids if str(x).isdigit()][:6] - else: - anchor_ids = [] - current_question = str(question or "").strip()[:1200] - current_domains: list[str] = [] - for item in domains: - if item not in current_domains: - current_domains.append(item) - merged_rows: list[RetrievedItem] = [] - merged_metrics: dict[str, Any] = { - "db_query_ms": 0, - "vector_search_ms": 0, - "graph_query_ms": 0, - "db_timeout": False, - "vector_timeout": False, - "graph_timeout": False, - } - merge_limit = max(DEFAULT_TOP_K * 2, 8) - if vector_top_k is not None: - merge_limit = max(merge_limit, max(2, int(vector_top_k)) * 3) - - for step in range(1, max_steps + 1): - resolved_payload = { - **resolved, - "anchor_entity_ids": anchor_ids, - } - step_rows, step_metrics = await self._run_single_retrieval_pass( - question=current_question, - domains=current_domains, - project_id=project_id, - source_chat_id=source_chat_id, - resolved_entities=resolved_payload, - vector_top_k=vector_top_k, - ) - feature_rows = self._retrieve_feature_catalog_context( - question=current_question, - system_snapshot=system_snapshot, - max_items=4, - ) - if feature_rows: - step_rows = self._merge_retrieved_items(feature_rows, step_rows, limit=merge_limit) - merged_rows = self._merge_retrieved_items(merged_rows, step_rows, limit=merge_limit) - merged_metrics = self._merge_search_metrics(merged_metrics, step_metrics) - top_score = round(float(step_rows[0].score), 4) if step_rows else 0.0 - tool_trace.append( - { - "step": step, - "tool": "parallel_retrieval", - "query": current_question[:240], - "domains": current_domains, - "items": len(step_rows), - "feature_items": len(feature_rows), - "top_score": top_score, - "merged_items": len(merged_rows), - } - ) - - if step >= max_steps: - tool_trace.append( - { - "step": step, - "tool": "planner_stop", - "reason": "max_steps_reached", - "max_steps": max_steps, - } - ) - break - if not self._should_second_pass(merged_rows): - tool_trace.append( - { - "step": step, - "tool": "planner_stop", - "reason": "enough_evidence", - "top_score": round(float(merged_rows[0].score), 4) if merged_rows else 0.0, - } - ) - break - - next_question, rewrite_meta = await self._build_second_pass_query( - question=current_question, - project_id=project_id, - ) - row_hint = self._summarize_retrieval_hints(step_rows) - if row_hint and row_hint not in next_question: - next_question = f"{next_question}。已命中线索:{row_hint}" - next_question = next_question[:1200] - if next_question.strip() == current_question.strip(): - tool_trace.append( - { - "step": step, - "tool": "planner_stop", - "reason": "query_not_changed", - "rewrite_meta": rewrite_meta, - } - ) - break - - next_domains: list[str] = [] - for item in [*current_domains, "graph", "db", "doc", "pmflow", "feishu"]: - if item not in next_domains: - next_domains.append(item) - tool_trace.append( - { - "step": step + 1, - "tool": "query_refine", - "rewrite_meta": rewrite_meta, - "next_query": next_question[:240], - "next_domains": next_domains, - "anchor_entity_ids": anchor_ids, - } - ) - current_question = next_question - current_domains = next_domains - - result_cap = max(DEFAULT_TOP_K * 2, 8) - if vector_top_k is not None: - result_cap = max(result_cap, max(2, int(vector_top_k)) * 2) - - return merged_rows[:result_cap], { - **merged_metrics, - "domains_used": current_domains, - "tool_trace": tool_trace, - } - - async def multi_step_search( - self, - *, - question: str, - project_id: Optional[int] = None, - source_chat_id: Optional[str] = None, - session_state: Optional[QASessionState] = None, - ) -> dict[str, Any]: - domains = self._route_domains(question) - rows, metrics = await self._multi_step_search( - question=question, - domains=domains, - project_id=project_id, - source_chat_id=source_chat_id, - session_state=session_state, - ) - return { - "question": question, - "domains": metrics.get("domains_used") or domains, - "items": self._dedupe_sources(rows), - "retrieval_metrics": { - "db_query_ms": int(metrics.get("db_query_ms") or 0), - "vector_search_ms": int(metrics.get("vector_search_ms") or 0), - "graph_query_ms": int(metrics.get("graph_query_ms") or 0), - "db_timeout": bool(metrics.get("db_timeout")), - "vector_timeout": bool(metrics.get("vector_timeout")), - "graph_timeout": bool(metrics.get("graph_timeout")), - }, - "tool_trace": metrics.get("tool_trace") if isinstance(metrics.get("tool_trace"), list) else [], - } - - # ------------------------------------------------------------------ - # Retrieval - # ------------------------------------------------------------------ - def _route_domains(self, question: str) -> list[str]: - text = question.lower() - domains: list[str] = [] - is_referential = any(k in text for k in FOLLOWUP_REFERENTIAL_KEYWORDS) - - if self._is_people_query(text): - domains.append("db") - if any(k in text for k in PROJECT_KEYWORDS): - domains.append("db") - domains.append("graph") - if is_referential: - domains.append("graph") - if any(k in text for k in CODE_KEYWORDS): - domains.append("graph") - domains.append("doc") - if any(k in text for k in FEATURE_QUERY_KEYWORDS): - domains.append("doc") - domains.append("graph") - domains.append("pmflow") - if any(k in text for k in DOC_KEYWORDS): - domains.append("doc") - if any(k in text for k in HISTORY_KEYWORDS): - domains.append("feishu") - if any(k in text for k in PMFLOW_KEYWORDS): - domains.append("pmflow") - if self._is_engine_process_query(text): - domains.append("graph") - domains.append("doc") - domains.append("pmflow") - domains.append("db") - if URL_RE.search(text) or "根据" in text and "http" in text: - domains.append("url") - - if not domains: - # 模糊问题:并行检索所有向量域 - domains.extend(["graph", "doc", "feishu", "pmflow", "url"]) - - # 去重并保持顺序 - uniq: list[str] = [] - for item in domains: - if item not in uniq: - uniq.append(item) - return uniq - - async def _retrieve_structured_db(self, question: str, *, project_id: Optional[int]) -> list[RetrievedItem]: - text = question.lower() - rows: list[RetrievedItem] = [] - ask_people = self._is_people_query(text) - - if ask_people: - scoped_project_id: Optional[int] = None - scoped_project_name: Optional[str] = None - if "项目" in text or project_id is not None: - scoped_project_id, scoped_project_name, _ = await self._resolve_project_scope_for_question( - question=text, - project_id=project_id, - ) - user_stmt = select(User).where( - and_( - User.tenant_id == self.tenant_id, - User.is_active.is_(True), - ) - ) - if scoped_project_id is not None: - member_subq = select(UserProject.user_id).where( - and_( - UserProject.tenant_id == self.tenant_id, - UserProject.project_id == int(scoped_project_id), - ) - ) - user_stmt = user_stmt.where(User.id.in_(member_subq)) - members = ( - await self.db.execute( - user_stmt.order_by(User.updated_at.desc(), User.id.desc()).limit(20) - ) - ).scalars().all() - if members: - preview = ";".join( - f"#{int(row.id)} {str(row.name or f'User#{int(row.id)}')}(角色:{str(row.role or 'unknown')})" - for row in members[:10] - ) - rows.append( - RetrievedItem( - source="db", - content=( - f"成员清单({len(members)}人):{preview}" - if scoped_project_name is None - else f"项目「{scoped_project_name}」成员清单({len(members)}人):{preview}" - ), - score=0.95, - source_label="User database", - metadata={ - "table": "users", - "count": len(members), - "project_id": scoped_project_id, - "project_name": scoped_project_name, - }, - ) - ) - - if "项目" in text and not ask_people: - stmt = select(Project).where(Project.tenant_id == self.tenant_id) - if project_id: - stmt = stmt.where(Project.id == project_id) - projects = (await self.db.execute(stmt.limit(8))).scalars().all() - if projects: - summary = ";".join( - f"#{p.id} {p.title or p.name}(状态:{p.status})" for p in projects - ) - rows.append( - RetrievedItem( - source="db", - content=f"项目列表:{summary}", - score=0.93, - source_label="Project database", - metadata={"table": "projects", "count": len(projects)}, - ) - ) - - if "信号" in text or "待处理" in text: - signal_stmt = select(Signal).where(Signal.tenant_id == self.tenant_id) - if project_id: - signal_stmt = signal_stmt.where( - or_( - Signal.matched_project_id == project_id, - Signal.content.ilike(f"%#{project_id}%"), - ) - ) - pending = ( - await self.db.execute(signal_stmt.order_by(Signal.created_at.desc()).limit(10)) - ).scalars().all() - if pending: - preview = ";".join((s.summary or s.content or "")[:80] for s in pending[:5]) - rows.append( - RetrievedItem( - source="db", - content=f"最近信号({len(pending)}条):{preview}", - score=0.9, - source_label="Project database", - metadata={"table": "signals", "count": len(pending)}, - ) - ) - - if "任务" in text or "进度" in text: - task_stmt = select(Task).where(Task.tenant_id == self.tenant_id) - if project_id: - task_stmt = task_stmt.where(Task.project_id == project_id) - tasks = (await self.db.execute(task_stmt.order_by(Task.updated_at.desc()).limit(12))).scalars().all() - if tasks: - open_tasks = [t for t in tasks if str(t.status or "").lower() not in {"done", "completed", "已完成"}] - owners = [str(t.assignee_id) for t in open_tasks if t.assignee_id is not None] - rows.append( - RetrievedItem( - source="db", - content=( - f"任务总数 {len(tasks)},未完成 {len(open_tasks)}。" - f"当前负责人ID(去重): {', '.join(sorted(set(owners))[:8]) or '暂无'}" - ), - score=0.91, - source_label="Project database", - metadata={"table": "tasks", "open_count": len(open_tasks)}, - ) - ) - - if "宪章" in text or "charter" in text: - charter_stmt = select(TruthNode).where(TruthNode.tenant_id == self.tenant_id) - if project_id: - charter_stmt = charter_stmt.where(TruthNode.project_id == project_id) - charters = (await self.db.execute(charter_stmt.order_by(TruthNode.updated_at.desc()).limit(6))).scalars().all() - if charters: - rows.append( - RetrievedItem( - source="db", - content="\n".join( - f"宪章#{c.id}: {(c.title or '无标题')} / {str(c.content or '')[:200]}" for c in charters - ), - score=0.92, - source_label="Project database", - metadata={"table": "truth_nodes", "count": len(charters)}, - ) - ) - - if "工作量" in text or "负载" in text: - workload_stmt = select(UserWorkload).where(UserWorkload.tenant_id == self.tenant_id) - workloads = (await self.db.execute(workload_stmt.order_by(UserWorkload.updated_at.desc()).limit(10))).scalars().all() - if workloads: - content = ";".join( - f"user#{w.user_id} 已分配 {float(w.current_assigned_hours or 0):.1f}h / " - f"最大 {float(w.max_available_hours_per_week or 0):.1f}h,负载比 {float(w.workload_ratio or 0):.2f}" - for w in workloads - ) - rows.append( - RetrievedItem( - source="db", - content=f"团队工作量:{content}", - score=0.88, - source_label="Project database", - metadata={"table": "user_workloads", "count": len(workloads)}, - ) - ) - - return rows[:DEFAULT_TOP_K] - - async def _retrieve_graph_context( - self, - question: str, - *, - project_id: Optional[int], - resolved_entities: Optional[dict[str, Any]] = None, - ) -> list[RetrievedItem]: - try: - resolution = resolved_entities or {} - matched_entities = ( - resolution.get("matched_entities") if isinstance(resolution.get("matched_entities"), list) else [] - ) - anchor_ids_raw = ( - resolution.get("anchor_entity_ids") if isinstance(resolution.get("anchor_entity_ids"), list) else [] - ) - anchor_ids: list[int] = [] - for value in anchor_ids_raw: - try: - anchor_ids.append(int(value)) - except Exception: - continue - - seed_entities: list[KGEntity] = [] - if matched_entities: - ids: list[int] = [] - for row in matched_entities: - if not isinstance(row, dict): - continue - try: - ids.append(int(row.get("entity_id"))) - except Exception: - continue - if ids: - seed_entities = ( - await self.db.execute( - select(KGEntity).where( - and_( - KGEntity.tenant_id == self.tenant_id, - KGEntity.id.in_(ids), - ) - ) - ) - ).scalars().all() - - if not seed_entities: - terms = self._extract_query_terms(question, preferred=self._extract_project_lookup_keyword(question)) - if terms: - conds = [] - for term in terms[:6]: - like = f"%{term}%" - conds.append(KGEntity.display_name.ilike(like)) - conds.append(KGEntity.entity_key.ilike(like)) - seed_entities = ( - await self.db.execute( - select(KGEntity) - .where( - and_( - KGEntity.tenant_id == self.tenant_id, - or_(*conds), - ) - ) - .order_by(KGEntity.last_seen_at.desc(), KGEntity.id.desc()) - .limit(15) - ) - ).scalars().all() - if not seed_entities and anchor_ids: - seed_entities = ( - await self.db.execute( - select(KGEntity).where( - and_( - KGEntity.tenant_id == self.tenant_id, - KGEntity.id.in_(anchor_ids[:10]), - ) - ) - ) - ).scalars().all() - if not seed_entities: - return [] - - seed_ids = [int(x.id) for x in seed_entities] - relation_rows = ( - await self.db.execute( - select(KGRelation) - .where( - and_( - KGRelation.tenant_id == self.tenant_id, - or_( - KGRelation.subject_entity_id.in_(seed_ids), - KGRelation.object_entity_id.in_(seed_ids), - ), - ) - ) - .order_by(KGRelation.weight.desc(), KGRelation.id.desc()) - .limit(120) - ) - ).scalars().all() - - entity_ids: set[int] = set(seed_ids) - for rel in relation_rows: - entity_ids.add(int(rel.subject_entity_id)) - entity_ids.add(int(rel.object_entity_id)) - entities = ( - await self.db.execute( - select(KGEntity).where( - and_( - KGEntity.tenant_id == self.tenant_id, - KGEntity.id.in_(list(entity_ids)), - ) - ) - ) - ).scalars().all() - entity_map = {int(row.id): row for row in entities} - - relation_ids = [int(row.id) for row in relation_rows] - evidence_rows: list[KGEvidence] = [] - if relation_ids or entity_ids: - evidence_rows = ( - await self.db.execute( - select(KGEvidence) - .where( - and_( - KGEvidence.tenant_id == self.tenant_id, - or_( - KGEvidence.relation_id.in_(relation_ids or [-1]), - KGEvidence.entity_id.in_(list(entity_ids) or [-1]), - ), - ) - ) - .order_by(KGEvidence.occurred_at.desc(), KGEvidence.id.desc()) - .limit(220) - ) - ).scalars().all() - evidence_by_relation: dict[int, str] = {} - evidence_by_entity: dict[int, str] = {} - for ev in evidence_rows: - excerpt = str(ev.content_excerpt or "").strip() - if not excerpt: - continue - if ev.relation_id is not None and int(ev.relation_id) not in evidence_by_relation: - evidence_by_relation[int(ev.relation_id)] = excerpt[:320] - if ev.entity_id is not None and int(ev.entity_id) not in evidence_by_entity: - evidence_by_entity[int(ev.entity_id)] = excerpt[:320] - - out: list[RetrievedItem] = [] - for rel in relation_rows: - subject = entity_map.get(int(rel.subject_entity_id)) - target = entity_map.get(int(rel.object_entity_id)) - if subject is None or target is None: - continue - subject_payload = self._safe_dict(subject.payload) - target_payload = self._safe_dict(target.payload) - if project_id is not None: - subject_pid = subject_payload.get("project_id") - target_pid = target_payload.get("project_id") - if subject_pid is not None and int(subject_pid) != int(project_id): - if target_pid is not None and int(target_pid) != int(project_id): - continue - evidence_text = evidence_by_relation.get(int(rel.id)) - if not evidence_text: - evidence_text = evidence_by_entity.get(int(subject.id)) or evidence_by_entity.get(int(target.id)) or "" - content = ( - f"图路径: {str(subject.display_name or subject.entity_key)} --[{str(rel.relation_type)}]--> " - f"{str(target.display_name or target.entity_key)}\n" - f"证据: {evidence_text or '无显式证据摘录'}" - ) - weight = self._to_float(rel.weight, 1.0) - score = min(0.99, max(0.45, 0.62 + min(weight, 1.5) * 0.18)) - out.append( - RetrievedItem( - source="graph", - content=content[:1200], - score=score, - source_label="Knowledge Graph", - metadata={ - "relation_id": int(rel.id), - "relation_type": str(rel.relation_type or ""), - "subject_entity_id": int(subject.id), - "object_entity_id": int(target.id), - "subject_name": str(subject.display_name or subject.entity_key), - "object_name": str(target.display_name or target.entity_key), - "path": f"{subject.id}->{str(rel.relation_type)}->{target.id}", - }, - ) - ) - - if not out: - for seed in seed_entities[:DEFAULT_TOP_K]: - payload = self._safe_dict(seed.payload) - if project_id is not None and payload.get("project_id") is not None: - if int(payload.get("project_id")) != int(project_id): - continue - excerpt = evidence_by_entity.get(int(seed.id)) or "" - out.append( - RetrievedItem( - source="graph", - content=f"实体: {str(seed.display_name or seed.entity_key)}\n证据: {excerpt or '无显式证据摘录'}", - score=0.58, - source_label="Knowledge Graph", - metadata={ - "entity_id": int(seed.id), - "entity_type": str(seed.entity_type or ""), - "entity_key": str(seed.entity_key or ""), - }, - ) - ) - out.sort(key=lambda x: x.score, reverse=True) - return out[: max(DEFAULT_TOP_K, 6)] - except Exception: - return [] - - async def _retrieve_vector_context( - self, - question: str, - *, - domains: list[str], - project_id: Optional[int], - source_chat_id: Optional[str], - top_k: Optional[int] = None, - ) -> list[RetrievedItem]: - question_embedding = await self.llm.get_embedding_async( - question, - db=self.db, - tenant_id=self.tenant_id, - ) - if not question_embedding: - return [] - - source_types = self._map_domains_to_source_types(domains) - if not source_types: - return [] - limit = max(1, min(int(top_k or DEFAULT_TOP_K), 40)) - if self._is_postgresql_session(): - pg_rows = await self._retrieve_vector_context_pg( - question_embedding=question_embedding, - source_types=source_types, - project_id=project_id, - source_chat_id=source_chat_id, - limit=limit, - ) - if pg_rows is not None: - return pg_rows - - return await self._retrieve_vector_context_legacy( - question_embedding=question_embedding, - source_types=source_types, - project_id=project_id, - source_chat_id=source_chat_id, - limit=limit, - ) - - def _is_postgresql_session(self) -> bool: - try: - bind = self.db.get_bind() - except Exception: - return False - dialect_name = str(getattr(getattr(bind, "dialect", None), "name", "")).lower() - return dialect_name.startswith("postgres") - - @staticmethod - def _vector_literal(values: list[float]) -> str: - normalized: list[str] = [] - for raw in values: - try: - normalized.append(f"{float(raw):.8f}") - except Exception: - normalized.append("0.0") - return "[" + ",".join(normalized) + "]" - - async def _retrieve_vector_context_pg( - self, - *, - question_embedding: list[float], - source_types: list[str], - project_id: Optional[int], - source_chat_id: Optional[str], - limit: int, - ) -> Optional[list[RetrievedItem]]: - query_vec = self._vector_literal(question_embedding) - scoped_project_id = int(project_id) if project_id is not None else 0 - scoped_source_chat_id = str(source_chat_id or "").strip() - stmt = text( - """ - SELECT - id, - source_type, - content, - project_id, - "metadata" AS chunk_metadata, - 1 - (embedding <=> CAST(:query_vec AS vector)) AS similarity - FROM knowledge_base_chunks - WHERE tenant_id = :tenant_id - AND source_type IN :source_types - AND embedding IS NOT NULL - AND ( - :project_scope = FALSE - OR (source_type IN ('feishu', 'pmflow') AND project_id = :project_id) - OR (source_type NOT IN ('feishu', 'pmflow') AND (project_id = :project_id OR project_id IS NULL)) - ) - AND ( - :source_chat_id = '' - OR source_type <> 'feishu' - OR COALESCE("metadata"->>'chat_id', '') = '' - OR "metadata"->>'chat_id' = :source_chat_id - ) - ORDER BY embedding <=> CAST(:query_vec AS vector) - LIMIT :limit - """ - ).bindparams(bindparam("source_types", expanding=True)) - try: - rows = ( - await self.db.execute( - stmt, - { - "tenant_id": self.tenant_id, - "source_types": list(source_types), - "project_scope": project_id is not None, - "project_id": scoped_project_id, - "source_chat_id": scoped_source_chat_id, - "query_vec": query_vec, - "limit": int(limit), - }, - ) - ).mappings().all() - except Exception: - return None - - scored: list[RetrievedItem] = [] - for row in rows: - source_type = str(row.get("source_type") or "doc") - meta = self._safe_dict(row.get("chunk_metadata")).copy() - chunk_project_id_raw = row.get("project_id") - chunk_project_id = int(chunk_project_id_raw) if chunk_project_id_raw is not None else None - if chunk_project_id is not None: - meta.setdefault("project_id", chunk_project_id) - - if source_type == "pmflow": - target_url = str(meta.get("target_url") or "").strip() - if not target_url: - target_url = self._build_pmflow_target_url( - record_type=str(meta.get("record_type") or ""), - record_id=meta.get("record_id"), - project_id=meta.get("project_id"), - ) - if target_url: - meta["target_url"] = target_url - - score = self._to_float(row.get("similarity"), 0.0) - if score <= 0: - continue - label = self._source_label(source_type, meta) - scored.append( - RetrievedItem( - source=source_type, - content=str(row.get("content") or "")[:1200], - score=score, - source_label=label, - metadata=meta, - ) - ) - scored.sort(key=lambda x: x.score, reverse=True) - return scored[:limit] - - async def _retrieve_vector_context_legacy( - self, - *, - question_embedding: list[float], - source_types: list[str], - project_id: Optional[int], - source_chat_id: Optional[str], - limit: int, - ) -> list[RetrievedItem]: - stmt = select(KnowledgeBaseChunk).where( - and_( - KnowledgeBaseChunk.tenant_id == self.tenant_id, - KnowledgeBaseChunk.source_type.in_(source_types), - ) - ) - - scan_limit = max(120, min(1200, limit * 20)) - chunks = (await self.db.execute(stmt.limit(scan_limit))).scalars().all() - scored: list[RetrievedItem] = [] - for chunk in chunks: - meta = self._safe_dict(chunk.chunk_metadata).copy() - chunk_project_id = int(chunk.project_id) if chunk.project_id is not None else None - if chunk_project_id is not None: - meta.setdefault("project_id", chunk_project_id) - - if project_id is not None: - if chunk.source_type in {"feishu", "pmflow"}: - if chunk_project_id != int(project_id): - continue - elif chunk_project_id is not None and chunk_project_id != int(project_id): - continue - - if chunk.source_type == "feishu" and source_chat_id: - chat_id = str(meta.get("chat_id") or "").strip() - if chat_id and chat_id != str(source_chat_id): - continue - if chunk.source_type == "pmflow": - target_url = str(meta.get("target_url") or "").strip() - if not target_url: - target_url = self._build_pmflow_target_url( - record_type=str(meta.get("record_type") or ""), - record_id=meta.get("record_id"), - project_id=meta.get("project_id"), - ) - if target_url: - meta["target_url"] = target_url - score = self._cosine_similarity(question_embedding, self._embedding_to_list(chunk.embedding)) - if score <= 0: - continue - label = self._source_label(chunk.source_type, meta) - scored.append( - RetrievedItem( - source=str(chunk.source_type or "doc"), - content=str(chunk.content or "")[:1200], - score=score, - source_label=label, - metadata=meta, - ) - ) - scored.sort(key=lambda x: x.score, reverse=True) - return scored[:limit] - - # ------------------------------------------------------------------ - # Knowledge indexing - # ------------------------------------------------------------------ - async def create_knowledge_doc( - self, - *, - title: str, - file_type: str, - raw_content: str, - uploaded_by: Optional[int], - source_type: str = "file", - file_url: Optional[str] = None, - project_id: Optional[int] = None, - ) -> dict[str, Any]: - doc = await self.create_knowledge_doc_placeholder( - title=title, - file_type=file_type, - uploaded_by=uploaded_by, - source_type=source_type, - file_url=file_url, - ) - await self._index_doc_content( - doc=doc, - raw_content=raw_content, - project_id=project_id, - source_type=source_type, - ) - return self._serialize_doc(doc) - - async def create_knowledge_doc_placeholder( - self, - *, - title: str, - file_type: str, - uploaded_by: Optional[int], - source_type: str = "file", - file_url: Optional[str] = None, - ) -> KnowledgeBaseDoc: - doc = KnowledgeBaseDoc( - tenant_id=self.tenant_id, - title=(title or "未命名文档")[:255], - file_type=(file_type or "txt")[:32], - source_type=(source_type or "file")[:32], - file_url=(file_url or "")[:1024] or None, - uploaded_by=uploaded_by, - status="processing", - last_fetched_at=now_cn_naive() if source_type == "url" else None, - ) - self.db.add(doc) - await self.db.commit() - await self.db.refresh(doc) - return doc - - async def process_knowledge_doc_content( - self, - *, - doc_id: int, - raw_content: str, - project_id: Optional[int] = None, - ) -> dict[str, Any]: - doc = ( - await self.db.execute( - select(KnowledgeBaseDoc).where( - and_( - KnowledgeBaseDoc.id == int(doc_id), - KnowledgeBaseDoc.tenant_id == self.tenant_id, - ) - ) - ) - ).scalar_one_or_none() - if not doc: - raise ValueError("doc_not_found") - await self._index_doc_content( - doc=doc, - raw_content=raw_content, - project_id=project_id, - source_type=str(doc.source_type or "file"), - ) - return self._serialize_doc(doc) - - async def _index_doc_content( - self, - *, - doc: KnowledgeBaseDoc, - raw_content: str, - project_id: Optional[int], - source_type: str, - ) -> None: - clean_content = str(raw_content or "").strip() - if not clean_content: - doc.status = "failed" - doc.error_message = "文档内容为空,无法向量化" - await self.db.commit() - return - - try: - await self.db.execute( - delete(KnowledgeBaseChunk).where( - and_( - KnowledgeBaseChunk.tenant_id == self.tenant_id, - KnowledgeBaseChunk.doc_id == int(doc.id), - ) - ) - ) - - chunk_items = self._chunk_text_with_metadata(clean_content, chunk_size=500, overlap=50) - for idx, (part, extra_meta) in enumerate(chunk_items): - emb = await self.llm.get_embedding_async(part, db=self.db, tenant_id=self.tenant_id) - chunk_metadata = { - "title": doc.title, - "file_type": doc.file_type, - "source_type": source_type, - "file_url": doc.file_url, - } - chunk_metadata.update(extra_meta) - self.db.add( - KnowledgeBaseChunk( - tenant_id=self.tenant_id, - doc_id=int(doc.id), - source_type="url" if source_type == "url" else "doc", - project_id=project_id, - content=part, - embedding=emb if emb else None, - chunk_index=idx, - chunk_metadata=chunk_metadata, - ) - ) - doc.status = "complete" - doc.error_message = None - if source_type == "url": - doc.last_fetched_at = now_cn_naive() - await self.db.commit() - except Exception as exc: - doc.status = "failed" - doc.error_message = str(exc)[:500] - await self.db.commit() - raise - - async def list_knowledge_docs(self) -> list[dict[str, Any]]: - docs = ( - await self.db.execute( - select(KnowledgeBaseDoc) - .where(KnowledgeBaseDoc.tenant_id == self.tenant_id) - .order_by(KnowledgeBaseDoc.created_at.desc()) - .limit(200) - ) - ).scalars().all() - return [ - self._serialize_doc(d) - for d in docs - ] - - async def get_knowledge_doc(self, *, doc_id: int) -> dict[str, Any]: - doc = ( - await self.db.execute( - select(KnowledgeBaseDoc).where( - and_( - KnowledgeBaseDoc.id == int(doc_id), - KnowledgeBaseDoc.tenant_id == self.tenant_id, - ) - ) - ) - ).scalar_one_or_none() - if not doc: - raise ValueError("doc_not_found") - return self._serialize_doc(doc) - - async def delete_knowledge_doc(self, *, doc_id: int) -> bool: - doc = ( - await self.db.execute( - select(KnowledgeBaseDoc).where( - and_( - KnowledgeBaseDoc.id == int(doc_id), - KnowledgeBaseDoc.tenant_id == self.tenant_id, - ) - ) - ) - ).scalar_one_or_none() - if not doc: - return False - - await self.db.execute( - delete(KnowledgeBaseChunk).where( - and_( - KnowledgeBaseChunk.doc_id == int(doc_id), - KnowledgeBaseChunk.tenant_id == self.tenant_id, - ) - ) - ) - await self.db.delete(doc) - await self.db.commit() - return True - - async def ingest_feishu_message( - self, - *, - content: str, - sender: str, - chat_id: str, - message_id: Optional[str], - captured_at: Optional[datetime] = None, - project_id: Optional[int] = None, - ) -> None: - text = str(content or "").strip() - if not text: - return - chunks = self._chunk_text(text, chunk_size=320, overlap=40) - for idx, part in enumerate(chunks): - emb = await self.llm.get_embedding_async(part, db=self.db, tenant_id=self.tenant_id) - self.db.add( - KnowledgeBaseChunk( - tenant_id=self.tenant_id, - doc_id=None, - source_type="feishu", - project_id=project_id, - content=part, - embedding=emb if emb else None, - chunk_index=idx, - chunk_metadata={ - "sender": sender, - "chat_id": chat_id, - "message_id": message_id, - "captured_at": (captured_at or now_cn_aware()).isoformat(), - }, - ) - ) - await self.db.commit() - - async def mark_doc_processing(self, *, doc_id: int) -> dict[str, Any]: - doc = ( - await self.db.execute( - select(KnowledgeBaseDoc).where( - and_( - KnowledgeBaseDoc.id == int(doc_id), - KnowledgeBaseDoc.tenant_id == self.tenant_id, - ) - ) - ) - ).scalar_one_or_none() - if not doc: - raise ValueError("doc_not_found") - doc.status = "processing" - doc.error_message = None - await self.db.commit() - await self.db.refresh(doc) - return self._serialize_doc(doc) - - @staticmethod - async def ingest_feishu_message_async( - *, - tenant_id: int, - content: str, - sender: str, - chat_id: str, - message_id: Optional[str], - captured_at: Optional[datetime] = None, - project_id: Optional[int] = None, - ) -> None: - async with AsyncSessionLocal() as session: - service = AdaptiveRAGService(session, tenant_id=tenant_id) - try: - await service.ingest_feishu_message( - content=content, - sender=sender, - chat_id=chat_id, - message_id=message_id, - captured_at=captured_at, - project_id=project_id, - ) - except Exception: - await session.rollback() - - @staticmethod - async def process_knowledge_doc_content_async( - *, - tenant_id: int, - doc_id: int, - raw_content: str, - project_id: Optional[int] = None, - ) -> None: - async with AsyncSessionLocal() as session: - service = AdaptiveRAGService(session, tenant_id=tenant_id) - try: - await service.process_knowledge_doc_content( - doc_id=doc_id, - raw_content=raw_content, - project_id=project_id, - ) - except Exception: - await session.rollback() - - async def refresh_url_doc(self, *, doc_id: int, project_id: Optional[int] = None) -> dict[str, Any]: - doc = ( - await self.db.execute( - select(KnowledgeBaseDoc).where( - and_( - KnowledgeBaseDoc.id == int(doc_id), - KnowledgeBaseDoc.tenant_id == self.tenant_id, - ) - ) - ) - ).scalar_one_or_none() - if not doc: - raise ValueError("doc_not_found") - if str(doc.source_type or "").lower() != "url": - raise ValueError("doc_is_not_url_source") - if not str(doc.file_url or "").strip(): - raise ValueError("doc_url_missing") - text = await self._fetch_url_text(str(doc.file_url)) - await self._index_doc_content( - doc=doc, - raw_content=text, - project_id=project_id, - source_type="url", - ) - await self.db.refresh(doc) - return self._serialize_doc(doc) - - @staticmethod - async def refresh_url_doc_async( - *, - tenant_id: int, - doc_id: int, - project_id: Optional[int] = None, - ) -> None: - async with AsyncSessionLocal() as session: - service = AdaptiveRAGService(session, tenant_id=tenant_id) - try: - await service.refresh_url_doc(doc_id=doc_id, project_id=project_id) - except Exception: - await session.rollback() - - async def upsert_pmflow_record( - self, - *, - record_type: str, - record_id: int, - project_id: Optional[int], - title: str, - content: str, - ) -> None: - safe_type = str(record_type or "").strip().lower()[:32] - safe_id = int(record_id) - if not safe_type or safe_id <= 0: - return - - stmt = select(KnowledgeBaseChunk).where( - and_( - KnowledgeBaseChunk.tenant_id == self.tenant_id, - KnowledgeBaseChunk.source_type == "pmflow", - ) - ) - if project_id is not None: - stmt = stmt.where(KnowledgeBaseChunk.project_id == int(project_id)) - existing = (await self.db.execute(stmt.limit(2000))).scalars().all() - for row in existing: - meta = self._safe_dict(row.chunk_metadata) - if str(meta.get("record_type") or "").strip().lower() != safe_type: - continue - if str(meta.get("record_id") or "").strip() != str(safe_id): - continue - await self.db.delete(row) - - text = f"{title}\n\n{content}".strip() - chunks = self._chunk_text(text, chunk_size=500, overlap=50) - for idx, part in enumerate(chunks): - emb = await self.llm.get_embedding_async(part, db=self.db, tenant_id=self.tenant_id) - target_url = self._build_pmflow_target_url( - record_type=safe_type, - record_id=safe_id, - project_id=project_id, - ) - self.db.add( - KnowledgeBaseChunk( - tenant_id=self.tenant_id, - doc_id=None, - source_type="pmflow", - project_id=project_id, - content=part, - embedding=emb if emb else None, - chunk_index=idx, - chunk_metadata={ - "record_type": safe_type, - "record_id": safe_id, - "project_id": int(project_id) if project_id is not None else None, - "target_url": target_url, - "title": title[:255], - "updated_at": now_cn_aware().isoformat(), - }, - ) - ) - await self.db.commit() - - @staticmethod - async def upsert_pmflow_record_async( - *, - tenant_id: int, - record_type: str, - record_id: int, - project_id: Optional[int], - title: str, - content: str, - ) -> None: - async with AsyncSessionLocal() as session: - service = AdaptiveRAGService(session, tenant_id=tenant_id) - try: - await service.upsert_pmflow_record( - record_type=record_type, - record_id=record_id, - project_id=project_id, - title=title, - content=content, - ) - except Exception: - await session.rollback() - - @staticmethod - def _build_intent_correction_record( - *, - user_utterance: str, - wrong_interpretation: str, - correct_intent: str, - canonical_action: str, - clarification_template: str, - risk_level: str, - project_id: Optional[int], - ) -> tuple[int, str, str, dict[str, Any]]: - utterance = " ".join(str(user_utterance or "").split()).strip()[:500] - wrong = " ".join(str(wrong_interpretation or "").split()).strip()[:300] - correct = " ".join(str(correct_intent or "").split()).strip().lower()[:60] - action = " ".join(str(canonical_action or "").split()).strip().lower()[:80] - clarification = " ".join(str(clarification_template or "").split()).strip()[:500] - risk = " ".join(str(risk_level or "medium").split()).strip().lower()[:20] or "medium" - - fingerprint = hashlib.sha1( - "||".join([utterance, wrong, correct, action, clarification, risk]).encode("utf-8") - ).hexdigest() - record_id = int(fingerprint[:12], 16) - title = f"意图纠错/{correct or 'unknown'}" - content = ( - f"用户原话: {utterance}\n" - f"错误理解: {wrong}\n" - f"正确意图: {correct}\n" - f"标准动作: {action}\n" - f"澄清模板: {clarification}\n" - f"风险等级: {risk}" - )[:4000] - metadata = { - "record_type": "intent_correction", - "record_id": str(record_id), - "project_id": int(project_id) if project_id is not None else None, - "user_utterance": utterance, - "wrong_interpretation": wrong, - "correct_intent": correct, - "canonical_action": action, - "clarification_template": clarification, - "risk_level": risk, - "target_url": None, - "title": title, - "updated_at": now_cn_aware().isoformat(), - } - return record_id, title, content, metadata - - async def upsert_intent_correction_sample( - self, - *, - user_utterance: str, - wrong_interpretation: str, - correct_intent: str, - canonical_action: str, - clarification_template: str, - risk_level: str, - project_id: Optional[int] = None, - ) -> dict[str, Any]: - record_id, title, content, metadata = self._build_intent_correction_record( - user_utterance=user_utterance, - wrong_interpretation=wrong_interpretation, - correct_intent=correct_intent, - canonical_action=canonical_action, - clarification_template=clarification_template, - risk_level=risk_level, - project_id=project_id, - ) - if not metadata["user_utterance"] or not metadata["correct_intent"]: - raise ValueError("intent correction sample missing required fields") - - stmt = select(KnowledgeBaseChunk).where( - and_( - KnowledgeBaseChunk.tenant_id == self.tenant_id, - KnowledgeBaseChunk.source_type == "pmflow", - ) - ) - existing = (await self.db.execute(stmt.limit(2000))).scalars().all() - for row in existing: - meta = self._safe_dict(row.chunk_metadata) - if str(meta.get("record_type") or "").strip().lower() != "intent_correction": - continue - if str(meta.get("record_id") or "").strip() != str(record_id): - continue - await self.db.delete(row) - - embedding = await self.llm.get_embedding_async(content, db=self.db, tenant_id=self.tenant_id) - self.db.add( - KnowledgeBaseChunk( - tenant_id=self.tenant_id, - doc_id=None, - source_type="pmflow", - project_id=int(project_id) if project_id is not None else None, - content=content, - embedding=embedding if embedding else None, - chunk_index=0, - chunk_metadata=metadata, - ) - ) - await self.db.commit() - return metadata - - async def retrieve_intent_correction_samples( - self, - *, - utterance: str, - top_k: int = 3, - project_id: Optional[int] = None, - ) -> list[dict[str, Any]]: - text = " ".join(str(utterance or "").split()).strip() - if len(text) < 2: - return [] - question_embedding = await self.llm.get_embedding_async( - text, - db=self.db, - tenant_id=self.tenant_id, - ) - if not question_embedding: - return [] - - rows = ( - await self.db.execute( - select(KnowledgeBaseChunk).where( - and_( - KnowledgeBaseChunk.tenant_id == self.tenant_id, - KnowledgeBaseChunk.source_type == "pmflow", - ) - ).limit(500) - ) - ).scalars().all() - scored: list[dict[str, Any]] = [] - for row in rows: - meta = self._safe_dict(row.chunk_metadata) - if str(meta.get("record_type") or "").strip().lower() != "intent_correction": - continue - try: - chunk_project_id = int(meta.get("project_id")) if meta.get("project_id") is not None else None - except Exception: - chunk_project_id = None - if project_id is not None and chunk_project_id is not None and chunk_project_id != int(project_id): - continue - score = self._cosine_similarity(question_embedding, self._embedding_to_list(row.embedding)) - if score <= 0: - continue - scored.append( - { - "score": round(float(score), 4), - "record_id": str(meta.get("record_id") or ""), - "user_utterance": str(meta.get("user_utterance") or ""), - "wrong_interpretation": str(meta.get("wrong_interpretation") or ""), - "correct_intent": str(meta.get("correct_intent") or ""), - "canonical_action": str(meta.get("canonical_action") or ""), - "clarification_template": str(meta.get("clarification_template") or ""), - "risk_level": str(meta.get("risk_level") or "medium"), - "project_id": chunk_project_id, - } - ) - scored.sort(key=lambda x: float(x.get("score") or 0.0), reverse=True) - return scored[: max(1, min(int(top_k or 3), 10))] - - # ------------------------------------------------------------------ - # QA session helpers - # ------------------------------------------------------------------ - @staticmethod - def _to_naive_utc(value: Optional[datetime]) -> Optional[datetime]: - if value is None: - return None - if value.tzinfo is None: - return value - return value.astimezone(CN_TZ).replace(tzinfo=None) - - @staticmethod - def _normalize_session_title_raw(text: str, *, max_len: int = 30) -> str: - compact = " ".join(str(text or "").replace("\n", " ").split()).strip() - if not compact: - return "" - return compact[:max_len] - - @classmethod - def _build_auto_session_title(cls, question: str) -> Optional[str]: - normalized = cls._normalize_session_title_raw(question, max_len=30) - if not normalized: - return None - compact = re.sub(r"\s+", "", normalized) - if len(compact) < SESSION_TITLE_MIN_LEN: - return None - return normalized - - @staticmethod - def _is_default_session_title(title: Optional[str]) -> bool: - clean = str(title or "").strip() - return (not clean) or clean == SESSION_DEFAULT_TITLE - - async def _touch_session_for_new_record( - self, - *, - asked_by: Optional[int], - asked_via: str, - project_id: Optional[int], - question: str, - preferred_session_id: Optional[int] = None, - force_new_session: bool = False, - ) -> tuple[Optional[int], int]: - if asked_by is None: - return (None, 1) - - now_utc = now_cn_naive() - chosen: Optional[QASession] = None - preferred_id = int(preferred_session_id) if preferred_session_id else 0 - if preferred_id > 0: - chosen = ( - await self.db.execute( - select(QASession).where( - and_( - QASession.tenant_id == self.tenant_id, - QASession.user_id == int(asked_by), - QASession.id == preferred_id, - ) - ) - ) - ).scalar_one_or_none() - - # 无显式 session_id 时,仅在非强制新会话模式下复用同渠道(asked_via)且时间窗口内的最近会话。 - if chosen is None and not force_new_session: - latest_record = ( - await self.db.execute( - select(QARecord.session_id, QARecord.created_at) - .where( - and_( - QARecord.tenant_id == self.tenant_id, - QARecord.asked_by == int(asked_by), - QARecord.asked_via == (asked_via or "pmflow")[:20], - QARecord.session_id.is_not(None), - ) - ) - .order_by(QARecord.created_at.desc(), QARecord.id.desc()) - .limit(1) - ) - ).first() - if latest_record and latest_record.session_id is not None: - last_at = self._to_naive_utc(latest_record.created_at) - if last_at is not None: - gap_seconds = abs((now_utc - last_at).total_seconds()) - is_same_day = now_utc.date() == last_at.date() - if is_same_day and gap_seconds <= SESSION_GAP_MINUTES * 60: - chosen = ( - await self.db.execute( - select(QASession).where( - and_( - QASession.tenant_id == self.tenant_id, - QASession.user_id == int(asked_by), - QASession.id == int(latest_record.session_id), - ) - ) - ) - ).scalar_one_or_none() - - if chosen is None: - title = self._build_auto_session_title(question) or SESSION_DEFAULT_TITLE - chosen = QASession( - tenant_id=self.tenant_id, - user_id=int(asked_by), - title=title[:120], - project_id=project_id, - started_at=now_utc, - last_message_at=now_utc, - message_count=0, - ) - self.db.add(chosen) - await self.db.flush() - else: - if chosen.project_id is None and project_id is not None: - chosen.project_id = int(project_id) - chosen.last_message_at = now_utc - - chosen.message_count = int(chosen.message_count or 0) + 1 - if self._is_default_session_title(chosen.title): - generated = self._build_auto_session_title(question) - if generated: - chosen.title = generated[:120] - await self.db.flush() - return (int(chosen.id), int(chosen.message_count)) - - # ------------------------------------------------------------------ - # QA feedback/list/export - # ------------------------------------------------------------------ - async def _latest_feedback_map(self, record_ids: list[int]) -> dict[int, QAFeedback]: - ids = [int(x) for x in record_ids if x is not None] - if not ids: - return {} - feedback_rows = ( - await self.db.execute( - select(QAFeedback).where( - and_( - QAFeedback.tenant_id == self.tenant_id, - QAFeedback.qa_id.in_(ids), - ) - ) - ) - ).scalars().all() - - latest_feedback: dict[int, QAFeedback] = {} - for fb in feedback_rows: - key = int(fb.qa_id) - curr = latest_feedback.get(key) - fb_at = self._to_naive_utc(fb.created_at) or datetime.min - curr_at = self._to_naive_utc(curr.created_at) if curr else None - if curr is None or curr_at is None or fb_at >= curr_at: - latest_feedback[key] = fb - return latest_feedback - - async def list_qa_sessions( - self, - *, - user_id: int, - date_from: Optional[datetime], - date_to: Optional[datetime], - page: int, - page_size: int, - ) -> dict[str, Any]: - page = max(1, int(page)) - page_size = max(1, min(100, int(page_size))) - date_from_naive = self._to_naive_utc(date_from) - date_to_naive = self._to_naive_utc(date_to) - - base_stmt = select(QASession).where( - and_( - QASession.tenant_id == self.tenant_id, - QASession.user_id == int(user_id), - ) - ) - if date_from_naive is not None: - base_stmt = base_stmt.where(QASession.last_message_at >= date_from_naive) - if date_to_naive is not None: - base_stmt = base_stmt.where(QASession.last_message_at <= date_to_naive) - - total = int((await self.db.execute(select(func.count()).select_from(base_stmt.subquery()))).scalar() or 0) - rows = ( - await self.db.execute( - base_stmt.order_by(QASession.last_message_at.desc(), QASession.id.desc()) - .offset((page - 1) * page_size) - .limit(page_size) - ) - ).scalars().all() - if not rows: - return { - "items": [], - "page": page, - "page_size": page_size, - "total": total, - "total_pages": 0, - "count": 0, - } - - session_ids = [int(x.id) for x in rows] - record_rows = ( - await self.db.execute( - select(QARecord).where( - and_( - QARecord.tenant_id == self.tenant_id, - QARecord.session_id.in_(session_ids), - ) - ) - ) - ).scalars().all() - - records_by_session: dict[int, list[QARecord]] = {} - for row in record_rows: - sid = int(row.session_id or 0) - if sid <= 0: - continue - records_by_session.setdefault(sid, []).append(row) - - feedback_map = await self._latest_feedback_map([int(x.id) for x in record_rows]) - items: list[dict[str, Any]] = [] - for row in rows: - sid = int(row.id) - session_records = sorted( - records_by_session.get(sid, []), - key=lambda r: (int(r.turn_index or 0), self._to_naive_utc(r.created_at) or datetime.min), - ) - positive = 0 - negative = 0 - corrected = 0 - last_asked_via = "pmflow" - for rec in session_records: - last_asked_via = str(rec.asked_via or last_asked_via) - fb = feedback_map.get(int(rec.id)) - if not fb: - continue - rating = str(fb.rating or "").strip().lower() - if rating == "positive": - positive += 1 - elif rating == "negative": - negative += 1 - if str(fb.correct_answer or "").strip(): - corrected += 1 - - message_count = int(row.message_count or len(session_records)) - unrated = max(message_count - positive - negative, 0) - first_question = session_records[0].question if session_records else "" - items.append( - { - "id": sid, - "title": str(row.title or SESSION_DEFAULT_TITLE), - "project_id": row.project_id, - "started_at": row.started_at.isoformat() if row.started_at else None, - "last_message_at": row.last_message_at.isoformat() if row.last_message_at else None, - "message_count": message_count, - "turn_count": message_count, - "rating_summary": { - "positive": positive, - "negative": negative, - "unrated": unrated, - "corrected": corrected, - }, - "last_asked_via": last_asked_via, - "preview_question": str(first_question or "")[:120], - } - ) - - total_pages = math.ceil(total / page_size) if total > 0 else 0 - return { - "items": items, - "page": page, - "page_size": page_size, - "total": total, - "total_pages": total_pages, - "count": len(items), - } - - async def get_qa_session_detail(self, *, session_id: int, user_id: int) -> dict[str, Any]: - session_row = ( - await self.db.execute( - select(QASession).where( - and_( - QASession.id == int(session_id), - QASession.tenant_id == self.tenant_id, - QASession.user_id == int(user_id), - ) - ) - ) - ).scalar_one_or_none() - if not session_row: - raise ValueError("qa session 不存在") - - records = ( - await self.db.execute( - select(QARecord).where( - and_( - QARecord.tenant_id == self.tenant_id, - QARecord.session_id == int(session_id), - ) - ).order_by(QARecord.turn_index.asc(), QARecord.created_at.asc()) - ) - ).scalars().all() - feedback_map = await self._latest_feedback_map([int(x.id) for x in records]) - - messages: list[dict[str, Any]] = [] - positive = 0 - negative = 0 - corrected = 0 - for row in records: - fb = feedback_map.get(int(row.id)) - ack_meta = self._extract_ack_metadata_from_sources(row.sources, answer=row.answer) - if fb: - rating = str(fb.rating or "").strip().lower() - if rating == "positive": - positive += 1 - elif rating == "negative": - negative += 1 - if str(fb.correct_answer or "").strip(): - corrected += 1 - messages.append( - { - "id": int(row.id), - "question": row.question, - "answer": row.answer, - "sources": row.sources or [], - "intent": row.intent, - "domains_used": row.domains_used or [], - "response_time_ms": row.response_time_ms, - "asked_by": row.asked_by, - "asked_via": row.asked_via, - "project_id": row.project_id, - "session_id": row.session_id, - "turn_index": int(row.turn_index or 1), - "created_at": row.created_at.isoformat() if row.created_at else None, - "feedback": { - "id": int(fb.id), - "rating": fb.rating, - "helpfulness": fb.helpfulness, - "error_types": fb.error_types or [], - "correct_answer": fb.correct_answer, - } - if fb - else None, - **ack_meta, - } - ) - - message_count = int(session_row.message_count or len(records)) - unrated = max(message_count - positive - negative, 0) - return { - "session": { - "id": int(session_row.id), - "title": str(session_row.title or SESSION_DEFAULT_TITLE), - "project_id": session_row.project_id, - "started_at": session_row.started_at.isoformat() if session_row.started_at else None, - "last_message_at": session_row.last_message_at.isoformat() if session_row.last_message_at else None, - "message_count": message_count, - "turn_count": message_count, - "rating_summary": { - "positive": positive, - "negative": negative, - "unrated": unrated, - "corrected": corrected, - }, - }, - "messages": messages, - } - - async def rename_qa_session(self, *, session_id: int, user_id: int, title: str) -> dict[str, Any]: - session_row = ( - await self.db.execute( - select(QASession).where( - and_( - QASession.id == int(session_id), - QASession.tenant_id == self.tenant_id, - QASession.user_id == int(user_id), - ) - ) - ) - ).scalar_one_or_none() - if not session_row: - raise ValueError("qa session 不存在") - - cleaned = self._normalize_session_title_raw(title, max_len=120) - if not cleaned: - raise ValueError("会话标题不能为空") - session_row.title = cleaned - await self.db.commit() - return {"id": int(session_row.id), "title": cleaned} - - async def delete_qa_session(self, *, session_id: int, user_id: int) -> bool: - session_row = ( - await self.db.execute( - select(QASession).where( - and_( - QASession.id == int(session_id), - QASession.tenant_id == self.tenant_id, - QASession.user_id == int(user_id), - ) - ) - ) - ).scalar_one_or_none() - if not session_row: - return False - - qa_ids = ( - await self.db.execute( - select(QARecord.id).where( - and_( - QARecord.tenant_id == self.tenant_id, - QARecord.session_id == int(session_id), - ) - ) - ) - ).scalars().all() - id_list = [int(x) for x in qa_ids] - if id_list: - await self.db.execute( - delete(QAFeedback).where( - and_( - QAFeedback.tenant_id == self.tenant_id, - QAFeedback.qa_id.in_(id_list), - ) - ) - ) - await self.db.execute( - delete(QARecord).where( - and_( - QARecord.tenant_id == self.tenant_id, - QARecord.session_id == int(session_id), - ) - ) - ) - await self.db.delete(session_row) - await self.db.commit() - return True - - async def submit_feedback( - self, - *, - qa_id: int, - rating: str, - submitted_by: Optional[int], - helpfulness: Optional[str] = None, - error_types: Optional[list[str]] = None, - correct_answer: Optional[str] = None, - ) -> dict[str, Any]: - qa = ( - await self.db.execute( - select(QARecord).where( - and_(QARecord.id == int(qa_id), QARecord.tenant_id == self.tenant_id) - ) - ) - ).scalar_one_or_none() - if not qa: - raise ValueError("qa record 不存在") - - feedback = QAFeedback( - tenant_id=self.tenant_id, - qa_id=int(qa_id), - rating=(rating or "").strip().lower()[:16], - helpfulness=(helpfulness or "").strip()[:32] or None, - error_types=[str(x).strip() for x in (error_types or []) if str(x).strip()] or None, - correct_answer=(correct_answer or "").strip()[:4000] or None, - submitted_by=submitted_by, - ) - self.db.add(feedback) - await self.db.commit() - await self.db.refresh(feedback) - if str(feedback.rating or "").strip().lower() == "negative" and ( - str(feedback.correct_answer or "").strip() or [str(x).strip() for x in (feedback.error_types or []) if str(x).strip()] - ): - try: - await self.upsert_intent_correction_sample( - user_utterance=str(qa.question or ""), - wrong_interpretation=str(qa.answer or ""), - correct_intent="query", - canonical_action="clarify_before_answer", - clarification_template=str(feedback.correct_answer or "").strip() or "收到纠正后先澄清范围再回答。", - risk_level="medium", - project_id=int(qa.project_id) if qa.project_id is not None else None, - ) - except Exception: - await self.db.rollback() - return { - "id": int(feedback.id), - "qa_id": int(feedback.qa_id), - "rating": feedback.rating, - "helpfulness": feedback.helpfulness, - "error_types": feedback.error_types or [], - "correct_answer": feedback.correct_answer, - } - - async def list_qa_records( - self, - *, - project_id: Optional[int], - date_from: Optional[datetime], - date_to: Optional[datetime], - rating: Optional[str], - page: int, - page_size: int, - ) -> dict[str, Any]: - page = max(1, int(page)) - page_size = max(1, min(100, int(page_size))) - date_from_naive = self._to_naive_utc(date_from) - date_to_naive = self._to_naive_utc(date_to) - - stmt = select(QARecord).where(QARecord.tenant_id == self.tenant_id) - if project_id is not None: - stmt = stmt.where(QARecord.project_id == int(project_id)) - if date_from_naive is not None: - stmt = stmt.where(QARecord.created_at >= date_from_naive) - if date_to_naive is not None: - stmt = stmt.where(QARecord.created_at <= date_to_naive) - - records = ( - await self.db.execute( - stmt.order_by(QARecord.created_at.desc()) - .offset((page - 1) * page_size) - .limit(page_size) - ) - ).scalars().all() - - ids = [int(r.id) for r in records] - feedback_rows = [] - if ids: - fb_stmt = select(QAFeedback).where( - and_( - QAFeedback.tenant_id == self.tenant_id, - QAFeedback.qa_id.in_(ids), - ) - ) - if rating: - fb_stmt = fb_stmt.where(QAFeedback.rating == rating) - feedback_rows = (await self.db.execute(fb_stmt)).scalars().all() - - latest_feedback: dict[int, QAFeedback] = {} - for fb in feedback_rows: - key = int(fb.qa_id) - curr = latest_feedback.get(key) - if curr is None or (fb.created_at and curr.created_at and fb.created_at > curr.created_at): - latest_feedback[key] = fb - - items: list[dict[str, Any]] = [] - for row in records: - fb = latest_feedback.get(int(row.id)) - if rating and not fb: - continue - ack_meta = self._extract_ack_metadata_from_sources(row.sources, answer=row.answer) - items.append( - { - "id": int(row.id), - "question": row.question, - "answer": row.answer, - "sources": row.sources or [], - "intent": row.intent, - "domains_used": row.domains_used or [], - "response_time_ms": row.response_time_ms, - "asked_by": row.asked_by, - "asked_via": row.asked_via, - "project_id": row.project_id, - "session_id": row.session_id, - "turn_index": int(row.turn_index or 1), - "created_at": row.created_at.isoformat() if row.created_at else None, - "feedback": { - "id": int(fb.id), - "rating": fb.rating, - "helpfulness": fb.helpfulness, - "error_types": fb.error_types or [], - "correct_answer": fb.correct_answer, - } - if fb - else None, - **ack_meta, - } - ) - - return { - "items": items, - "page": page, - "page_size": page_size, - "count": len(items), - } - - async def get_qa_stats( - self, - *, - project_id: Optional[int], - date_from: Optional[datetime], - date_to: Optional[datetime], - ) -> dict[str, Any]: - date_from_naive = self._to_naive_utc(date_from) - date_to_naive = self._to_naive_utc(date_to) - record_stmt = select(QARecord).where(QARecord.tenant_id == self.tenant_id) - if project_id is not None: - record_stmt = record_stmt.where(QARecord.project_id == int(project_id)) - if date_from_naive is not None: - record_stmt = record_stmt.where(QARecord.created_at >= date_from_naive) - if date_to_naive is not None: - record_stmt = record_stmt.where(QARecord.created_at <= date_to_naive) - - records = (await self.db.execute(record_stmt)).scalars().all() - if not records: - return { - "total_sessions": 0, - "total_qa": 0, - "rated_count": 0, - "rated_ratio": 0.0, - "average_helpfulness": 0.0, - "exportable_pairs": 0, - } - - record_ids = [int(r.id) for r in records] - feedback_rows = ( - await self.db.execute( - select(QAFeedback).where( - and_( - QAFeedback.tenant_id == self.tenant_id, - QAFeedback.qa_id.in_(record_ids), - ) - ) - ) - ).scalars().all() - - latest_feedback: dict[int, QAFeedback] = {} - for fb in feedback_rows: - key = int(fb.qa_id) - curr = latest_feedback.get(key) - fb_at = fb.created_at or datetime.min - curr_at = curr.created_at if curr and curr.created_at else datetime.min - if curr is None or fb_at >= curr_at: - latest_feedback[key] = fb - - rated_count = len(latest_feedback) - rated_ratio = round(rated_count / max(len(records), 1), 4) - - helpful_scores: list[float] = [] - helpful_map = { - "fully": 5.0, - "could_be_better": 4.0, - "partially": 3.0, - } - for fb in latest_feedback.values(): - rating = str(fb.rating or "").strip().lower() - if rating == "positive": - score = helpful_map.get(str(fb.helpfulness or "").strip().lower(), 4.0) - elif rating == "negative": - score = 1.0 - else: - continue - helpful_scores.append(score) - - avg_helpfulness = round(sum(helpful_scores) / len(helpful_scores), 2) if helpful_scores else 0.0 - - exportable_pairs = 0 - for fb in latest_feedback.values(): - rating = str(fb.rating or "").strip().lower() - if rating == "positive" and str(fb.helpfulness or "").strip(): - exportable_pairs += 1 - continue - if rating == "negative": - exportable_pairs += 1 - - return { - "total_sessions": len({int(r.session_id) for r in records if r.session_id is not None}), - "total_qa": len(records), - "rated_count": rated_count, - "rated_ratio": rated_ratio, - "average_helpfulness": avg_helpfulness, - "exportable_pairs": exportable_pairs, - } - - async def export_qa_jsonl( - self, - *, - include_unrated: bool, - project_id: Optional[int], - rating: Optional[str], - date_from: Optional[datetime], - date_to: Optional[datetime], - domain: Optional[str], - ) -> str: - date_from_naive = self._to_naive_utc(date_from) - date_to_naive = self._to_naive_utc(date_to) - record_stmt = select(QARecord).where(QARecord.tenant_id == self.tenant_id) - if project_id is not None: - record_stmt = record_stmt.where(QARecord.project_id == int(project_id)) - if date_from_naive is not None: - record_stmt = record_stmt.where(QARecord.created_at >= date_from_naive) - if date_to_naive is not None: - record_stmt = record_stmt.where(QARecord.created_at <= date_to_naive) - if domain: - record_stmt = record_stmt.where(QARecord.domains_used.is_not(None)) - - records = (await self.db.execute(record_stmt.order_by(QARecord.created_at.asc()))).scalars().all() - if not records: - return "" - - feedback_map = await self._latest_feedback_map([int(r.id) for r in records]) - session_ids = sorted({int(r.session_id) for r in records if r.session_id is not None}) - session_map: dict[int, QASession] = {} - if session_ids: - rows = ( - await self.db.execute( - select(QASession).where( - and_( - QASession.tenant_id == self.tenant_id, - QASession.id.in_(session_ids), - ) - ) - ) - ).scalars().all() - session_map = {int(x.id): x for x in rows} - - lines: list[str] = [] - normalized_rating = str(rating or "").strip().lower() or None - for record in records: - feedback = feedback_map.get(int(record.id)) - if normalized_rating == "unrated": - if feedback: - continue - else: - if not feedback and not include_unrated: - continue - if normalized_rating in {"positive", "negative"}: - if not feedback or str(feedback.rating or "").strip().lower() != normalized_rating: - continue - if domain: - domains = [str(x).lower() for x in (record.domains_used or [])] - if domain.lower() not in domains: - continue - - session = session_map.get(int(record.session_id)) if record.session_id is not None else None - row = { - "session_id": int(record.session_id) if record.session_id is not None else None, - "session_title": str(session.title or SESSION_DEFAULT_TITLE) if session else SESSION_DEFAULT_TITLE, - "turn_index": int(record.turn_index or 1), - "question": record.question, - "answer": record.answer, - "sources": record.sources or [], - "domains_used": record.domains_used or [], - "asked_via": record.asked_via, - "project_id": record.project_id, - "created_at": record.created_at.isoformat() if record.created_at else None, - # 兼容历史导出字段 - "prompt": record.question, - "response": record.answer, - "rating": str(feedback.rating) if feedback else "unrated", - } - if feedback: - if feedback.helpfulness: - row["helpfulness"] = feedback.helpfulness - errors = [str(x) for x in (feedback.error_types or []) if str(x).strip()] - if errors: - row["error_type"] = errors[0] - row["error_types"] = errors - if feedback.correct_answer: - row["correct_answer"] = feedback.correct_answer - lines.append(json.dumps(row, ensure_ascii=False)) - - return "\n".join(lines) - - # ------------------------------------------------------------------ - # Internal helpers - # ------------------------------------------------------------------ - @staticmethod - def _contains_any(text: str, keywords: tuple[str, ...]) -> bool: - lowered = str(text or "").strip().lower() - if not lowered: - return False - return any(keyword in lowered for keyword in keywords) - - @classmethod - def _is_correction_query(cls, question: str) -> bool: - text = str(question or "").strip().lower() - if not text: - return False - if BRIEF_NEGATIVE_FEEDBACK_RE.match(text): - return True - if cls._contains_any(text, CORRECTION_CUE_KEYWORDS): - return True - if re.search(r"(不是|别|不要).{0,8}(系统架构|架构)", text): - return True - return False - - @classmethod - def _has_explicit_target_scope(cls, question: str) -> bool: - text = str(question or "").strip().lower() - if not text: - return False - explicit_groups: tuple[tuple[str, ...], ...] = ( - ENGINE_PROCESS_KEYWORDS, - FEATURE_QUERY_KEYWORDS, - ARCHITECTURE_KEYWORDS, - PROJECT_KEYWORDS, - PEOPLE_KEYWORDS, - DOC_KEYWORDS, - ) - for group in explicit_groups: - if cls._contains_any(text, group): - return True - if any(token in text for token in ("openclaw", "llm", "model", "engine", "api")): - return True - return False - - @classmethod - def _is_engine_process_query(cls, question: str) -> bool: - return cls._contains_any(question, ENGINE_PROCESS_KEYWORDS) - - @classmethod - def _is_explicit_deep_request(cls, question: str) -> bool: - return cls._contains_any(question, DEEP_EXPLICIT_KEYWORDS) - - @classmethod - def _forced_deep_reason( - cls, - *, - question: str, - effective_question: str, - conversation: list[dict[str, str]], - ) -> str: - if cls._is_correction_query(question) or cls._is_correction_query(effective_question): - if conversation: - return "correction_turn" - if cls._is_engine_process_query(question) or cls._is_engine_process_query(effective_question): - return "engine_process_query" - return "" - - @classmethod - def _should_force_deep_for_channel( - cls, - *, - asked_via: str, - question: str, - effective_question: str, - reason: str, - ) -> bool: - if not str(reason or "").strip(): - return False - channel = str(asked_via or "").strip().lower() - if channel != "feishu": - return True - - if cls._is_explicit_deep_request(question) or cls._is_explicit_deep_request(effective_question): - return True - if str(reason or "").strip() == "engine_process_query": - combined = f"{question} {effective_question}".strip() - return cls._has_explicit_target_scope(combined) - return False - - @classmethod - def _is_snapshot_only_sources(cls, sources: list[dict[str, Any]]) -> bool: - if not isinstance(sources, list) or not sources: - return True - allowed_labels = {"System Snapshot", "User Snapshot", "Session State"} - for row in sources: - if not isinstance(row, dict): - continue - source = str(row.get("source") or "").strip().lower() - label = str(row.get("label") or "").strip() - if source != "system": - return False - if label and label not in allowed_labels: - return False - return True - - @classmethod - def _should_auto_deep_retry( - cls, - *, - question: str, - effective_question: str, - sources: list[dict[str, Any]], - relevance_max: float, - ) -> str: - process_sensitive = cls._is_engine_process_query(question) or cls._is_engine_process_query(effective_question) - correction_sensitive = cls._is_correction_query(question) or cls._is_correction_query(effective_question) - if not process_sensitive and not correction_sensitive: - return "" - snapshot_only = cls._is_snapshot_only_sources(sources) - if snapshot_only: - return "snapshot_only" - if float(relevance_max or 0.0) < 0.62: - return "low_relevance" - return "" - - @classmethod - def _allow_auto_deep_retry_for_channel( - cls, - *, - asked_via: str, - question: str, - effective_question: str, - reason: str, - ) -> bool: - if not str(reason or "").strip(): - return False - channel = str(asked_via or "").strip().lower() - if channel != "feishu": - return True - if cls._is_explicit_deep_request(question) or cls._is_explicit_deep_request(effective_question): - return True - return False - - @classmethod - def _should_block_answer_with_guard( - cls, - *, - question: str, - effective_question: str, - sources: list[dict[str, Any]], - relevance_max: float, - ) -> bool: - if not (cls._is_engine_process_query(question) or cls._is_engine_process_query(effective_question)): - return False - if not cls._is_snapshot_only_sources(sources): - return False - return float(relevance_max or 0.0) < 0.68 - - @staticmethod - def _build_evidence_guard_answer(question: str) -> str: - raw = str(question or "").strip() - target = raw[:80] if raw else "该问题" - return ( - f"我不能对“{target}”直接给结论:当前只命中系统快照,缺少调用链证据。" - "请补充目标对象(如 openclaw 路由规则、函数名、入口层级),我会按调用链返回可核验答案。" - ) - - @staticmethod - def _is_snapshot_direct_question(question: str) -> bool: - text = str(question or "").strip().lower() - if not text: - return False - if AdaptiveRAGService._is_correction_query(text): - return False - if AdaptiveRAGService._is_engine_process_query(text): - return False - has_feature_token = any(k in text for k in FEATURE_QUERY_KEYWORDS) - ask_feature_purpose = has_feature_token and any(k in text for k in FEATURE_PURPOSE_HINTS) - return ( - any(k in text for k in IDENTITY_KEYWORDS) - or any(k in text for k in ARCHITECTURE_KEYWORDS) - or ask_feature_purpose - ) - - @staticmethod - def _expand_followup_question(question: str, conversation: list[dict[str, str]]) -> str: - current = str(question or "").strip() - if not current: - return current - - lowered = current.lower() - # 短句不等于追问:若已包含明确业务对象,则按独立问题处理,避免串错上下文。 - has_explicit_subject = any( - marker in lowered for marker in ("项目", "信号", "信号源", "任务", "系统", "架构", "数据库", "redis", "接口", "用户") - ) - is_short_followup = len(current) <= 16 and not has_explicit_subject - has_referential = any(k in lowered for k in FOLLOWUP_REFERENTIAL_KEYWORDS) - if not (is_short_followup or has_referential): - return current - - anchor_question = "" - for turn in reversed(conversation[-5:]): - candidate = str(turn.get("question") or "").strip() - if not candidate: - continue - if candidate == current: - continue - if any( - marker in candidate - for marker in ("项目", "系统", "任务", "信号", "接口", "数据库", "redis", "架构", "模块") - ): - anchor_question = candidate - break - - if not anchor_question: - return current - - is_enumeration = any(k in lowered for k in FOLLOWUP_ENUMERATION_KEYWORDS) - if is_enumeration and ("信号" in lowered or "信号" in anchor_question): - return f"{anchor_question}。请列出该项目关联的信号标题和ID。" - if is_enumeration and ("任务" in lowered or "任务" in anchor_question): - return f"{anchor_question}。请列出对应任务的标题和状态。" - if is_enumeration and "项目" in anchor_question: - return f"{anchor_question}。请列出对应项目的名称和ID。" - return f"{anchor_question}。补充问题:{current}" - - def _build_snapshot_direct_answer( - self, - question: str, - *, - system_snapshot: Optional[dict[str, Any]], - user_snapshot: Optional[dict[str, Any]], - ) -> Optional[str]: - text = str(question or "").strip().lower() - if not text: - return None - if self._is_correction_query(text): - return None - if self._is_engine_process_query(text): - return None - - ask_identity = any(k in text for k in IDENTITY_KEYWORDS) - ask_architecture = any(k in text for k in ARCHITECTURE_KEYWORDS) - has_feature_token = any(k in text for k in FEATURE_QUERY_KEYWORDS) - ask_feature = has_feature_token and any(k in text for k in FEATURE_PURPOSE_HINTS) - if not ask_identity and not ask_architecture and not ask_feature: - return None - - system_data = system_snapshot if isinstance(system_snapshot, dict) else {} - assistant = system_data.get("assistant_profile") if isinstance(system_data.get("assistant_profile"), dict) else {} - platform = system_data.get("platform_profile") if isinstance(system_data.get("platform_profile"), dict) else {} - backend = platform.get("backend") if isinstance(platform.get("backend"), dict) else {} - infra = platform.get("infrastructure") if isinstance(platform.get("infrastructure"), dict) else {} - module_counts = backend.get("module_counts") if isinstance(backend.get("module_counts"), dict) else {} - - lines: list[str] = [] - if ask_identity: - name = str(assistant.get("name") or "PMFlow AI 管家") - identity = str(assistant.get("identity") or "PMFlow 内置 AI 助手") - lines.append(f"我是{name},{identity}。") - responsibilities = assistant.get("responsibilities") if isinstance(assistant.get("responsibilities"), list) else [] - if responsibilities: - joined = ";".join(str(x) for x in responsibilities[:5] if str(x).strip()) - if joined: - lines.append(f"职责范围:{joined}。") - - if ask_architecture: - runtime = str(platform.get("runtime") or "unknown") - backend_framework = str(backend.get("framework") or "FastAPI") - orm_name = str(backend.get("orm") or "SQLAlchemy 2.0") - db_name = str(infra.get("database") or "PostgreSQL") - cache_name = str(infra.get("cache") or "Redis") - lines.append( - f"当前系统采用前后端分层架构:后端 {backend_framework} + {orm_name},数据库 {db_name},缓存 {cache_name},运行环境 {runtime}。" - ) - if module_counts: - lines.append( - "后端模块规模:" - f"api {int(module_counts.get('api', 0))}、" - f"services {int(module_counts.get('services', 0))}、" - f"models {int(module_counts.get('models', 0))}、" - f"schemas {int(module_counts.get('schemas', 0))}。" - ) - - if ask_feature: - feature_hits = self._match_feature_catalog_entries( - question=question, - system_snapshot=system_data, - limit=4, - ) - if feature_hits: - lines.append("命中的产品功能:") - for idx, row in enumerate(feature_hits, start=1): - name = str(row.get("name") or "未知功能") - summary = str(row.get("summary") or "暂无说明") - route = str(row.get("route") or "") - page_file = str(row.get("page_file") or "") - apis = row.get("apis") if isinstance(row.get("apis"), list) else [] - api_text = "、".join(str(x) for x in apis[:3] if str(x).strip()) - line = f"{idx}. {name}:{summary}" - if route: - line += f" 入口 {route}。" - if page_file: - line += f" 页面文件 {page_file}。" - if api_text: - line += f" 相关接口 {api_text}。" - lines.append(line) - else: - catalog = self._feature_catalog_from_snapshot(system_data) - if catalog: - brief = "、".join( - str(row.get("name") or "") - for row in catalog[:8] - if isinstance(row, dict) and str(row.get("name") or "").strip() - ) - if brief: - lines.append(f"当前功能目录包括:{brief}。你可以继续指定其中一个功能名称,我会返回用途与入口。") - else: - lines.append("当前 System Snapshot 未包含 feature_catalog,建议先刷新上下文缓存后再提问。") - - if ask_identity and isinstance(user_snapshot, dict) and user_snapshot: - projects = user_snapshot.get("projects") if isinstance(user_snapshot.get("projects"), list) else [] - tasks = user_snapshot.get("assigned_tasks") if isinstance(user_snapshot.get("assigned_tasks"), list) else [] - lines.append(f"你的当前上下文:关联项目 {len(projects)} 个,未完成任务 {len(tasks)} 个。") - - if not lines: - return None - lines.append("来源: System Snapshot / User Snapshot") - return "\n".join(lines)[:3200] - - async def _generate_answer( - self, - question: str, - retrieved: list[RetrievedItem], - conversation: list[dict[str, str]], - project_id: Optional[int], - *, - system_snapshot: Optional[dict[str, Any]] = None, - user_snapshot: Optional[dict[str, Any]] = None, - relevance_max: float = 0.0, - thinking_mode: str = THINKING_MODE_NORMAL, - context_block_limit: Optional[int] = None, - answer_max_tokens: Optional[int] = None, - ) -> str: - active_thinking_mode = self._normalize_thinking_mode(thinking_mode) - deep_thinking = active_thinking_mode == THINKING_MODE_DEEP - context_blocks: list[str] = [] - used_tokens = 0 - max_blocks = max(4, min(int(context_block_limit or DEFAULT_TOP_K * 2), 32)) - manifest = await self.get_project_manifest(project_id=project_id) - manifest_text = str(manifest.get("manifest_text") or "").strip() - if manifest_text: - manifest_block = f"[M] 项目画像\n{manifest_text}\n" - manifest_tokens = max(len(manifest_block) // 4, 1) - if used_tokens + manifest_tokens <= MAX_CONTEXT_TOKENS: - context_blocks.append(manifest_block) - used_tokens += manifest_tokens - - for idx, item in enumerate(retrieved[:max_blocks]): - block = f"[{idx + 1}] 来源: {item.source_label}\n相关度: {item.score:.3f}\n内容: {item.content}\n" - block_tokens = max(len(block) // 4, 1) - if used_tokens + block_tokens > MAX_CONTEXT_TOKENS: - break - used_tokens += block_tokens - context_blocks.append(block) - - history_lines = [] - for row in conversation[-10:]: - history_lines.append(f"Q: {row.get('question', '')}\nA: {row.get('answer', '')}") - - system_snapshot_text = json.dumps(system_snapshot or {}, ensure_ascii=False)[:12000] - user_snapshot_text = json.dumps(user_snapshot or {}, ensure_ascii=False)[:6000] - - prompt = PROMPT_RAG_ANSWER.format( - project_id=project_id if project_id is not None else "unknown", - question=question, - conversation=chr(10).join(history_lines) if history_lines else "无", - context_blocks=chr(10).join(context_blocks) if context_blocks else "无", - system_snapshot=system_snapshot_text or "{}", - user_snapshot=user_snapshot_text or "{}", - relevance_max=f"{max(0.0, float(relevance_max or 0.0)):.3f}", - ).strip() - if deep_thinking: - prompt = ( - f"{prompt}\n\n" - "补充要求(深入思考模式):\n" - "1. 先给结论,再给证据链;\n" - "2. 明确区分已验证事实与推断;\n" - "3. 若存在不确定项,给出待确认清单。" - ) - - effective_max_tokens = int( - answer_max_tokens - if answer_max_tokens is not None - else (DEEP_ANSWER_MAX_TOKENS if deep_thinking else 900) - ) - effective_max_tokens = max(256, min(effective_max_tokens, 3000)) - effective_temperature = 0.15 if deep_thinking else 0.2 - function_name = "adaptive_rag_answer_deep" if deep_thinking else "adaptive_rag_answer_normal" - - answer = await self.llm.generate( - prompt, - db=self.db, - tenant_id=self.tenant_id, - function_name=function_name, - temperature=effective_temperature, - max_tokens=effective_max_tokens, - use_prompt_profile=False, - ) - text = str(answer or "").strip() - if not text: - return "我未能从当前上下文生成有效答案,请补充问题细节后重试。" - enforced = self._enforce_feature_evidence_in_answer( - question=question, - answer_text=text, - retrieved=retrieved, - system_snapshot=system_snapshot, - ) - return str(enforced or text)[:3200] - - async def _load_recent_conversation( - self, - *, - asked_by: Optional[int], - asked_via: str, - project_id: Optional[int], - session_id: Optional[int], - external_session_id: Optional[int], - limit: int, - ) -> list[dict[str, str]]: - active_session_id = session_id if session_id is not None else external_session_id - if active_session_id is not None: - cached_messages = await self.context_cache.get_session_messages( - tenant_id=self.tenant_id, - session_id=active_session_id, - ) - if cached_messages: - cached_turns = self.context_cache.messages_to_turns(cached_messages) - if cached_turns: - return cached_turns[-limit:] - - if asked_by is None: - return [] - - stmt = select(QARecord).where( - and_( - QARecord.tenant_id == self.tenant_id, - QARecord.asked_by == int(asked_by), - QARecord.asked_via == (asked_via or "pmflow")[:20], - ) - ) - if session_id is not None: - stmt = stmt.where(QARecord.session_id == int(session_id)) - if project_id is not None: - stmt = stmt.where(QARecord.project_id == int(project_id)) - rows = (await self.db.execute(stmt.order_by(QARecord.created_at.desc()).limit(limit))).scalars().all() - return [{"question": str(x.question or ""), "answer": str(x.answer or "")} for x in reversed(rows)] - - def _map_domains_to_source_types(self, domains: list[str]) -> list[str]: - mapped: list[str] = [] - if "doc" in domains: - mapped.append("doc") - if "feishu" in domains: - mapped.append("feishu") - if "pmflow" in domains: - mapped.append("pmflow") - if "url" in domains: - mapped.append("url") - return mapped - - def _source_label(self, source_type: str, metadata: dict[str, Any]) -> str: - if source_type == "doc": - title = metadata.get("title") or "unknown" - page_number = metadata.get("page_number") - if page_number is not None: - return f"Document[{title}] p.{page_number}" - return f"Document[{title}]" - if source_type == "url": - return f"URL[{metadata.get('file_url') or metadata.get('url') or 'external'}]" - if source_type == "feishu": - return "Feishu history" - if source_type == "pmflow": - record_type = str(metadata.get("record_type") or "").strip() - record_id = metadata.get("record_id") - if record_type and record_id is not None: - return f"PMFlow[{record_type}#{record_id}]" - return "PMFlow records" - return "Knowledge base" - - async def _noop_results(self) -> list[RetrievedItem]: - return [] - - async def _fetch_url_text(self, url: str) -> str: - target = str(url or "").strip() - if not target: - raise ValueError("url_empty") - async with httpx.AsyncClient(timeout=20.0, trust_env=False, follow_redirects=True) as client: - resp = await client.get(target, headers={"User-Agent": "PMFlow-RAG/1.0"}) - resp.raise_for_status() - content_type = str(resp.headers.get("content-type") or "").lower() - body = resp.text - if "html" in content_type: - return self._html_to_text(body) - return body[:300000] - - @staticmethod - def _html_to_text(html: str) -> str: - text = str(html or "") - text = re.sub(r".*?", " ", text, flags=re.IGNORECASE | re.DOTALL) - text = re.sub(r".*?", " ", text, flags=re.IGNORECASE | re.DOTALL) - text = re.sub(r"<[^>]+>", " ", text) - text = re.sub(r" | ", " ", text) - text = re.sub(r"&", "&", text) - text = re.sub(r"<", "<", text) - text = re.sub(r">", ">", text) - text = re.sub(r"\s+", " ", text).strip() - return text[:300000] - - def _chunk_text(self, text: str, *, chunk_size: int = 500, overlap: int = 50) -> list[str]: - clean = str(text or "").strip() - if not clean: - return [] - if len(clean) <= chunk_size: - return [clean] - - chunks: list[str] = [] - start = 0 - step = max(1, chunk_size - overlap) - while start < len(clean): - end = min(len(clean), start + chunk_size) - part = clean[start:end].strip() - if part: - chunks.append(part) - if end >= len(clean): - break - start += step - return chunks - - def _chunk_text_with_metadata( - self, - text: str, - *, - chunk_size: int = 500, - overlap: int = 50, - ) -> list[tuple[str, dict[str, Any]]]: - segments = self._split_text_by_page_markers(text) - if not segments: - return [(part, {}) for part in self._chunk_text(text, chunk_size=chunk_size, overlap=overlap)] - - out: list[tuple[str, dict[str, Any]]] = [] - for page_number, segment in segments: - for part in self._chunk_text(segment, chunk_size=chunk_size, overlap=overlap): - meta: dict[str, Any] = {} - if page_number is not None: - meta["page_number"] = int(page_number) - out.append((part, meta)) - return out - - def _split_text_by_page_markers(self, text: str) -> list[tuple[Optional[int], str]]: - clean = str(text or "") - matches = list(PAGE_MARKER_RE.finditer(clean)) - if not matches: - return [] - - segments: list[tuple[Optional[int], str]] = [] - prefix = clean[: matches[0].start()].strip() - if prefix: - segments.append((None, prefix)) - - for idx, marker in enumerate(matches): - try: - page_number = int(marker.group(1)) - except Exception: - page_number = None - start = marker.end() - end = matches[idx + 1].start() if idx + 1 < len(matches) else len(clean) - segment = clean[start:end].strip() - if segment: - segments.append((page_number, segment)) - return segments - - @staticmethod - def _to_float(value: Any, default: float) -> float: - try: - return float(value) - except Exception: - return float(default) - - @staticmethod - def _safe_dict(value: Any) -> dict[str, Any]: - if isinstance(value, dict): - return value - if isinstance(value, str): - try: - parsed = json.loads(value) - return parsed if isinstance(parsed, dict) else {} - except Exception: - return {} - return {} - - @staticmethod - def _parse_json_object(raw: str) -> dict[str, Any]: - text = str(raw or "").strip() - if text.startswith("```"): - parts = text.split("```") - if len(parts) >= 2: - text = parts[1].strip() - if text.startswith("json"): - text = text[4:].strip() - parsed: dict[str, Any] = {} - try: - value = json.loads(text) - if isinstance(value, dict): - parsed = value - except Exception: - pass - return parsed - - @staticmethod - def _embedding_to_list(raw: Any) -> list[float]: - if raw is None: - return [] - if isinstance(raw, list): - return [float(x) for x in raw] - if isinstance(raw, str): - try: - value = json.loads(raw) - if isinstance(value, list): - return [float(x) for x in value] - except Exception: - return [] - return [] - - @staticmethod - def _cosine_similarity(v1: list[float], v2: list[float]) -> float: - if not v1 or not v2: - return 0.0 - size = min(len(v1), len(v2)) - if size == 0: - return 0.0 - a = v1[:size] - b = v2[:size] - dot = sum(x * y for x, y in zip(a, b)) - norm_a = math.sqrt(sum(x * x for x in a)) - norm_b = math.sqrt(sum(y * y for y in b)) - if norm_a == 0 or norm_b == 0: - return 0.0 - sim = dot / (norm_a * norm_b) - return (sim + 1) / 2 - - def _dedupe_sources(self, retrieved: list[RetrievedItem]) -> list[dict[str, Any]]: - seen: set[str] = set() - out: list[dict[str, Any]] = [] - for item in retrieved: - meta_identity = ( - item.metadata.get("message_id") - or item.metadata.get("file_url") - or item.metadata.get("target_url") - or item.metadata.get("path") - or item.metadata.get("relation_id") - or item.metadata.get("entity_id") - or item.metadata.get("subject_entity_id") - or item.metadata.get("object_entity_id") - or item.content[:80] - ) - key = f"{item.source}|{item.source_label}|{meta_identity}" - if key in seen: - continue - seen.add(key) - out.append( - { - "source": item.source, - "label": item.source_label, - "score": round(item.score, 4), - "metadata": item.metadata, - } - ) - if len(out) >= 8: - break - return out - - @staticmethod - def _build_pmflow_target_url(*, record_type: str, record_id: Any, project_id: Any) -> str: - safe_type = str(record_type or "").strip().lower() - try: - pid = int(project_id) if project_id is not None else None - except Exception: - pid = None - try: - rid = int(record_id) if record_id is not None else None - except Exception: - rid = None - if pid is None: - return "" - if safe_type == "charter" and rid is not None: - return f"/projects/{pid}/charter/{rid}" - if safe_type == "task" and rid is not None: - return f"/projects/{pid}/board?focus=task&taskId={rid}" - if safe_type == "review" and rid is not None: - return f"/projects/{pid}/board?focus=review&reviewId={rid}" - return f"/projects/{pid}/board" - - @staticmethod - def _serialize_doc(doc: KnowledgeBaseDoc) -> dict[str, Any]: - return { - "id": int(doc.id), - "title": doc.title, - "file_type": doc.file_type, - "source_type": doc.source_type, - "file_url": doc.file_url, - "status": doc.status, - "uploaded_by": doc.uploaded_by, - "created_at": doc.created_at.isoformat() if doc.created_at else None, - "last_fetched_at": doc.last_fetched_at.isoformat() if doc.last_fetched_at else None, - "error_message": doc.error_message, - } +"""Adaptive RAG service compatibility module.""" +from .adaptive_rag_service import AdaptiveRAGService __all__ = ["AdaptiveRAGService"] diff --git a/backend/app/services/adaptive_rag_service/__init__.py b/backend/app/services/adaptive_rag_service/__init__.py new file mode 100644 index 00000000..be776f7b --- /dev/null +++ b/backend/app/services/adaptive_rag_service/__init__.py @@ -0,0 +1,5 @@ +"""Adaptive RAG service package.""" + +from .service import AdaptiveRAGService + +__all__ = ["AdaptiveRAGService"] diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_entry.py b/backend/app/services/adaptive_rag_service/mixin_answer_entry.py new file mode 100644 index 00000000..a8481598 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_answer_entry.py @@ -0,0 +1,264 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGAnswerEntryMixin: + async def answer_question( + self, + *, + question: str, + asked_by: Optional[int], + asked_via: str, + project_id: Optional[int] = None, + source_chat_id: Optional[str] = None, + session_id: Optional[int] = None, + conversation_key: Optional[str] = None, + force_new_session: bool = False, + thinking_mode: str = THINKING_MODE_NORMAL, + action_mode: str = ACTION_MODE_AUTO, + sync_requirement: bool = True, + ) -> dict[str, Any]: + started = time.perf_counter() + clean_q = str(question or "").strip() + external_conversation_key = self._build_external_conversation_key( + asked_via=asked_via, + source_chat_id=source_chat_id, + conversation_key=conversation_key, + ) + active_thinking_mode = self._normalize_thinking_mode(thinking_mode) + deep_thinking = active_thinking_mode == THINKING_MODE_DEEP + asked_via_lower = str(asked_via or "").strip().lower() + deep_retrieval_steps = DEEP_RETRIEVAL_STEPS + deep_vector_top_k = DEEP_VECTOR_TOP_K + if asked_via_lower == "feishu": + # 飞书链路优先低时延,保留 deep 能力但收紧预算。 + deep_retrieval_steps = min(DEEP_RETRIEVAL_STEPS, 3) + deep_vector_top_k = min(DEEP_VECTOR_TOP_K, 8) + if not clean_q: + return self._build_response_payload( + answer="问题为空,无法检索。", + sources=[], + domains_used=[], + response_time_ms=0, + db_query_ms=0, + vector_search_ms=0, + db_timeout=False, + vector_timeout=False, + qa_id=None, + session_id=None, + turn_index=1, + relevance_max=0.0, + query_type=QUERY_TYPE_UNKNOWN, + confidence=0.0, + missing_fields=["question"], + thinking_mode=active_thinking_mode, + ) + + session_id, turn_index = await self._touch_session_for_new_record( + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + question=clean_q, + preferred_session_id=session_id, + force_new_session=bool(force_new_session), + ) + context_session_id: Optional[int] + if session_id is not None: + context_session_id = int(session_id) + elif asked_by is None: + context_session_id = self._external_session_id_from_key(external_conversation_key) + else: + context_session_id = None + conversation = await self._load_recent_conversation( + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + session_id=session_id, + external_session_id=context_session_id, + limit=10, + ) + state_row = await self._ensure_session_state(session_id=session_id) + effective_q, rewrite_meta = self._rewrite_question_with_state( + question=clean_q, + conversation=conversation, + session_state=state_row, + ) + query_type = self._classify_query_type(effective_q) + requested_action_mode = self._normalize_action_mode(action_mode) + effective_action_mode = self._resolve_action_mode( + requested_mode=requested_action_mode, + question=clean_q, + effective_question=effective_q, + ) + if ( + bool(sync_requirement) + and asked_via_lower != "feishu" + and effective_action_mode in {ACTION_MODE_WRITE, ACTION_MODE_READ_WRITE} + ): + ack_result = await self._handle_ack_first_requirement( + question=clean_q, + effective_question=effective_q, + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + session_id=session_id, + turn_index=turn_index, + state_row=state_row, + rewrite_meta=rewrite_meta, + context_session_id=context_session_id, + query_type=query_type, + action_mode=effective_action_mode, + started_at=started, + ) + if ack_result is not None: + return ack_result + forced_deep_reason = "" + if not deep_thinking: + forced_deep_reason = self._forced_deep_reason( + question=clean_q, + effective_question=effective_q, + conversation=conversation, + ) + if forced_deep_reason and self._should_force_deep_for_channel( + asked_via=asked_via, + question=clean_q, + effective_question=effective_q, + reason=forced_deep_reason, + ): + deep_thinking = True + active_thinking_mode = THINKING_MODE_DEEP + else: + forced_deep_reason = "" + + clarification_answer = self._build_clarification_question( + original_question=clean_q, + effective_question=effective_q, + session_state=state_row, + ) + clarification_result = await self._maybe_return_clarification_response( + clarification_answer=clarification_answer, + started=started, + clean_q=clean_q, + effective_q=effective_q, + query_type=query_type, + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + session_id=session_id, + turn_index=turn_index, + state_row=state_row, + rewrite_meta=rewrite_meta, + context_session_id=context_session_id, + active_thinking_mode=active_thinking_mode, + ) + if clarification_result is not None: + return clarification_result + + system_snapshot = await self.context_cache.get_system_snapshot( + db=self.db, + tenant_id=self.tenant_id, + ) + user_snapshot = await self.context_cache.get_user_snapshot( + db=self.db, + tenant_id=self.tenant_id, + user_id=asked_by, + ) + + if not deep_thinking: + deterministic_result = await self._run_deterministic_query( + question=effective_q, + project_id=project_id, + query_type=query_type, + ) + if deterministic_result is not None: + return await self._persist_fast_answer( + question=clean_q, + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + fast_result=deterministic_result, + session_id=session_id, + external_session_id=context_session_id, + turn_index=turn_index, + query_type=str(deterministic_result.get("query_type") or query_type), + missing_fields=list(deterministic_result.get("missing_fields") or []), + state_row=state_row, + effective_question=effective_q, + rewrite_meta=rewrite_meta, + thinking_mode=active_thinking_mode, + ) + + direct_answer = None + if not deep_thinking: + direct_answer = self._build_snapshot_direct_answer( + effective_q, + system_snapshot=system_snapshot, + user_snapshot=user_snapshot, + ) + direct_result = await self._maybe_return_snapshot_direct_response( + direct_answer=direct_answer, + started=started, + clean_q=clean_q, + effective_q=effective_q, + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + session_id=session_id, + turn_index=turn_index, + state_row=state_row, + rewrite_meta=rewrite_meta, + context_session_id=context_session_id, + active_thinking_mode=active_thinking_mode, + user_snapshot=user_snapshot, + ) + if direct_result is not None: + return direct_result + + is_snapshot_direct_question = self._is_snapshot_direct_question(effective_q) + if not deep_thinking and not is_snapshot_direct_question: + glm_semantic_result = await self._try_glm_project_semantic_answer(clean_q=effective_q, project_id=project_id) + if glm_semantic_result is not None: + return await self._persist_fast_answer( + question=clean_q, + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + fast_result=glm_semantic_result, + session_id=session_id, + external_session_id=context_session_id, + turn_index=turn_index, + query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_EXPLAIN, + missing_fields=[], + state_row=state_row, + effective_question=effective_q, + rewrite_meta=rewrite_meta, + thinking_mode=active_thinking_mode, + ) + + return await self._build_retrieval_response( + clean_q=clean_q, + effective_q=effective_q, + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + source_chat_id=source_chat_id, + session_id=session_id, + turn_index=turn_index, + context_session_id=context_session_id, + conversation=conversation, + state_row=state_row, + rewrite_meta=rewrite_meta, + query_type=query_type, + thinking_mode=thinking_mode, + active_thinking_mode=active_thinking_mode, + deep_thinking=deep_thinking, + forced_deep_reason=forced_deep_reason, + deep_retrieval_steps=deep_retrieval_steps, + deep_vector_top_k=deep_vector_top_k, + system_snapshot=system_snapshot, + user_snapshot=user_snapshot, + started=started, + ) + diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_payload.py b/backend/app/services/adaptive_rag_service/mixin_answer_payload.py new file mode 100644 index 00000000..a693a868 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_answer_payload.py @@ -0,0 +1,130 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGAnswerPayloadMixin: + def _build_response_payload( + self, + *, + answer: str, + sources: list[dict[str, Any]], + domains_used: list[str], + response_time_ms: int, + db_query_ms: int, + vector_search_ms: int, + graph_query_ms: int = 0, + db_timeout: bool = False, + vector_timeout: bool = False, + graph_timeout: bool = False, + qa_id: Optional[int] = None, + session_id: Optional[int] = None, + turn_index: int = 1, + relevance_max: float = 0.0, + query_type: str = QUERY_TYPE_UNKNOWN, + confidence: float = 0.0, + missing_fields: Optional[list[str]] = None, + tool_trace: Optional[list[dict[str, Any]]] = None, + thinking_mode: str = THINKING_MODE_NORMAL, + action_taken: Optional[str] = None, + signal_id: Optional[int] = None, + reply_required: bool = False, + reply_text: Optional[str] = None, + ack_latency_ms: Optional[int] = None, + trace_id: Optional[str] = None, + processing_stage: Optional[str] = None, + ) -> dict[str, Any]: + evidence: list[dict[str, Any]] = [] + for item in sources[:8]: + if not isinstance(item, dict): + continue + evidence.append( + { + "source": str(item.get("source") or "unknown"), + "label": str(item.get("label") or "unknown"), + "score": round(self._to_float(item.get("score"), 0.0), 4), + "metadata": item.get("metadata") if isinstance(item.get("metadata"), dict) else {}, + } + ) + return { + "answer": str(answer or "").strip(), + "evidence": evidence, + "confidence": round(max(0.0, min(1.0, self._to_float(confidence, 0.0))), 4), + "thinking_mode": self._normalize_thinking_mode(thinking_mode), + "missing_fields": [str(x) for x in (missing_fields or []) if str(x).strip()], + "query_type": str(query_type or QUERY_TYPE_UNKNOWN), + "sources": sources, + "domains_used": domains_used, + "response_time_ms": int(response_time_ms or 0), + "retrieval_metrics": { + "db_query_ms": int(db_query_ms or 0), + "vector_search_ms": int(vector_search_ms or 0), + "graph_query_ms": int(graph_query_ms or 0), + "db_timeout": bool(db_timeout), + "vector_timeout": bool(vector_timeout), + "graph_timeout": bool(graph_timeout), + }, + "tool_trace": tool_trace if isinstance(tool_trace, list) else [], + "qa_id": int(qa_id) if qa_id is not None else None, + "session_id": int(session_id) if session_id is not None else None, + "turn_index": int(turn_index or 1), + "relevance_max": round(max(0.0, self._to_float(relevance_max, 0.0)), 4), + "action_taken": str(action_taken).strip() if action_taken else None, + "signal_id": int(signal_id) if signal_id is not None else None, + "reply_required": bool(reply_required), + "reply_text": str(reply_text or "").strip() if reply_text else None, + "ack_latency_ms": int(ack_latency_ms) if ack_latency_ms is not None else None, + "trace_id": str(trace_id or "").strip() or None, + "processing_stage": str(processing_stage or "").strip() or None, + } + + @staticmethod + def _extract_ack_metadata_from_sources( + sources: Any, + *, + answer: str = "", + ) -> dict[str, Any]: + rows = sources if isinstance(sources, list) else [] + action_taken = "" + signal_id: Optional[int] = None + reply_required = False + ack_latency_ms: Optional[int] = None + trace_id = "" + processing_stage = "" + for source in rows: + if not isinstance(source, dict): + continue + meta = source.get("metadata") if isinstance(source.get("metadata"), dict) else {} + candidate_action = str(meta.get("action_taken") or "").strip() + if not candidate_action: + continue + action_taken = candidate_action + try: + signal_id = int(meta.get("signal_id")) if meta.get("signal_id") is not None else None + except Exception: + signal_id = None + trace_id = str(meta.get("trace_id") or "").strip() + processing_stage = str(meta.get("processing_stage") or meta.get("action_mode") or "").strip() + try: + ack_latency_ms = ( + int(meta.get("ack_latency_ms")) if meta.get("ack_latency_ms") is not None else None + ) + except Exception: + ack_latency_ms = None + reply_required = True + break + reply_text = str(answer or "").strip() if reply_required else "" + normalized_stage = processing_stage + if normalized_stage == "write": + normalized_stage = "ack_only" + return { + "action_taken": action_taken or None, + "signal_id": signal_id, + "reply_required": bool(reply_required), + "reply_text": reply_text or None, + "ack_latency_ms": ack_latency_ms, + "trace_id": trace_id or None, + "processing_stage": normalized_stage or None, + } + diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_precheck.py b/backend/app/services/adaptive_rag_service/mixin_answer_precheck.py new file mode 100644 index 00000000..e995fa21 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_answer_precheck.py @@ -0,0 +1,181 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGAnswerPrecheckMixin: + async def _maybe_return_clarification_response( + self, + *, + clarification_answer: Optional[str], + started: float, + clean_q: str, + effective_q: str, + query_type: str, + asked_by: Optional[int], + asked_via: str, + project_id: Optional[int], + session_id: Optional[int], + turn_index: int, + state_row: Optional[QASessionState], + rewrite_meta: dict[str, Any], + context_session_id: Optional[int], + active_thinking_mode: str, + ) -> Optional[dict[str, Any]]: + if not clarification_answer: + return None + + elapsed = int((time.perf_counter() - started) * 1000) + clarify_sources = [ + { + "source": "system", + "label": "Session State", + "score": 0.95, + "metadata": {"mode": "clarification", "reason": "missing_subject"}, + } + ] + qa = QARecord( + tenant_id=self.tenant_id, + question=clean_q, + answer=clarification_answer, + sources=clarify_sources, + intent="question", + domains_used=["system_context"], + response_time_ms=elapsed, + asked_by=asked_by, + asked_via=(asked_via or "pmflow")[:20], + project_id=project_id, + session_id=session_id, + turn_index=turn_index, + ) + self.db.add(qa) + await self._update_session_state_after_turn( + session_state=state_row, + question=clean_q, + effective_question=effective_q, + answer=clarification_answer, + query_type=query_type, + ambiguity="needs_clarification", + rewrite_meta=rewrite_meta, + sources=clarify_sources, + ) + await self.db.commit() + await self.db.refresh(qa) + await self.context_cache.append_session_messages( + tenant_id=self.tenant_id, + session_id=context_session_id, + question=clean_q, + answer=clarification_answer, + ) + return self._build_response_payload( + answer=clarification_answer, + sources=clarify_sources, + domains_used=["system_context"], + response_time_ms=elapsed, + db_query_ms=0, + vector_search_ms=0, + db_timeout=False, + vector_timeout=False, + qa_id=int(qa.id), + session_id=int(session_id) if session_id is not None else None, + turn_index=int(turn_index), + relevance_max=0.95, + query_type=query_type, + confidence=0.92, + missing_fields=["subject"], + thinking_mode=active_thinking_mode, + ) + + async def _maybe_return_snapshot_direct_response( + self, + *, + direct_answer: Optional[str], + started: float, + clean_q: str, + effective_q: str, + asked_by: Optional[int], + asked_via: str, + project_id: Optional[int], + session_id: Optional[int], + turn_index: int, + state_row: Optional[QASessionState], + rewrite_meta: dict[str, Any], + context_session_id: Optional[int], + active_thinking_mode: str, + user_snapshot: Optional[dict[str, Any]], + ) -> Optional[dict[str, Any]]: + if not direct_answer: + return None + + elapsed = int((time.perf_counter() - started) * 1000) + direct_sources = [ + { + "source": "system", + "label": "System Snapshot", + "score": 0.99, + "metadata": {"mode": "snapshot_direct", "topic": "identity_or_architecture"}, + } + ] + if user_snapshot: + direct_sources.append( + { + "source": "system", + "label": "User Snapshot", + "score": 0.98, + "metadata": {"mode": "snapshot_direct", "topic": "current_user"}, + } + ) + + qa = QARecord( + tenant_id=self.tenant_id, + question=clean_q, + answer=direct_answer, + sources=direct_sources, + intent="question", + domains_used=["system_context"], + response_time_ms=elapsed, + asked_by=asked_by, + asked_via=(asked_via or "pmflow")[:20], + project_id=project_id, + session_id=session_id, + turn_index=turn_index, + ) + self.db.add(qa) + await self._update_session_state_after_turn( + session_state=state_row, + question=clean_q, + effective_question=effective_q, + answer=direct_answer, + query_type=QUERY_TYPE_EXPLAIN, + ambiguity="clear", + rewrite_meta=rewrite_meta, + sources=direct_sources, + ) + await self.db.commit() + await self.db.refresh(qa) + await self.context_cache.append_session_messages( + tenant_id=self.tenant_id, + session_id=context_session_id, + question=clean_q, + answer=direct_answer, + ) + return self._build_response_payload( + answer=direct_answer, + sources=direct_sources, + domains_used=["system_context"], + response_time_ms=elapsed, + db_query_ms=0, + vector_search_ms=0, + db_timeout=False, + vector_timeout=False, + qa_id=int(qa.id), + session_id=int(session_id) if session_id is not None else None, + turn_index=int(turn_index), + relevance_max=1.0, + query_type=QUERY_TYPE_EXPLAIN, + confidence=0.99, + missing_fields=[], + thinking_mode=active_thinking_mode, + ) + diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py b/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py new file mode 100644 index 00000000..037b6ae4 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py @@ -0,0 +1,287 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGAnswerRetrievalMixin: + async def _build_retrieval_response( + self, + *, + clean_q: str, + effective_q: str, + asked_by: Optional[int], + asked_via: str, + project_id: Optional[int], + source_chat_id: Optional[str], + session_id: Optional[int], + turn_index: int, + context_session_id: Optional[int], + conversation: list[dict[str, Any]], + state_row: Optional[QASessionState], + rewrite_meta: dict[str, Any], + query_type: str, + thinking_mode: str, + active_thinking_mode: str, + deep_thinking: bool, + forced_deep_reason: str, + deep_retrieval_steps: int, + deep_vector_top_k: int, + system_snapshot: dict[str, Any], + user_snapshot: Optional[dict[str, Any]], + started: float, + ) -> dict[str, Any]: + domains = self._route_domains(effective_q) + if str(asked_via or "").strip().lower() == "feishu" and source_chat_id and "feishu" not in domains: + domains.append("feishu") + retrieved, search_meta = await self._multi_step_search( + question=effective_q, + domains=domains, + project_id=project_id, + source_chat_id=source_chat_id, + session_state=state_row, + max_steps_override=deep_retrieval_steps if deep_thinking else None, + vector_top_k=deep_vector_top_k if deep_thinking else None, + ) + db_query_ms = int(search_meta.get("db_query_ms") or 0) + vector_search_ms = int(search_meta.get("vector_search_ms") or 0) + graph_query_ms = int(search_meta.get("graph_query_ms") or 0) + db_timeout = bool(search_meta.get("db_timeout")) + vector_timeout = bool(search_meta.get("vector_timeout")) + graph_timeout = bool(search_meta.get("graph_timeout")) + tool_trace = list(search_meta.get("tool_trace") or []) + tool_trace.insert( + 0, + { + "step": 0, + "tool": "thinking_mode", + "mode": active_thinking_mode, + "fast_path_bypassed": bool(deep_thinking), + "reason": forced_deep_reason or ( + "manual_deep" if self._normalize_thinking_mode(thinking_mode) == THINKING_MODE_DEEP else "normal" + ), + }, + ) + final_domains = [str(x) for x in (search_meta.get("domains_used") or domains)] + + max_score = retrieved[0].score if retrieved else 0.0 + answer_text = await self._generate_answer( + effective_q, + retrieved, + conversation, + project_id, + system_snapshot=system_snapshot, + user_snapshot=user_snapshot, + relevance_max=max_score, + thinking_mode=active_thinking_mode, + context_block_limit=DEEP_CONTEXT_BLOCKS if deep_thinking else None, + answer_max_tokens=DEEP_ANSWER_MAX_TOKENS if deep_thinking else None, + ) + used_sources = self._dedupe_sources(retrieved) + if not used_sources: + used_sources = [ + { + "source": "system", + "label": "System Snapshot", + "score": round(max(max_score, 0.6), 4), + "metadata": {"mode": "snapshot_only", "relevance_max": round(max_score, 4)}, + } + ] + if user_snapshot: + used_sources.append( + { + "source": "system", + "label": "User Snapshot", + "score": round(max(max_score, 0.58), 4), + "metadata": {"mode": "snapshot_only", "relevance_max": round(max_score, 4)}, + } + ) + + auto_retry_reason = "" + if not deep_thinking: + auto_retry_reason = self._should_auto_deep_retry( + question=clean_q, + effective_question=effective_q, + sources=used_sources, + relevance_max=max_score, + ) + if auto_retry_reason and not self._allow_auto_deep_retry_for_channel( + asked_via=asked_via, + question=clean_q, + effective_question=effective_q, + reason=auto_retry_reason, + ): + auto_retry_reason = "" + if auto_retry_reason: + deep_retrieved, deep_meta = await self._multi_step_search( + question=effective_q, + domains=domains, + project_id=project_id, + source_chat_id=source_chat_id, + session_state=state_row, + max_steps_override=deep_retrieval_steps, + vector_top_k=deep_vector_top_k, + ) + deep_max_score = deep_retrieved[0].score if deep_retrieved else 0.0 + deep_answer_text = await self._generate_answer( + effective_q, + deep_retrieved, + conversation, + project_id, + system_snapshot=system_snapshot, + user_snapshot=user_snapshot, + relevance_max=deep_max_score, + thinking_mode=THINKING_MODE_DEEP, + context_block_limit=DEEP_CONTEXT_BLOCKS, + answer_max_tokens=DEEP_ANSWER_MAX_TOKENS, + ) + deep_sources = self._dedupe_sources(deep_retrieved) + if not deep_sources: + deep_sources = [ + { + "source": "system", + "label": "System Snapshot", + "score": round(max(deep_max_score, 0.6), 4), + "metadata": {"mode": "snapshot_only", "relevance_max": round(deep_max_score, 4)}, + } + ] + if user_snapshot: + deep_sources.append( + { + "source": "system", + "label": "User Snapshot", + "score": round(max(deep_max_score, 0.58), 4), + "metadata": {"mode": "snapshot_only", "relevance_max": round(deep_max_score, 4)}, + } + ) + + merged_metrics = self._merge_search_metrics( + { + "db_query_ms": db_query_ms, + "vector_search_ms": vector_search_ms, + "graph_query_ms": graph_query_ms, + "db_timeout": db_timeout, + "vector_timeout": vector_timeout, + "graph_timeout": graph_timeout, + }, + deep_meta, + ) + db_query_ms = int(merged_metrics.get("db_query_ms") or 0) + vector_search_ms = int(merged_metrics.get("vector_search_ms") or 0) + graph_query_ms = int(merged_metrics.get("graph_query_ms") or 0) + db_timeout = bool(merged_metrics.get("db_timeout")) + vector_timeout = bool(merged_metrics.get("vector_timeout")) + graph_timeout = bool(merged_metrics.get("graph_timeout")) + + deep_trace = list(deep_meta.get("tool_trace") or []) + tool_trace.append( + { + "step": 98, + "tool": "auto_deep_retry", + "reason": auto_retry_reason, + "prev_relevance_max": round(float(max_score or 0.0), 4), + "prev_snapshot_only": self._is_snapshot_only_sources(used_sources), + } + ) + for row in deep_trace: + tool_trace.append(row) + + final_domains = [str(x) for x in (deep_meta.get("domains_used") or final_domains or domains)] + max_score = deep_max_score + answer_text = deep_answer_text + used_sources = deep_sources + deep_thinking = True + active_thinking_mode = THINKING_MODE_DEEP + + evidence_guard_triggered = self._should_block_answer_with_guard( + question=clean_q, + effective_question=effective_q, + sources=used_sources, + relevance_max=max_score, + ) + if evidence_guard_triggered: + answer_text = self._build_evidence_guard_answer(clean_q) + used_sources = [ + { + "source": "system", + "label": "Evidence Guard", + "score": 0.99, + "metadata": {"mode": "guard", "reason": "snapshot_only_process_query"}, + } + ] + max_score = min(float(max_score or 0.0), 0.35) + if "system_context" not in final_domains: + final_domains.append("system_context") + tool_trace.append( + { + "step": 99, + "tool": "answer_guard", + "reason": "insufficient_call_chain_evidence", + } + ) + + elapsed = int((time.perf_counter() - started) * 1000) + qa = QARecord( + tenant_id=self.tenant_id, + question=clean_q, + answer=answer_text, + sources=used_sources, + intent="question", + domains_used=final_domains, + response_time_ms=elapsed, + asked_by=asked_by, + asked_via=(asked_via or "pmflow")[:20], + project_id=project_id, + session_id=session_id, + turn_index=turn_index, + ) + self.db.add(qa) + confidence = round(max(0.2, min(1.0, max_score if max_score > 0 else 0.55)), 4) + if evidence_guard_triggered: + confidence = min(confidence, 0.35) + missing_fields = [] + if max_score < 0.45: + missing_fields = ["high_relevance_evidence"] + if evidence_guard_triggered: + missing_fields = sorted(set([*missing_fields, "call_chain_evidence", "target_scope"])) + await self._update_session_state_after_turn( + session_state=state_row, + question=clean_q, + effective_question=effective_q, + answer=answer_text, + query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_EXPLAIN, + ambiguity="clear" if not missing_fields else "low_evidence", + rewrite_meta=rewrite_meta, + sources=used_sources, + ) + await self.db.commit() + await self.db.refresh(qa) + await self.context_cache.append_session_messages( + tenant_id=self.tenant_id, + session_id=context_session_id, + question=clean_q, + answer=answer_text, + ) + + return self._build_response_payload( + answer=answer_text, + sources=used_sources, + domains_used=final_domains, + response_time_ms=elapsed, + db_query_ms=db_query_ms, + vector_search_ms=vector_search_ms, + graph_query_ms=graph_query_ms, + db_timeout=db_timeout, + vector_timeout=vector_timeout, + graph_timeout=graph_timeout, + tool_trace=tool_trace, + qa_id=int(qa.id), + session_id=int(session_id) if session_id is not None else None, + turn_index=int(turn_index), + relevance_max=round(max_score, 4), + query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_EXPLAIN, + confidence=confidence, + missing_fields=missing_fields, + thinking_mode=active_thinking_mode, + ) + diff --git a/backend/app/services/adaptive_rag_service/mixin_conversation_utils_a_p1.py b/backend/app/services/adaptive_rag_service/mixin_conversation_utils_a_p1.py new file mode 100644 index 00000000..7582524d --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_conversation_utils_a_p1.py @@ -0,0 +1,237 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGConversationUtilsAMixinP1: + async def _load_recent_conversation( + self, + *, + asked_by: Optional[int], + asked_via: str, + project_id: Optional[int], + session_id: Optional[int], + external_session_id: Optional[int], + limit: int, + ) -> list[dict[str, str]]: + active_session_id = session_id if session_id is not None else external_session_id + if active_session_id is not None: + cached_messages = await self.context_cache.get_session_messages( + tenant_id=self.tenant_id, + session_id=active_session_id, + ) + if cached_messages: + cached_turns = self.context_cache.messages_to_turns(cached_messages) + if cached_turns: + return cached_turns[-limit:] + + if asked_by is None: + return [] + + stmt = select(QARecord).where( + and_( + QARecord.tenant_id == self.tenant_id, + QARecord.asked_by == int(asked_by), + QARecord.asked_via == (asked_via or "pmflow")[:20], + ) + ) + if session_id is not None: + stmt = stmt.where(QARecord.session_id == int(session_id)) + if project_id is not None: + stmt = stmt.where(QARecord.project_id == int(project_id)) + rows = (await self.db.execute(stmt.order_by(QARecord.created_at.desc()).limit(limit))).scalars().all() + return [{"question": str(x.question or ""), "answer": str(x.answer or "")} for x in reversed(rows)] + + def _map_domains_to_source_types(self, domains: list[str]) -> list[str]: + mapped: list[str] = [] + if "doc" in domains: + mapped.append("doc") + if "feishu" in domains: + mapped.append("feishu") + if "pmflow" in domains: + mapped.append("pmflow") + if "url" in domains: + mapped.append("url") + return mapped + + def _source_label(self, source_type: str, metadata: dict[str, Any]) -> str: + if source_type == "doc": + title = metadata.get("title") or "unknown" + page_number = metadata.get("page_number") + if page_number is not None: + return f"Document[{title}] p.{page_number}" + return f"Document[{title}]" + if source_type == "url": + return f"URL[{metadata.get('file_url') or metadata.get('url') or 'external'}]" + if source_type == "feishu": + return "Feishu history" + if source_type == "pmflow": + record_type = str(metadata.get("record_type") or "").strip() + record_id = metadata.get("record_id") + if record_type and record_id is not None: + return f"PMFlow[{record_type}#{record_id}]" + return "PMFlow records" + return "Knowledge base" + + async def _noop_results(self) -> list[RetrievedItem]: + return [] + + async def _fetch_url_text(self, url: str) -> str: + target = str(url or "").strip() + if not target: + raise ValueError("url_empty") + async with httpx.AsyncClient(timeout=20.0, trust_env=False, follow_redirects=True) as client: + resp = await client.get(target, headers={"User-Agent": "PMFlow-RAG/1.0"}) + resp.raise_for_status() + content_type = str(resp.headers.get("content-type") or "").lower() + body = resp.text + if "html" in content_type: + return self._html_to_text(body) + return body[:300000] + + @staticmethod + def _html_to_text(html: str) -> str: + text = str(html or "") + text = re.sub(r".*?", " ", text, flags=re.IGNORECASE | re.DOTALL) + text = re.sub(r".*?", " ", text, flags=re.IGNORECASE | re.DOTALL) + text = re.sub(r"<[^>]+>", " ", text) + text = re.sub(r" | ", " ", text) + text = re.sub(r"&", "&", text) + text = re.sub(r"<", "<", text) + text = re.sub(r">", ">", text) + text = re.sub(r"\s+", " ", text).strip() + return text[:300000] + + def _chunk_text(self, text: str, *, chunk_size: int = 500, overlap: int = 50) -> list[str]: + clean = str(text or "").strip() + if not clean: + return [] + if len(clean) <= chunk_size: + return [clean] + + chunks: list[str] = [] + start = 0 + step = max(1, chunk_size - overlap) + while start < len(clean): + end = min(len(clean), start + chunk_size) + part = clean[start:end].strip() + if part: + chunks.append(part) + if end >= len(clean): + break + start += step + return chunks + + def _chunk_text_with_metadata( + self, + text: str, + *, + chunk_size: int = 500, + overlap: int = 50, + ) -> list[tuple[str, dict[str, Any]]]: + segments = self._split_text_by_page_markers(text) + if not segments: + return [(part, {}) for part in self._chunk_text(text, chunk_size=chunk_size, overlap=overlap)] + + out: list[tuple[str, dict[str, Any]]] = [] + for page_number, segment in segments: + for part in self._chunk_text(segment, chunk_size=chunk_size, overlap=overlap): + meta: dict[str, Any] = {} + if page_number is not None: + meta["page_number"] = int(page_number) + out.append((part, meta)) + return out + + def _split_text_by_page_markers(self, text: str) -> list[tuple[Optional[int], str]]: + clean = str(text or "") + matches = list(PAGE_MARKER_RE.finditer(clean)) + if not matches: + return [] + + segments: list[tuple[Optional[int], str]] = [] + prefix = clean[: matches[0].start()].strip() + if prefix: + segments.append((None, prefix)) + + for idx, marker in enumerate(matches): + try: + page_number = int(marker.group(1)) + except Exception: + page_number = None + start = marker.end() + end = matches[idx + 1].start() if idx + 1 < len(matches) else len(clean) + segment = clean[start:end].strip() + if segment: + segments.append((page_number, segment)) + return segments + + @staticmethod + def _to_float(value: Any, default: float) -> float: + try: + return float(value) + except Exception: + return float(default) + + @staticmethod + def _safe_dict(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if isinstance(value, str): + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + return {} + + @staticmethod + def _parse_json_object(raw: str) -> dict[str, Any]: + text = str(raw or "").strip() + if text.startswith("```"): + parts = text.split("```") + if len(parts) >= 2: + text = parts[1].strip() + if text.startswith("json"): + text = text[4:].strip() + parsed: dict[str, Any] = {} + try: + value = json.loads(text) + if isinstance(value, dict): + parsed = value + except Exception: + pass + return parsed + + @staticmethod + def _embedding_to_list(raw: Any) -> list[float]: + if raw is None: + return [] + if isinstance(raw, list): + return [float(x) for x in raw] + if isinstance(raw, str): + try: + value = json.loads(raw) + if isinstance(value, list): + return [float(x) for x in value] + except Exception: + return [] + return [] + + @staticmethod + def _cosine_similarity(v1: list[float], v2: list[float]) -> float: + if not v1 or not v2: + return 0.0 + size = min(len(v1), len(v2)) + if size == 0: + return 0.0 + a = v1[:size] + b = v2[:size] + dot = sum(x * y for x, y in zip(a, b)) + norm_a = math.sqrt(sum(x * x for x in a)) + norm_b = math.sqrt(sum(y * y for y in b)) + if norm_a == 0 or norm_b == 0: + return 0.0 + sim = dot / (norm_a * norm_b) + return (sim + 1) / 2 + diff --git a/backend/app/services/adaptive_rag_service/mixin_conversation_utils_a_p2.py b/backend/app/services/adaptive_rag_service/mixin_conversation_utils_a_p2.py new file mode 100644 index 00000000..0f012bcc --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_conversation_utils_a_p2.py @@ -0,0 +1,38 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGConversationUtilsAMixinP2: + def _dedupe_sources(self, retrieved: list[RetrievedItem]) -> list[dict[str, Any]]: + seen: set[str] = set() + out: list[dict[str, Any]] = [] + for item in retrieved: + meta_identity = ( + item.metadata.get("message_id") + or item.metadata.get("file_url") + or item.metadata.get("target_url") + or item.metadata.get("path") + or item.metadata.get("relation_id") + or item.metadata.get("entity_id") + or item.metadata.get("subject_entity_id") + or item.metadata.get("object_entity_id") + or item.content[:80] + ) + key = f"{item.source}|{item.source_label}|{meta_identity}" + if key in seen: + continue + seen.add(key) + out.append( + { + "source": item.source, + "label": item.source_label, + "score": round(item.score, 4), + "metadata": item.metadata, + } + ) + if len(out) >= 8: + break + return out + diff --git a/backend/app/services/adaptive_rag_service/mixin_conversation_utils_b.py b/backend/app/services/adaptive_rag_service/mixin_conversation_utils_b.py new file mode 100644 index 00000000..0045d2e2 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_conversation_utils_b.py @@ -0,0 +1,43 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGConversationUtilsBMixin: + @staticmethod + def _build_pmflow_target_url(*, record_type: str, record_id: Any, project_id: Any) -> str: + safe_type = str(record_type or "").strip().lower() + try: + pid = int(project_id) if project_id is not None else None + except Exception: + pid = None + try: + rid = int(record_id) if record_id is not None else None + except Exception: + rid = None + if pid is None: + return "" + if safe_type == "charter" and rid is not None: + return f"/projects/{pid}/charter/{rid}" + if safe_type == "task" and rid is not None: + return f"/projects/{pid}/board?focus=task&taskId={rid}" + if safe_type == "review" and rid is not None: + return f"/projects/{pid}/board?focus=review&reviewId={rid}" + return f"/projects/{pid}/board" + + @staticmethod + def _serialize_doc(doc: KnowledgeBaseDoc) -> dict[str, Any]: + return { + "id": int(doc.id), + "title": doc.title, + "file_type": doc.file_type, + "source_type": doc.source_type, + "file_url": doc.file_url, + "status": doc.status, + "uploaded_by": doc.uploaded_by, + "created_at": doc.created_at.isoformat() if doc.created_at else None, + "last_fetched_at": doc.last_fetched_at.isoformat() if doc.last_fetched_at else None, + "error_message": doc.error_message, + } + diff --git a/backend/app/services/adaptive_rag_service/mixin_core_intent.py b/backend/app/services/adaptive_rag_service/mixin_core_intent.py new file mode 100644 index 00000000..7a4b7de8 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_core_intent.py @@ -0,0 +1,147 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGCoreIntentMixin: + def __init__(self, db: AsyncSession, *, tenant_id: int) -> None: + self.db = db + self.tenant_id = int(tenant_id) + self.llm = LLMService() + self.context_cache = context_cache_service + + @staticmethod + def _normalize_thinking_mode(value: Optional[str]) -> str: + text = str(value or "").strip().lower() + if text == THINKING_MODE_DEEP: + return THINKING_MODE_DEEP + return THINKING_MODE_NORMAL + + @staticmethod + def _normalize_conversation_key(value: Optional[str]) -> str: + raw = str(value or "").strip() + if not raw: + return "" + cleaned = re.sub(r"\s+", "", raw.lower()) + cleaned = re.sub(r"[^a-z0-9:_\-\.]", "", cleaned) + return cleaned[:160] + + def _build_external_conversation_key( + self, + *, + asked_via: str, + source_chat_id: Optional[str], + conversation_key: Optional[str], + ) -> str: + explicit = self._normalize_conversation_key(conversation_key) + if explicit: + return explicit + channel = str(asked_via or "").strip().lower() + if channel == "feishu": + chat = str(source_chat_id or "").strip() + if chat: + return self._normalize_conversation_key(f"feishu:{chat}") + return "" + + @staticmethod + def _external_session_id_from_key(conversation_key: str) -> Optional[int]: + key = str(conversation_key or "").strip() + if not key: + return None + digest = hashlib.sha1(key.encode("utf-8")).hexdigest() + bucket = int(digest[:10], 16) % 500_000_000 + return 1_500_000_000 + bucket + + async def classify_intent(self, message: str) -> dict[str, Any]: + text = str(message or "").strip() + if not text: + return { + "intent": "statement", + "confidence": 1.0, + "question_part": "", + "statement_part": "", + } + + heuristic = self._classify_intent_rule(text) + if heuristic["confidence"] >= 0.86: + return heuristic + + prompt = PROMPT_RAG_INTENT_CLASSIFICATION.format(message=text[:1200]).strip() + try: + raw = await self.llm.generate( + prompt, + db=self.db, + tenant_id=self.tenant_id, + function_name="rag_intent_classification", + temperature=0.0, + max_tokens=220, + use_prompt_profile=False, + ) + parsed = self._parse_json_object(raw) + intent = str(parsed.get("intent") or heuristic["intent"]).strip().lower() + if intent not in {"question", "statement", "mixed"}: + intent = heuristic["intent"] + confidence = self._to_float(parsed.get("confidence"), heuristic["confidence"]) + question_part = str(parsed.get("question_part") or "").strip() + statement_part = str(parsed.get("statement_part") or "").strip() + if not question_part and intent in {"question", "mixed"}: + question_part = heuristic["question_part"] + if not statement_part and intent in {"statement", "mixed"}: + statement_part = heuristic["statement_part"] + return { + "intent": intent, + "confidence": max(0.0, min(1.0, confidence)), + "question_part": question_part, + "statement_part": statement_part, + } + except Exception: + return heuristic + + def _classify_intent_rule(self, text: str) -> dict[str, Any]: + question_score = 0.0 + statement_score = 0.0 + lowered = text.lower() + + if "?" in text or "?" in text: + question_score += 0.35 + if lowered.endswith(("吗", "么", "呢", "呀", "啊")): + question_score += 0.24 + for hint in QUESTION_HINTS: + if hint in lowered: + question_score += 0.08 + if PROJECT_EXISTENCE_RE.search(text): + question_score += 0.26 + if re.search(r"(现在|当前|目前|请问).*(项目|任务|负责人|状态|进度|列表|清单)", text): + question_score += 0.18 + for hint in STATEMENT_HINTS: + if hint in lowered: + statement_score += 0.10 + + sentences = [s.strip() for s in SENTENCE_SPLIT_RE.split(text) if s.strip()] + question_sentences = [s for s in sentences if "?" in s or "?" in s or any(k in s.lower() for k in QUESTION_HINTS)] + statement_sentences = [s for s in sentences if s not in question_sentences] + + if question_sentences and statement_sentences: + return { + "intent": "mixed", + "confidence": 0.88, + "question_part": " ".join(question_sentences)[:1000], + "statement_part": " ".join(statement_sentences)[:1000], + } + + if question_score >= 0.28 and question_score >= statement_score: + return { + "intent": "question", + "confidence": min(0.9, 0.62 + question_score), + "question_part": text[:1000], + "statement_part": "", + } + + return { + "intent": "statement", + "confidence": min(0.9, 0.66 + statement_score), + "question_part": "", + "statement_part": text[:1000], + } + diff --git a/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py b/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py new file mode 100644 index 00000000..7ee74008 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py @@ -0,0 +1,201 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGDeepGuardAnswerMixin: + def _build_snapshot_direct_answer( + self, + question: str, + *, + system_snapshot: Optional[dict[str, Any]], + user_snapshot: Optional[dict[str, Any]], + ) -> Optional[str]: + text = str(question or "").strip().lower() + if not text: + return None + if self._is_correction_query(text): + return None + if self._is_engine_process_query(text): + return None + + ask_identity = any(k in text for k in IDENTITY_KEYWORDS) + ask_architecture = any(k in text for k in ARCHITECTURE_KEYWORDS) + has_feature_token = any(k in text for k in FEATURE_QUERY_KEYWORDS) + ask_feature = has_feature_token and any(k in text for k in FEATURE_PURPOSE_HINTS) + if not ask_identity and not ask_architecture and not ask_feature: + return None + + system_data = system_snapshot if isinstance(system_snapshot, dict) else {} + assistant = system_data.get("assistant_profile") if isinstance(system_data.get("assistant_profile"), dict) else {} + platform = system_data.get("platform_profile") if isinstance(system_data.get("platform_profile"), dict) else {} + backend = platform.get("backend") if isinstance(platform.get("backend"), dict) else {} + infra = platform.get("infrastructure") if isinstance(platform.get("infrastructure"), dict) else {} + module_counts = backend.get("module_counts") if isinstance(backend.get("module_counts"), dict) else {} + + lines: list[str] = [] + if ask_identity: + name = str(assistant.get("name") or "PMFlow AI 管家") + identity = str(assistant.get("identity") or "PMFlow 内置 AI 助手") + lines.append(f"我是{name},{identity}。") + responsibilities = assistant.get("responsibilities") if isinstance(assistant.get("responsibilities"), list) else [] + if responsibilities: + joined = ";".join(str(x) for x in responsibilities[:5] if str(x).strip()) + if joined: + lines.append(f"职责范围:{joined}。") + + if ask_architecture: + runtime = str(platform.get("runtime") or "unknown") + backend_framework = str(backend.get("framework") or "FastAPI") + orm_name = str(backend.get("orm") or "SQLAlchemy 2.0") + db_name = str(infra.get("database") or "PostgreSQL") + cache_name = str(infra.get("cache") or "Redis") + lines.append( + f"当前系统采用前后端分层架构:后端 {backend_framework} + {orm_name},数据库 {db_name},缓存 {cache_name},运行环境 {runtime}。" + ) + if module_counts: + lines.append( + "后端模块规模:" + f"api {int(module_counts.get('api', 0))}、" + f"services {int(module_counts.get('services', 0))}、" + f"models {int(module_counts.get('models', 0))}、" + f"schemas {int(module_counts.get('schemas', 0))}。" + ) + + if ask_feature: + feature_hits = self._match_feature_catalog_entries( + question=question, + system_snapshot=system_data, + limit=4, + ) + if feature_hits: + lines.append("命中的产品功能:") + for idx, row in enumerate(feature_hits, start=1): + name = str(row.get("name") or "未知功能") + summary = str(row.get("summary") or "暂无说明") + route = str(row.get("route") or "") + page_file = str(row.get("page_file") or "") + apis = row.get("apis") if isinstance(row.get("apis"), list) else [] + api_text = "、".join(str(x) for x in apis[:3] if str(x).strip()) + line = f"{idx}. {name}:{summary}" + if route: + line += f" 入口 {route}。" + if page_file: + line += f" 页面文件 {page_file}。" + if api_text: + line += f" 相关接口 {api_text}。" + lines.append(line) + else: + catalog = self._feature_catalog_from_snapshot(system_data) + if catalog: + brief = "、".join( + str(row.get("name") or "") + for row in catalog[:8] + if isinstance(row, dict) and str(row.get("name") or "").strip() + ) + if brief: + lines.append(f"当前功能目录包括:{brief}。你可以继续指定其中一个功能名称,我会返回用途与入口。") + else: + lines.append("当前 System Snapshot 未包含 feature_catalog,建议先刷新上下文缓存后再提问。") + + if ask_identity and isinstance(user_snapshot, dict) and user_snapshot: + projects = user_snapshot.get("projects") if isinstance(user_snapshot.get("projects"), list) else [] + tasks = user_snapshot.get("assigned_tasks") if isinstance(user_snapshot.get("assigned_tasks"), list) else [] + lines.append(f"你的当前上下文:关联项目 {len(projects)} 个,未完成任务 {len(tasks)} 个。") + + if not lines: + return None + lines.append("来源: System Snapshot / User Snapshot") + return "\n".join(lines)[:3200] + + async def _generate_answer( + self, + question: str, + retrieved: list[RetrievedItem], + conversation: list[dict[str, str]], + project_id: Optional[int], + *, + system_snapshot: Optional[dict[str, Any]] = None, + user_snapshot: Optional[dict[str, Any]] = None, + relevance_max: float = 0.0, + thinking_mode: str = THINKING_MODE_NORMAL, + context_block_limit: Optional[int] = None, + answer_max_tokens: Optional[int] = None, + ) -> str: + active_thinking_mode = self._normalize_thinking_mode(thinking_mode) + deep_thinking = active_thinking_mode == THINKING_MODE_DEEP + context_blocks: list[str] = [] + used_tokens = 0 + max_blocks = max(4, min(int(context_block_limit or DEFAULT_TOP_K * 2), 32)) + manifest = await self.get_project_manifest(project_id=project_id) + manifest_text = str(manifest.get("manifest_text") or "").strip() + if manifest_text: + manifest_block = f"[M] 项目画像\n{manifest_text}\n" + manifest_tokens = max(len(manifest_block) // 4, 1) + if used_tokens + manifest_tokens <= MAX_CONTEXT_TOKENS: + context_blocks.append(manifest_block) + used_tokens += manifest_tokens + + for idx, item in enumerate(retrieved[:max_blocks]): + block = f"[{idx + 1}] 来源: {item.source_label}\n相关度: {item.score:.3f}\n内容: {item.content}\n" + block_tokens = max(len(block) // 4, 1) + if used_tokens + block_tokens > MAX_CONTEXT_TOKENS: + break + used_tokens += block_tokens + context_blocks.append(block) + + history_lines = [] + for row in conversation[-10:]: + history_lines.append(f"Q: {row.get('question', '')}\nA: {row.get('answer', '')}") + + system_snapshot_text = json.dumps(system_snapshot or {}, ensure_ascii=False)[:12000] + user_snapshot_text = json.dumps(user_snapshot or {}, ensure_ascii=False)[:6000] + + prompt = PROMPT_RAG_ANSWER.format( + project_id=project_id if project_id is not None else "unknown", + question=question, + conversation=chr(10).join(history_lines) if history_lines else "无", + context_blocks=chr(10).join(context_blocks) if context_blocks else "无", + system_snapshot=system_snapshot_text or "{}", + user_snapshot=user_snapshot_text or "{}", + relevance_max=f"{max(0.0, float(relevance_max or 0.0)):.3f}", + ).strip() + if deep_thinking: + prompt = ( + f"{prompt}\n\n" + "补充要求(深入思考模式):\n" + "1. 先给结论,再给证据链;\n" + "2. 明确区分已验证事实与推断;\n" + "3. 若存在不确定项,给出待确认清单。" + ) + + effective_max_tokens = int( + answer_max_tokens + if answer_max_tokens is not None + else (DEEP_ANSWER_MAX_TOKENS if deep_thinking else 900) + ) + effective_max_tokens = max(256, min(effective_max_tokens, 3000)) + effective_temperature = 0.15 if deep_thinking else 0.2 + function_name = "adaptive_rag_answer_deep" if deep_thinking else "adaptive_rag_answer_normal" + + answer = await self.llm.generate( + prompt, + db=self.db, + tenant_id=self.tenant_id, + function_name=function_name, + temperature=effective_temperature, + max_tokens=effective_max_tokens, + use_prompt_profile=False, + ) + text = str(answer or "").strip() + if not text: + return "我未能从当前上下文生成有效答案,请补充问题细节后重试。" + enforced = self._enforce_feature_evidence_in_answer( + question=question, + answer_text=text, + retrieved=retrieved, + system_snapshot=system_snapshot, + ) + return str(enforced or text)[:3200] + diff --git a/backend/app/services/adaptive_rag_service/mixin_deep_guard_rules.py b/backend/app/services/adaptive_rag_service/mixin_deep_guard_rules.py new file mode 100644 index 00000000..81d55c95 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_deep_guard_rules.py @@ -0,0 +1,229 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGDeepGuardRulesMixin: + @staticmethod + def _contains_any(text: str, keywords: tuple[str, ...]) -> bool: + lowered = str(text or "").strip().lower() + if not lowered: + return False + return any(keyword in lowered for keyword in keywords) + + @classmethod + def _is_correction_query(cls, question: str) -> bool: + text = str(question or "").strip().lower() + if not text: + return False + if BRIEF_NEGATIVE_FEEDBACK_RE.match(text): + return True + if cls._contains_any(text, CORRECTION_CUE_KEYWORDS): + return True + if re.search(r"(不是|别|不要).{0,8}(系统架构|架构)", text): + return True + return False + + @classmethod + def _has_explicit_target_scope(cls, question: str) -> bool: + text = str(question or "").strip().lower() + if not text: + return False + explicit_groups: tuple[tuple[str, ...], ...] = ( + ENGINE_PROCESS_KEYWORDS, + FEATURE_QUERY_KEYWORDS, + ARCHITECTURE_KEYWORDS, + PROJECT_KEYWORDS, + PEOPLE_KEYWORDS, + DOC_KEYWORDS, + ) + for group in explicit_groups: + if cls._contains_any(text, group): + return True + if any(token in text for token in ("openclaw", "llm", "model", "engine", "api")): + return True + return False + + @classmethod + def _is_engine_process_query(cls, question: str) -> bool: + return cls._contains_any(question, ENGINE_PROCESS_KEYWORDS) + + @classmethod + def _is_explicit_deep_request(cls, question: str) -> bool: + return cls._contains_any(question, DEEP_EXPLICIT_KEYWORDS) + + @classmethod + def _forced_deep_reason( + cls, + *, + question: str, + effective_question: str, + conversation: list[dict[str, str]], + ) -> str: + if cls._is_correction_query(question) or cls._is_correction_query(effective_question): + if conversation: + return "correction_turn" + if cls._is_engine_process_query(question) or cls._is_engine_process_query(effective_question): + return "engine_process_query" + return "" + + @classmethod + def _should_force_deep_for_channel( + cls, + *, + asked_via: str, + question: str, + effective_question: str, + reason: str, + ) -> bool: + if not str(reason or "").strip(): + return False + channel = str(asked_via or "").strip().lower() + if channel != "feishu": + return True + + if cls._is_explicit_deep_request(question) or cls._is_explicit_deep_request(effective_question): + return True + if str(reason or "").strip() == "engine_process_query": + combined = f"{question} {effective_question}".strip() + return cls._has_explicit_target_scope(combined) + return False + + @classmethod + def _is_snapshot_only_sources(cls, sources: list[dict[str, Any]]) -> bool: + if not isinstance(sources, list) or not sources: + return True + allowed_labels = {"System Snapshot", "User Snapshot", "Session State"} + for row in sources: + if not isinstance(row, dict): + continue + source = str(row.get("source") or "").strip().lower() + label = str(row.get("label") or "").strip() + if source != "system": + return False + if label and label not in allowed_labels: + return False + return True + + @classmethod + def _should_auto_deep_retry( + cls, + *, + question: str, + effective_question: str, + sources: list[dict[str, Any]], + relevance_max: float, + ) -> str: + process_sensitive = cls._is_engine_process_query(question) or cls._is_engine_process_query(effective_question) + correction_sensitive = cls._is_correction_query(question) or cls._is_correction_query(effective_question) + if not process_sensitive and not correction_sensitive: + return "" + snapshot_only = cls._is_snapshot_only_sources(sources) + if snapshot_only: + return "snapshot_only" + if float(relevance_max or 0.0) < 0.62: + return "low_relevance" + return "" + + @classmethod + def _allow_auto_deep_retry_for_channel( + cls, + *, + asked_via: str, + question: str, + effective_question: str, + reason: str, + ) -> bool: + if not str(reason or "").strip(): + return False + channel = str(asked_via or "").strip().lower() + if channel != "feishu": + return True + if cls._is_explicit_deep_request(question) or cls._is_explicit_deep_request(effective_question): + return True + return False + + @classmethod + def _should_block_answer_with_guard( + cls, + *, + question: str, + effective_question: str, + sources: list[dict[str, Any]], + relevance_max: float, + ) -> bool: + if not (cls._is_engine_process_query(question) or cls._is_engine_process_query(effective_question)): + return False + if not cls._is_snapshot_only_sources(sources): + return False + return float(relevance_max or 0.0) < 0.68 + + @staticmethod + def _build_evidence_guard_answer(question: str) -> str: + raw = str(question or "").strip() + target = raw[:80] if raw else "该问题" + return ( + f"我不能对“{target}”直接给结论:当前只命中系统快照,缺少调用链证据。" + "请补充目标对象(如 openclaw 路由规则、函数名、入口层级),我会按调用链返回可核验答案。" + ) + + @staticmethod + def _is_snapshot_direct_question(question: str) -> bool: + text = str(question or "").strip().lower() + if not text: + return False + if AdaptiveRAGService._is_correction_query(text): + return False + if AdaptiveRAGService._is_engine_process_query(text): + return False + has_feature_token = any(k in text for k in FEATURE_QUERY_KEYWORDS) + ask_feature_purpose = has_feature_token and any(k in text for k in FEATURE_PURPOSE_HINTS) + return ( + any(k in text for k in IDENTITY_KEYWORDS) + or any(k in text for k in ARCHITECTURE_KEYWORDS) + or ask_feature_purpose + ) + + @staticmethod + def _expand_followup_question(question: str, conversation: list[dict[str, str]]) -> str: + current = str(question or "").strip() + if not current: + return current + + lowered = current.lower() + # 短句不等于追问:若已包含明确业务对象,则按独立问题处理,避免串错上下文。 + has_explicit_subject = any( + marker in lowered for marker in ("项目", "信号", "信号源", "任务", "系统", "架构", "数据库", "redis", "接口", "用户") + ) + is_short_followup = len(current) <= 16 and not has_explicit_subject + has_referential = any(k in lowered for k in FOLLOWUP_REFERENTIAL_KEYWORDS) + if not (is_short_followup or has_referential): + return current + + anchor_question = "" + for turn in reversed(conversation[-5:]): + candidate = str(turn.get("question") or "").strip() + if not candidate: + continue + if candidate == current: + continue + if any( + marker in candidate + for marker in ("项目", "系统", "任务", "信号", "接口", "数据库", "redis", "架构", "模块") + ): + anchor_question = candidate + break + + if not anchor_question: + return current + + is_enumeration = any(k in lowered for k in FOLLOWUP_ENUMERATION_KEYWORDS) + if is_enumeration and ("信号" in lowered or "信号" in anchor_question): + return f"{anchor_question}。请列出该项目关联的信号标题和ID。" + if is_enumeration and ("任务" in lowered or "任务" in anchor_question): + return f"{anchor_question}。请列出对应任务的标题和状态。" + if is_enumeration and "项目" in anchor_question: + return f"{anchor_question}。请列出对应项目的名称和ID。" + return f"{anchor_question}。补充问题:{current}" + diff --git a/backend/app/services/adaptive_rag_service/mixin_deterministic_query_core.py b/backend/app/services/adaptive_rag_service/mixin_deterministic_query_core.py new file mode 100644 index 00000000..528d5c82 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_deterministic_query_core.py @@ -0,0 +1,143 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGDeterministicQueryCoreMixin: + def _classify_query_type(self, question: str) -> str: + text = str(question or "").strip().lower() + if not text: + return QUERY_TYPE_UNKNOWN + # 若同一句同时出现“多少/几个”和“列出/分别”,优先按 list 处理,避免追问被误判为 count。 + if any(k in text for k in LIST_HINTS): + return QUERY_TYPE_LIST + if any(k in text for k in COUNT_HINTS): + return QUERY_TYPE_COUNT + if any(k in text for k in STATUS_HINTS): + return QUERY_TYPE_STATUS + if "为什么" in text or "如何" in text or "怎么" in text: + return QUERY_TYPE_EXPLAIN + return QUERY_TYPE_UNKNOWN + + async def _run_deterministic_query( + self, + *, + question: str, + project_id: Optional[int], + query_type: str, + ) -> Optional[dict[str, Any]]: + q_type = str(query_type or QUERY_TYPE_UNKNOWN) + if q_type == QUERY_TYPE_COUNT: + return await self._run_count_query(question=question, project_id=project_id) + if q_type == QUERY_TYPE_LIST: + return await self._run_list_query(question=question, project_id=project_id) + if q_type == QUERY_TYPE_STATUS: + progress = await self._try_fast_project_progress_answer(clean_q=question, project_id=project_id) + if progress: + progress["query_type"] = QUERY_TYPE_STATUS + progress.setdefault("confidence", 0.98) + progress.setdefault("missing_fields", []) + return progress + existence = await self._try_fast_project_existence_answer(clean_q=question, project_id=project_id) + if existence: + existence["query_type"] = QUERY_TYPE_STATUS + existence.setdefault("confidence", 0.96) + existence.setdefault("missing_fields", []) + return existence + return None + + async def _resolve_project_scope_for_question( + self, + *, + question: str, + project_id: Optional[int], + ) -> tuple[Optional[int], Optional[str], str]: + if project_id is not None: + row = ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == int(project_id), + ) + ) + ) + ).scalar_one_or_none() + if row is None: + return (None, None, "") + return (int(row.id), str(row.title or row.name or f"项目#{int(row.id)}"), "") + + direct_project_id, direct_project_name = await self._match_project_name_in_question(question=question) + if direct_project_id is not None and direct_project_name: + return (int(direct_project_id), str(direct_project_name), str(direct_project_name)[:24]) + + keyword = self._extract_project_lookup_keyword(question) + terms = self._extract_query_terms(question, preferred=keyword) + for term in terms: + matched = await self._search_projects_by_keyword(keyword=term, project_id=None, limit=5) + if matched: + target = matched[0] + return (int(target.id), str(target.title or target.name or f"项目#{int(target.id)}"), term) + return (None, None, keyword[:24] if keyword else "") + + @staticmethod + def _normalize_project_subject_text(text: str) -> str: + clean = str(text or "").strip().lower() + if not clean: + return "" + clean = clean.replace("—", "-").replace("_", " ") + clean = re.sub(r"[^\w\u4e00-\u9fff\- ]+", " ", clean) + clean = re.sub( + r"(我说的是|我说的|请问|请|帮我|一下|这个里面|这里面|里面|这个|那个|一下子|目前|当前|现在|有多少个|多少个|信号源|信号|项目|系统)", + " ", + clean, + ) + clean = re.sub(r"[-]+", " ", clean) + clean = re.sub(r"\s+", " ", clean).strip() + return clean + + async def _match_project_name_in_question(self, *, question: str) -> tuple[Optional[int], Optional[str]]: + normalized_question = self._normalize_project_subject_text(question) + if not normalized_question: + return (None, None) + + projects = ( + await self.db.execute( + select(Project) + .where(Project.tenant_id == self.tenant_id) + .order_by(Project.updated_at.desc(), Project.id.desc()) + .limit(200) + ) + ).scalars().all() + + best_id: Optional[int] = None + best_name: Optional[str] = None + best_score = 0 + for row in projects: + candidates = [ + str(row.title or "").strip(), + str(row.name or "").strip(), + ] + for name in candidates: + if len(name) < 2: + continue + normalized_name = self._normalize_project_subject_text(name) + if len(normalized_name) < 2: + continue + + score = 0 + if normalized_name in normalized_question: + score = len(normalized_name) + else: + tokens = [t for t in normalized_name.split(" ") if len(t) >= 2] + if tokens and all(t in normalized_question for t in tokens): + score = sum(len(t) for t in tokens) + + if score > best_score: + best_score = score + best_id = int(row.id) + best_name = str(row.title or row.name or f"项目#{int(row.id)}") + + return (best_id, best_name) + diff --git a/backend/app/services/adaptive_rag_service/mixin_deterministic_query_count.py b/backend/app/services/adaptive_rag_service/mixin_deterministic_query_count.py new file mode 100644 index 00000000..73d6fb91 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_deterministic_query_count.py @@ -0,0 +1,220 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGDeterministicQueryCountMixin: + async def _run_count_query(self, *, question: str, project_id: Optional[int]) -> Optional[dict[str, Any]]: + text = str(question or "").lower() + started = time.perf_counter() + has_project = "项目" in text + has_task = "任务" in text + has_signal = "信号" in text or "信号源" in text + has_people = self._is_people_query(text) + + # 先处理更具体的对象(信号/任务),避免“xxx项目里有多少信号”误落到项目总数分支。 + if has_signal: + scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question( + question=text, + project_id=project_id, + ) + if has_project and scoped_project_id is None and scoped_keyword: + elapsed = int((time.perf_counter() - started) * 1000) + return { + "answer": f"未检索到名称或描述包含“{scoped_keyword}”的项目,无法统计信号数。", + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.94, + "metadata": {"table": "projects", "query_keyword": scoped_keyword, "match_count": 0}, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_COUNT, + "confidence": 0.94, + "missing_fields": ["project_id"], + } + + stmt = select(func.count(Signal.id)).where(Signal.tenant_id == self.tenant_id) + if scoped_project_id is not None: + stmt = stmt.where(Signal.matched_project_id == int(scoped_project_id)) + total = int((await self.db.execute(stmt)).scalar() or 0) + elapsed = int((time.perf_counter() - started) * 1000) + if scoped_project_id is not None and scoped_project_name: + answer = f"项目「{scoped_project_name}」当前关联信号总数为 {total} 条。" + else: + answer = f"当前可见信号总数为 {total} 条。" + return { + "answer": answer, + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.99, + "metadata": { + "table": "signals", + "count": total, + "project_id": scoped_project_id, + "project_name": scoped_project_name, + }, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_COUNT, + "confidence": 0.99, + "missing_fields": [], + } + + if has_task: + scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question( + question=text, + project_id=project_id, + ) + if has_project and scoped_project_id is None and scoped_keyword: + elapsed = int((time.perf_counter() - started) * 1000) + return { + "answer": f"未检索到名称或描述包含“{scoped_keyword}”的项目,无法统计任务数。", + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.94, + "metadata": {"table": "projects", "query_keyword": scoped_keyword, "match_count": 0}, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_COUNT, + "confidence": 0.94, + "missing_fields": ["project_id"], + } + + stmt = select(func.count(Task.id)).where(Task.tenant_id == self.tenant_id) + if scoped_project_id is not None: + stmt = stmt.where(Task.project_id == int(scoped_project_id)) + total = int((await self.db.execute(stmt)).scalar() or 0) + elapsed = int((time.perf_counter() - started) * 1000) + if scoped_project_id is not None and scoped_project_name: + answer = f"项目「{scoped_project_name}」当前任务总数为 {total} 条。" + else: + answer = f"当前可见任务总数为 {total} 条。" + return { + "answer": answer, + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.99, + "metadata": { + "table": "tasks", + "count": total, + "project_id": scoped_project_id, + "project_name": scoped_project_name, + }, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_COUNT, + "confidence": 0.99, + "missing_fields": [], + } + + if has_people: + scoped_project_id: Optional[int] = None + scoped_project_name: Optional[str] = None + scoped_keyword = "" + if has_project or project_id is not None: + scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question( + question=text, + project_id=project_id, + ) + if has_project and scoped_project_id is None and scoped_keyword and not self._is_people_query(scoped_keyword): + elapsed = int((time.perf_counter() - started) * 1000) + return { + "answer": f"未检索到名称或描述包含“{scoped_keyword}”的项目,无法统计成员人数。", + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.94, + "metadata": {"table": "projects", "query_keyword": scoped_keyword, "match_count": 0}, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_COUNT, + "confidence": 0.94, + "missing_fields": ["project_id"], + } + + user_stmt = select(func.count(User.id)).where( + and_( + User.tenant_id == self.tenant_id, + User.is_active.is_(True), + ) + ) + if scoped_project_id is not None: + member_subq = select(UserProject.user_id).where( + and_( + UserProject.tenant_id == self.tenant_id, + UserProject.project_id == int(scoped_project_id), + ) + ) + user_stmt = user_stmt.where(User.id.in_(member_subq)) + total = int((await self.db.execute(user_stmt)).scalar() or 0) + elapsed = int((time.perf_counter() - started) * 1000) + if scoped_project_id is not None and scoped_project_name: + answer = f"项目「{scoped_project_name}」当前活跃成员总数为 {total} 人。" + else: + answer = f"当前系统里活跃成员总数为 {total} 人。" + return { + "answer": answer, + "sources": [ + { + "source": "db", + "label": "User database", + "score": 0.99, + "metadata": { + "table": "users", + "count": total, + "project_id": scoped_project_id, + "project_name": scoped_project_name, + }, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_COUNT, + "confidence": 0.99, + "missing_fields": [], + } + + if has_project: + stmt = select(func.count(Project.id)).where(Project.tenant_id == self.tenant_id) + if project_id is not None: + stmt = stmt.where(Project.id == int(project_id)) + total = int((await self.db.execute(stmt)).scalar() or 0) + elapsed = int((time.perf_counter() - started) * 1000) + return { + "answer": f"当前系统里项目总数为 {total} 个。", + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.99, + "metadata": {"table": "projects", "count": total}, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_COUNT, + "confidence": 0.99, + "missing_fields": [], + } + return None + diff --git a/backend/app/services/adaptive_rag_service/mixin_deterministic_query_list.py b/backend/app/services/adaptive_rag_service/mixin_deterministic_query_list.py new file mode 100644 index 00000000..dd4c261b --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_deterministic_query_list.py @@ -0,0 +1,281 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGDeterministicQueryListMixin: + async def _run_list_query(self, *, question: str, project_id: Optional[int]) -> Optional[dict[str, Any]]: + text = str(question or "").lower() + started = time.perf_counter() + if self._is_people_query(text): + scoped_project_id: Optional[int] = None + scoped_project_name: Optional[str] = None + scoped_keyword = "" + if "项目" in text or project_id is not None: + scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question( + question=text, + project_id=project_id, + ) + if "项目" in text and scoped_project_id is None and scoped_keyword and not self._is_people_query(scoped_keyword): + elapsed = int((time.perf_counter() - started) * 1000) + return { + "answer": f"未检索到名称或描述包含“{scoped_keyword}”的项目,无法列出成员清单。", + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.94, + "metadata": {"table": "projects", "query_keyword": scoped_keyword, "match_count": 0}, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_LIST, + "confidence": 0.94, + "missing_fields": ["project_id"], + } + + user_stmt = select(User).where( + and_( + User.tenant_id == self.tenant_id, + User.is_active.is_(True), + ) + ) + if scoped_project_id is not None: + member_subq = select(UserProject.user_id).where( + and_( + UserProject.tenant_id == self.tenant_id, + UserProject.project_id == int(scoped_project_id), + ) + ) + user_stmt = user_stmt.where(User.id.in_(member_subq)) + members = ( + await self.db.execute( + user_stmt.order_by(User.updated_at.desc(), User.id.desc()).limit(30) + ) + ).scalars().all() + elapsed = int((time.perf_counter() - started) * 1000) + if not members: + answer = ( + f"项目「{scoped_project_name}」当前没有可列举的成员。" + if scoped_project_name + else "当前没有可列举的成员。" + ) + return { + "answer": answer, + "sources": [ + { + "source": "db", + "label": "User database", + "score": 0.95, + "metadata": { + "table": "users", + "count": 0, + "project_id": scoped_project_id, + "project_name": scoped_project_name, + }, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_LIST, + "confidence": 0.95, + "missing_fields": [], + } + + member_ids = [int(row.id) for row in members] + workload_rows = ( + await self.db.execute( + select(UserWorkload.user_id, UserWorkload.workload_ratio).where( + and_( + UserWorkload.tenant_id == self.tenant_id, + UserWorkload.user_id.in_(member_ids), + ) + ) + ) + ).all() + workload_map = {int(uid): float(ratio or 0.0) for uid, ratio in workload_rows} + + project_count_rows = ( + await self.db.execute( + select(UserProject.user_id, func.count(UserProject.project_id)) + .where( + and_( + UserProject.tenant_id == self.tenant_id, + UserProject.user_id.in_(member_ids), + ) + ) + .group_by(UserProject.user_id) + ) + ).all() + project_count_map = {int(uid): int(cnt or 0) for uid, cnt in project_count_rows} + + lines = [] + for row in members: + uid = int(row.id) + ratio = workload_map.get(uid) + if ratio is None: + ratio = round(float(row.workload or 0) / 100.0, 2) + role = str(row.role or "unknown") + project_count = project_count_map.get(uid, 0) + lines.append( + f"#{uid} {str(row.name or f'User#{uid}')}(角色:{role},负载:{float(ratio):.2f},关联项目:{project_count})" + ) + elapsed = int((time.perf_counter() - started) * 1000) + header = f"项目「{scoped_project_name}」成员清单如下:" if scoped_project_name else "当前团队成员清单如下:" + return { + "answer": header + "\n" + "\n".join(lines), + "sources": [ + { + "source": "db", + "label": "User database", + "score": 0.99, + "metadata": { + "table": "users", + "count": len(members), + "project_id": scoped_project_id, + "project_name": scoped_project_name, + }, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_LIST, + "confidence": 0.99, + "missing_fields": [], + } + + if "信号" in text or "信号源" in text: + scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question( + question=text, + project_id=project_id, + ) + if "项目" in text and scoped_project_id is None and scoped_keyword: + elapsed = int((time.perf_counter() - started) * 1000) + return { + "answer": f"未检索到名称或描述包含“{scoped_keyword}”的项目,无法列出信号清单。", + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.94, + "metadata": {"table": "projects", "query_keyword": scoped_keyword, "match_count": 0}, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_LIST, + "confidence": 0.94, + "missing_fields": ["project_id"], + } + + stmt = select(Signal).where(Signal.tenant_id == self.tenant_id) + if scoped_project_id is not None: + stmt = stmt.where(Signal.matched_project_id == int(scoped_project_id)) + signals = ( + await self.db.execute(stmt.order_by(Signal.updated_at.desc(), Signal.id.desc()).limit(30)) + ).scalars().all() + elapsed = int((time.perf_counter() - started) * 1000) + if not signals: + answer = ( + f"项目「{scoped_project_name}」当前没有可列举的信号。" + if scoped_project_name + else "当前没有可列举的信号。" + ) + return { + "answer": answer, + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.95, + "metadata": { + "table": "signals", + "count": 0, + "project_id": scoped_project_id, + "project_name": scoped_project_name, + }, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_LIST, + "confidence": 0.95, + "missing_fields": [], + } + + lines = [] + for row in signals: + title = str(row.title or row.summary or row.content or f"Signal#{int(row.id)}").replace("\n", " ").strip() + status = str(row.status or "unknown") + lines.append(f"#{int(row.id)} {title[:90]}({status})") + header = f"项目「{scoped_project_name}」信号清单如下:" if scoped_project_name else "当前信号清单如下:" + return { + "answer": header + "\n" + "\n".join(lines), + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.99, + "metadata": { + "table": "signals", + "count": len(signals), + "project_id": scoped_project_id, + "project_name": scoped_project_name, + }, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_LIST, + "confidence": 0.99, + "missing_fields": [], + } + + if "项目" in text: + stmt = select(Project).where(Project.tenant_id == self.tenant_id) + if project_id is not None: + stmt = stmt.where(Project.id == int(project_id)) + projects = ( + await self.db.execute(stmt.order_by(Project.updated_at.desc(), Project.id.desc()).limit(20)) + ).scalars().all() + if not projects: + elapsed = int((time.perf_counter() - started) * 1000) + return { + "answer": "当前没有可列举的项目。", + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.95, + "metadata": {"table": "projects", "count": 0}, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_LIST, + "confidence": 0.95, + "missing_fields": [], + } + + lines = [f"#{int(p.id)} {str(p.title or p.name or '未命名项目')}({str(p.status or 'unknown')})" for p in projects] + elapsed = int((time.perf_counter() - started) * 1000) + return { + "answer": "当前项目列表如下:\n" + "\n".join(lines), + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.99, + "metadata": {"table": "projects", "count": len(projects)}, + } + ], + "db_query_ms": elapsed, + "response_time_ms": elapsed, + "query_type": QUERY_TYPE_LIST, + "confidence": 0.99, + "missing_fields": [], + } + return None + diff --git a/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_main.py b/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_main.py new file mode 100644 index 00000000..673df46c --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_main.py @@ -0,0 +1,227 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGFastAnswerProjectMainMixin: + async def _try_fast_project_progress_answer( + self, + *, + clean_q: str, + project_id: Optional[int], + ) -> Optional[dict[str, Any]]: + question = str(clean_q or "").strip() + if not question: + return None + if not PROJECT_PROGRESS_RE.search(question): + return None + if "项目" not in question and "系统" not in question: + return None + + primary_keyword = self._extract_project_lookup_keyword(question) + query_terms = self._extract_query_terms(question, preferred=primary_keyword) + if not query_terms: + return None + + started = time.perf_counter() + matched: list[Project] = [] + used_keyword = query_terms[0] + for term in query_terms: + rows = await self._search_projects_by_keyword(keyword=term, project_id=project_id, limit=3) + if rows: + matched = rows + used_keyword = term + break + if not matched: + db_query_ms = int((time.perf_counter() - started) * 1000) + return { + "answer": f"未检索到名称或描述包含“{used_keyword}”的项目,无法判断进度。", + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.99, + "metadata": {"table": "projects", "query_keyword": used_keyword, "match_count": 0}, + } + ], + "db_query_ms": db_query_ms, + "response_time_ms": db_query_ms, + } + + target = matched[0] + task_rows = ( + await self.db.execute( + select(Task).where( + and_( + Task.tenant_id == self.tenant_id, + Task.project_id == int(target.id), + ) + ) + ) + ).scalars().all() + db_query_ms = int((time.perf_counter() - started) * 1000) + + total = len(task_rows) + done_statuses = {"done", "completed", "已完成", "完成", "closed", "关闭"} + doing_statuses = {"in_progress", "doing", "进行中", "开发中", "处理中", "processing"} + pending_statuses = {"todo", "pending", "待处理", "未开始", "open"} + + done = 0 + doing = 0 + pending = 0 + latest_update: Optional[datetime] = None + for row in task_rows: + status = str(row.status or "").strip().lower() + if status in done_statuses: + done += 1 + elif status in doing_statuses: + doing += 1 + elif status in pending_statuses: + pending += 1 + else: + pending += 1 + + row_time = row.updated_at or row.created_at + if row_time and (latest_update is None or row_time > latest_update): + latest_update = row_time + + project_name = str(target.title or target.name or f"项目#{int(target.id)}") + project_status = str(target.status or "unknown") + if total == 0: + answer = ( + f"项目「{project_name}」当前状态为 {project_status}。" + "目前未检索到该项目的任务拆解记录,因此只能确认项目状态,无法量化阶段进度。" + ) + else: + completion = round((done / total) * 100, 1) + latest_text = latest_update.strftime("%Y-%m-%d %H:%M") if latest_update else "未知" + answer = ( + f"项目「{project_name}」当前状态为 {project_status}。" + f"任务总数 {total},已完成 {done},进行中 {doing},待处理 {pending},完成率 {completion}%。" + f"最近任务更新时间:{latest_text}。" + ) + + if len(matched) > 1: + others = "、".join([str(x.title or x.name or f'#{x.id}') for x in matched[1:]]) + answer += f" 另有 {len(matched) - 1} 个同名候选项目:{others}。" + + return { + "answer": answer, + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.99, + "metadata": { + "table": "projects", + "project_id": int(target.id), + "query_keyword": used_keyword, + "match_count": len(matched), + }, + }, + { + "source": "db", + "label": "Project database", + "score": 0.98, + "metadata": { + "table": "tasks", + "project_id": int(target.id), + "task_total": total, + "done_count": done, + "doing_count": doing, + "pending_count": pending, + }, + }, + ], + "db_query_ms": db_query_ms, + "response_time_ms": db_query_ms, + } + + async def _try_fast_project_existence_answer( + self, + *, + clean_q: str, + project_id: Optional[int], + ) -> Optional[dict[str, Any]]: + question = str(clean_q or "").strip() + if not question: + return None + if "项目" not in question and "系统" not in question: + return None + if ( + "吗" not in question + and "?" not in question + and "?" not in question + and "是否有" not in question + and "有没有" not in question + and "有无" not in question + and not PROJECT_EXISTENCE_RE.search(question) + ): + return None + + primary_keyword = self._extract_project_lookup_keyword(question) + query_terms = self._extract_query_terms(question, preferred=primary_keyword) + if not query_terms: + return None + + started = time.perf_counter() + matched: list[Project] = [] + used_keyword = query_terms[0] + for term in query_terms: + rows = await self._search_projects_by_keyword(keyword=term, project_id=project_id, limit=5) + if rows: + matched = rows + used_keyword = term + break + db_query_ms = int((time.perf_counter() - started) * 1000) + + if matched: + lines = [ + f"#{int(p.id)} {str(p.title or p.name or '未命名项目')}(状态:{str(p.status or 'unknown')})" + for p in matched + ] + answer = f"有,当前匹配到 {len(matched)} 个相关项目:\n" + "\n".join(lines) + else: + answer = f"暂未检索到名称或描述包含“{used_keyword}”的项目。" + + return { + "answer": answer, + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.99, + "metadata": { + "table": "projects", + "query_keyword": used_keyword, + "match_count": len(matched), + }, + } + ], + "db_query_ms": db_query_ms, + } + + async def _search_projects_by_keyword( + self, + *, + keyword: str, + project_id: Optional[int], + limit: int, + ) -> list[Project]: + term = str(keyword or "").strip() + if not term: + return [] + stmt = select(Project).where(Project.tenant_id == self.tenant_id) + if project_id is not None: + stmt = stmt.where(Project.id == int(project_id)) + like = f"%{term}%" + stmt = stmt.where( + or_( + Project.name.ilike(like), + Project.title.ilike(like), + Project.description.ilike(like), + ) + ) + return (await self.db.execute(stmt.limit(max(1, int(limit))))).scalars().all() + diff --git a/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_utils.py b/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_utils.py new file mode 100644 index 00000000..72efa445 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_utils.py @@ -0,0 +1,91 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGFastAnswerProjectUtilsMixin: + @staticmethod + def _extract_project_lookup_keyword(question: str) -> str: + text = str(question or "").strip() + if not text: + return "" + text = text.replace("?", "?") + + direct_patterns = [ + r"(?:是否有|有没有|有无)([\u4e00-\u9fffA-Za-z0-9_-]{1,24})(?:项目|系统)?", + r"(?:是否存在|存在)([\u4e00-\u9fffA-Za-z0-9_-]{1,24})(?:项目|系统)?", + ] + for pattern in direct_patterns: + m = re.search(pattern, text) + if m: + value = str(m.group(1) or "").strip(" ,。,.!??::;;“”\"'()()") + value = QUESTION_NOISE_RE.sub("", value).strip() + if len(value) >= 2: + return value[:24] + + anchored_matches = list(re.finditer(r"([\u4e00-\u9fffA-Za-z0-9_-]{1,24})(?:项目|系统)", text)) + for m in reversed(anchored_matches): + value = str(m.group(1) or "").strip(" ,。,.!??::;;“”\"'()()") + value = QUESTION_NOISE_RE.sub("", value).strip() + if len(value) >= 2: + return value[:24] + + patterns = [ + r"有(?:没有)?(.{1,24}?)(?:项目|系统).*\?", + r"项目.*?有(?:没有)?(.{1,24}?)(?:吗|\?)", + r"是否有(.{1,24}?)(?:项目|系统)", + r"(.{1,24}?)(?:项目|系统)(?:有吗|有没有|是否有)", + ] + for pattern in patterns: + m = re.search(pattern, text) + if not m: + continue + kw = str(m.group(1) or "").strip(" ,。,.!??::;;“”\"'()()") + kw = QUESTION_NOISE_RE.sub("", kw).strip() + if kw: + return kw[:24] + + fallback = re.sub(r"[??]", " ", text) + fallback = re.sub( + r"(项目|系统|目前|当前|现在|我们|有没有|是否有|有吗|有|吗|什么|进度|层面|阶段|是在|在|是|请问|一下|多少)", + " ", + fallback, + ) + fallback = re.sub(r"\s+", " ", fallback).strip(" ,。,.!??::;;") + return fallback[:24] + + @staticmethod + def _extract_query_terms(question: str, *, preferred: str = "") -> list[str]: + base = str(question or "").strip() + terms: list[str] = [] + pref = str(preferred or "").strip() + if len(pref) >= 2: + terms.append(pref[:24]) + + clean = re.sub(r"[??,,。!!::;;()()\"“”']", " ", base) + clean = QUESTION_NOISE_RE.sub(" ", clean) + clean = re.sub(r"(项目|系统|进度|状态|阶段|完成率|层面)", " ", clean) + clean = re.sub(r"\s+", " ", clean).strip() + + token_candidates: list[str] = [] + for token in clean.split(" "): + t = token.strip() + if len(t) >= 2: + token_candidates.append(t[:24]) + + for m in re.finditer(r"[\u4e00-\u9fff]{2,10}", clean): + token_candidates.append(m.group(0)[:24]) + + for t in sorted(token_candidates, key=len, reverse=True): + if t and t not in terms: + terms.append(t) + return terms[:6] + + @staticmethod + def _is_people_query(question: str) -> bool: + text = str(question or "").strip().lower() + if not text: + return False + return any(k in text for k in PEOPLE_KEYWORDS) + diff --git a/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py b/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py new file mode 100644 index 00000000..693815bc --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py @@ -0,0 +1,168 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGFastAnswerSemanticMixinP1: + async def _try_glm_project_semantic_answer( + self, + *, + clean_q: str, + project_id: Optional[int], + ) -> Optional[dict[str, Any]]: + question = str(clean_q or "").strip() + if not question: + return None + if self._is_people_query(question): + return None + if not any(k in question for k in ("项目", "系统", "进度", "状态", "有没有", "是否", "存在", "立项", "任务")): + return None + + db_started = time.perf_counter() + stmt = select(Project).where(Project.tenant_id == self.tenant_id) + if project_id is not None: + stmt = stmt.where(Project.id == int(project_id)) + projects = (await self.db.execute(stmt.order_by(Project.updated_at.desc()).limit(120))).scalars().all() + if not projects: + db_query_ms = int((time.perf_counter() - db_started) * 1000) + return { + "answer": "当前项目库为空,无法检索到匹配项目。", + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.99, + "metadata": {"table": "projects", "match_count": 0, "retrieval_mode": "glm_semantic"}, + } + ], + "db_query_ms": db_query_ms, + "response_time_ms": db_query_ms, + } + + project_ids = [int(p.id) for p in projects] + task_rows = [] + if project_ids: + task_rows = ( + await self.db.execute( + select(Task).where( + and_( + Task.tenant_id == self.tenant_id, + Task.project_id.in_(project_ids), + ) + ) + ) + ).scalars().all() + db_query_ms = int((time.perf_counter() - db_started) * 1000) + + done_statuses = {"done", "completed", "已完成", "完成", "closed", "关闭"} + doing_statuses = {"in_progress", "doing", "进行中", "开发中", "处理中", "processing"} + pending_statuses = {"todo", "pending", "待处理", "未开始", "open"} + + task_stats: dict[int, dict[str, Any]] = {} + for pid in project_ids: + task_stats[pid] = { + "task_total": 0, + "done_count": 0, + "doing_count": 0, + "pending_count": 0, + "latest_task_updated_at": None, + } + + for row in task_rows: + pid = int(row.project_id) + if pid not in task_stats: + continue + stat = task_stats[pid] + stat["task_total"] += 1 + status = str(row.status or "").strip().lower() + if status in done_statuses: + stat["done_count"] += 1 + elif status in doing_statuses: + stat["doing_count"] += 1 + elif status in pending_statuses: + stat["pending_count"] += 1 + else: + stat["pending_count"] += 1 + row_time = row.updated_at or row.created_at + latest = stat.get("latest_task_updated_at") + if row_time and (latest is None or row_time > latest): + stat["latest_task_updated_at"] = row_time + + project_index: list[dict[str, Any]] = [] + for p in projects: + pid = int(p.id) + stats = task_stats.get(pid) or {} + project_index.append( + { + "id": pid, + "title": str(p.title or p.name or "")[:120], + "name": str(p.name or "")[:120], + "status": str(p.status or "unknown"), + "description": str(p.description or "")[:220], + "task_total": int(stats.get("task_total") or 0), + "done_count": int(stats.get("done_count") or 0), + "doing_count": int(stats.get("doing_count") or 0), + "pending_count": int(stats.get("pending_count") or 0), + "latest_task_updated_at": ( + stats.get("latest_task_updated_at").isoformat() + if stats.get("latest_task_updated_at") is not None + else None + ), + } + ) + + prompt = PROMPT_RAG_DB_PROJECT_QA.format( + question=question[:600], + project_index=json.dumps(project_index, ensure_ascii=False), + ).strip() + llm_started = time.perf_counter() + try: + raw = await self.llm.generate( + prompt, + db=self.db, + tenant_id=self.tenant_id, + function_name="rag_db_project_semantic_retrieval", + temperature=0.0, + max_tokens=520, + use_prompt_profile=False, + ) + parsed = self._parse_json_object(raw) + if not parsed: + return None + handled = bool(parsed.get("handled")) + answer = str(parsed.get("answer") or "").strip() + confidence = self._to_float(parsed.get("confidence"), 0.0) + matched_ids_raw = parsed.get("matched_project_ids") + matched_ids: list[int] = [] + if isinstance(matched_ids_raw, list): + for x in matched_ids_raw: + try: + matched_ids.append(int(x)) + except Exception: + continue + matched_ids = matched_ids[:8] + if not handled or not answer: + return None + response_ms = int((time.perf_counter() - llm_started) * 1000) + db_query_ms + return { + "answer": answer, + "sources": [ + { + "source": "db", + "label": "Project database", + "score": round(max(0.0, min(1.0, confidence or 0.9)), 4), + "metadata": { + "table": "projects", + "matched_project_ids": matched_ids, + "project_index_count": len(project_index), + "retrieval_mode": "glm_semantic", + }, + } + ], + "db_query_ms": db_query_ms, + "response_time_ms": response_ms, + } + except Exception: + return None + diff --git a/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p2.py b/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p2.py new file mode 100644 index 00000000..9a5e298c --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p2.py @@ -0,0 +1,103 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGFastAnswerSemanticMixinP2: + async def _persist_fast_answer( + self, + *, + question: str, + asked_by: Optional[int], + asked_via: str, + project_id: Optional[int], + fast_result: dict[str, Any], + session_id: Optional[int] = None, + external_session_id: Optional[int] = None, + turn_index: Optional[int] = None, + query_type: str = QUERY_TYPE_EXPLAIN, + missing_fields: Optional[list[str]] = None, + state_row: Optional[QASessionState] = None, + effective_question: Optional[str] = None, + rewrite_meta: Optional[dict[str, Any]] = None, + thinking_mode: str = THINKING_MODE_NORMAL, + ) -> dict[str, Any]: + started = time.perf_counter() + answer_text = str(fast_result.get("answer") or "未命中结果") + sources = list(fast_result.get("sources") or []) + active_session_id = session_id + active_turn_index = turn_index + if active_session_id is None or active_turn_index is None: + active_session_id, active_turn_index = await self._touch_session_for_new_record( + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + question=question, + ) + active_turn_index = int(active_turn_index or 1) + active_session_id_int = int(active_session_id) if active_session_id is not None else None + + confidence = self._to_float(fast_result.get("confidence"), 0.96) + if missing_fields is None: + missing_fields = list(fast_result.get("missing_fields") or []) + final_query_type = str(fast_result.get("query_type") or query_type or QUERY_TYPE_EXPLAIN) + + qa = QARecord( + tenant_id=self.tenant_id, + question=question, + answer=answer_text, + sources=sources, + intent="question", + domains_used=["db"], + response_time_ms=int(fast_result.get("response_time_ms") or 0), + asked_by=asked_by, + asked_via=(asked_via or "pmflow")[:20], + project_id=project_id, + session_id=active_session_id_int, + turn_index=active_turn_index, + ) + self.db.add(qa) + if state_row is None and active_session_id_int is not None: + state_row = await self._ensure_session_state(session_id=active_session_id_int) + await self._update_session_state_after_turn( + session_state=state_row, + question=question, + effective_question=str(effective_question or question), + answer=answer_text, + query_type=final_query_type, + ambiguity="clear", + rewrite_meta=rewrite_meta or {}, + sources=sources, + ) + await self.db.commit() + await self.db.refresh(qa) + cache_session_id = active_session_id_int if active_session_id_int is not None else ( + int(external_session_id) if external_session_id is not None else None + ) + await self.context_cache.append_session_messages( + tenant_id=self.tenant_id, + session_id=cache_session_id, + question=question, + answer=answer_text, + ) + elapsed = int((time.perf_counter() - started) * 1000) + return self._build_response_payload( + answer=answer_text, + sources=sources, + domains_used=["db"], + response_time_ms=int(fast_result.get("response_time_ms") or elapsed), + db_query_ms=int(fast_result.get("db_query_ms") or 0), + vector_search_ms=0, + db_timeout=False, + vector_timeout=False, + qa_id=int(qa.id), + session_id=active_session_id_int, + turn_index=active_turn_index, + relevance_max=0.99, + query_type=final_query_type, + confidence=confidence, + missing_fields=list(missing_fields or []), + thinking_mode=thinking_mode, + ) + diff --git a/backend/app/services/adaptive_rag_service/mixin_ingest.py b/backend/app/services/adaptive_rag_service/mixin_ingest.py new file mode 100644 index 00000000..4b948792 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_ingest.py @@ -0,0 +1,230 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGIngestMixin: + async def ingest_feishu_message( + self, + *, + content: str, + sender: str, + chat_id: str, + message_id: Optional[str], + captured_at: Optional[datetime] = None, + project_id: Optional[int] = None, + ) -> None: + text = str(content or "").strip() + if not text: + return + chunks = self._chunk_text(text, chunk_size=320, overlap=40) + for idx, part in enumerate(chunks): + emb = await self.llm.get_embedding_async(part, db=self.db, tenant_id=self.tenant_id) + self.db.add( + KnowledgeBaseChunk( + tenant_id=self.tenant_id, + doc_id=None, + source_type="feishu", + project_id=project_id, + content=part, + embedding=emb if emb else None, + chunk_index=idx, + chunk_metadata={ + "sender": sender, + "chat_id": chat_id, + "message_id": message_id, + "captured_at": (captured_at or now_cn_aware()).isoformat(), + }, + ) + ) + await self.db.commit() + + async def mark_doc_processing(self, *, doc_id: int) -> dict[str, Any]: + doc = ( + await self.db.execute( + select(KnowledgeBaseDoc).where( + and_( + KnowledgeBaseDoc.id == int(doc_id), + KnowledgeBaseDoc.tenant_id == self.tenant_id, + ) + ) + ) + ).scalar_one_or_none() + if not doc: + raise ValueError("doc_not_found") + doc.status = "processing" + doc.error_message = None + await self.db.commit() + await self.db.refresh(doc) + return self._serialize_doc(doc) + + @staticmethod + async def ingest_feishu_message_async( + *, + tenant_id: int, + content: str, + sender: str, + chat_id: str, + message_id: Optional[str], + captured_at: Optional[datetime] = None, + project_id: Optional[int] = None, + ) -> None: + async with AsyncSessionLocal() as session: + service = AdaptiveRAGService(session, tenant_id=tenant_id) + try: + await service.ingest_feishu_message( + content=content, + sender=sender, + chat_id=chat_id, + message_id=message_id, + captured_at=captured_at, + project_id=project_id, + ) + except Exception: + await session.rollback() + + @staticmethod + async def process_knowledge_doc_content_async( + *, + tenant_id: int, + doc_id: int, + raw_content: str, + project_id: Optional[int] = None, + ) -> None: + async with AsyncSessionLocal() as session: + service = AdaptiveRAGService(session, tenant_id=tenant_id) + try: + await service.process_knowledge_doc_content( + doc_id=doc_id, + raw_content=raw_content, + project_id=project_id, + ) + except Exception: + await session.rollback() + + async def refresh_url_doc(self, *, doc_id: int, project_id: Optional[int] = None) -> dict[str, Any]: + doc = ( + await self.db.execute( + select(KnowledgeBaseDoc).where( + and_( + KnowledgeBaseDoc.id == int(doc_id), + KnowledgeBaseDoc.tenant_id == self.tenant_id, + ) + ) + ) + ).scalar_one_or_none() + if not doc: + raise ValueError("doc_not_found") + if str(doc.source_type or "").lower() != "url": + raise ValueError("doc_is_not_url_source") + if not str(doc.file_url or "").strip(): + raise ValueError("doc_url_missing") + text = await self._fetch_url_text(str(doc.file_url)) + await self._index_doc_content( + doc=doc, + raw_content=text, + project_id=project_id, + source_type="url", + ) + await self.db.refresh(doc) + return self._serialize_doc(doc) + + @staticmethod + async def refresh_url_doc_async( + *, + tenant_id: int, + doc_id: int, + project_id: Optional[int] = None, + ) -> None: + async with AsyncSessionLocal() as session: + service = AdaptiveRAGService(session, tenant_id=tenant_id) + try: + await service.refresh_url_doc(doc_id=doc_id, project_id=project_id) + except Exception: + await session.rollback() + + async def upsert_pmflow_record( + self, + *, + record_type: str, + record_id: int, + project_id: Optional[int], + title: str, + content: str, + ) -> None: + safe_type = str(record_type or "").strip().lower()[:32] + safe_id = int(record_id) + if not safe_type or safe_id <= 0: + return + + stmt = select(KnowledgeBaseChunk).where( + and_( + KnowledgeBaseChunk.tenant_id == self.tenant_id, + KnowledgeBaseChunk.source_type == "pmflow", + ) + ) + if project_id is not None: + stmt = stmt.where(KnowledgeBaseChunk.project_id == int(project_id)) + existing = (await self.db.execute(stmt.limit(2000))).scalars().all() + for row in existing: + meta = self._safe_dict(row.chunk_metadata) + if str(meta.get("record_type") or "").strip().lower() != safe_type: + continue + if str(meta.get("record_id") or "").strip() != str(safe_id): + continue + await self.db.delete(row) + + text = f"{title}\n\n{content}".strip() + chunks = self._chunk_text(text, chunk_size=500, overlap=50) + for idx, part in enumerate(chunks): + emb = await self.llm.get_embedding_async(part, db=self.db, tenant_id=self.tenant_id) + target_url = self._build_pmflow_target_url( + record_type=safe_type, + record_id=safe_id, + project_id=project_id, + ) + self.db.add( + KnowledgeBaseChunk( + tenant_id=self.tenant_id, + doc_id=None, + source_type="pmflow", + project_id=project_id, + content=part, + embedding=emb if emb else None, + chunk_index=idx, + chunk_metadata={ + "record_type": safe_type, + "record_id": safe_id, + "project_id": int(project_id) if project_id is not None else None, + "target_url": target_url, + "title": title[:255], + "updated_at": now_cn_aware().isoformat(), + }, + ) + ) + await self.db.commit() + + @staticmethod + async def upsert_pmflow_record_async( + *, + tenant_id: int, + record_type: str, + record_id: int, + project_id: Optional[int], + title: str, + content: str, + ) -> None: + async with AsyncSessionLocal() as session: + service = AdaptiveRAGService(session, tenant_id=tenant_id) + try: + await service.upsert_pmflow_record( + record_type=record_type, + record_id=record_id, + project_id=project_id, + title=title, + content=content, + ) + except Exception: + await session.rollback() + diff --git a/backend/app/services/adaptive_rag_service/mixin_intent_correction.py b/backend/app/services/adaptive_rag_service/mixin_intent_correction.py new file mode 100644 index 00000000..ea817343 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_intent_correction.py @@ -0,0 +1,166 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGIntentCorrectionMixin: + @staticmethod + def _build_intent_correction_record( + *, + user_utterance: str, + wrong_interpretation: str, + correct_intent: str, + canonical_action: str, + clarification_template: str, + risk_level: str, + project_id: Optional[int], + ) -> tuple[int, str, str, dict[str, Any]]: + utterance = " ".join(str(user_utterance or "").split()).strip()[:500] + wrong = " ".join(str(wrong_interpretation or "").split()).strip()[:300] + correct = " ".join(str(correct_intent or "").split()).strip().lower()[:60] + action = " ".join(str(canonical_action or "").split()).strip().lower()[:80] + clarification = " ".join(str(clarification_template or "").split()).strip()[:500] + risk = " ".join(str(risk_level or "medium").split()).strip().lower()[:20] or "medium" + + fingerprint = hashlib.sha1( + "||".join([utterance, wrong, correct, action, clarification, risk]).encode("utf-8") + ).hexdigest() + record_id = int(fingerprint[:12], 16) + title = f"意图纠错/{correct or 'unknown'}" + content = ( + f"用户原话: {utterance}\n" + f"错误理解: {wrong}\n" + f"正确意图: {correct}\n" + f"标准动作: {action}\n" + f"澄清模板: {clarification}\n" + f"风险等级: {risk}" + )[:4000] + metadata = { + "record_type": "intent_correction", + "record_id": str(record_id), + "project_id": int(project_id) if project_id is not None else None, + "user_utterance": utterance, + "wrong_interpretation": wrong, + "correct_intent": correct, + "canonical_action": action, + "clarification_template": clarification, + "risk_level": risk, + "target_url": None, + "title": title, + "updated_at": now_cn_aware().isoformat(), + } + return record_id, title, content, metadata + + async def upsert_intent_correction_sample( + self, + *, + user_utterance: str, + wrong_interpretation: str, + correct_intent: str, + canonical_action: str, + clarification_template: str, + risk_level: str, + project_id: Optional[int] = None, + ) -> dict[str, Any]: + record_id, title, content, metadata = self._build_intent_correction_record( + user_utterance=user_utterance, + wrong_interpretation=wrong_interpretation, + correct_intent=correct_intent, + canonical_action=canonical_action, + clarification_template=clarification_template, + risk_level=risk_level, + project_id=project_id, + ) + if not metadata["user_utterance"] or not metadata["correct_intent"]: + raise ValueError("intent correction sample missing required fields") + + stmt = select(KnowledgeBaseChunk).where( + and_( + KnowledgeBaseChunk.tenant_id == self.tenant_id, + KnowledgeBaseChunk.source_type == "pmflow", + ) + ) + existing = (await self.db.execute(stmt.limit(2000))).scalars().all() + for row in existing: + meta = self._safe_dict(row.chunk_metadata) + if str(meta.get("record_type") or "").strip().lower() != "intent_correction": + continue + if str(meta.get("record_id") or "").strip() != str(record_id): + continue + await self.db.delete(row) + + embedding = await self.llm.get_embedding_async(content, db=self.db, tenant_id=self.tenant_id) + self.db.add( + KnowledgeBaseChunk( + tenant_id=self.tenant_id, + doc_id=None, + source_type="pmflow", + project_id=int(project_id) if project_id is not None else None, + content=content, + embedding=embedding if embedding else None, + chunk_index=0, + chunk_metadata=metadata, + ) + ) + await self.db.commit() + return metadata + + async def retrieve_intent_correction_samples( + self, + *, + utterance: str, + top_k: int = 3, + project_id: Optional[int] = None, + ) -> list[dict[str, Any]]: + text = " ".join(str(utterance or "").split()).strip() + if len(text) < 2: + return [] + question_embedding = await self.llm.get_embedding_async( + text, + db=self.db, + tenant_id=self.tenant_id, + ) + if not question_embedding: + return [] + + rows = ( + await self.db.execute( + select(KnowledgeBaseChunk).where( + and_( + KnowledgeBaseChunk.tenant_id == self.tenant_id, + KnowledgeBaseChunk.source_type == "pmflow", + ) + ).limit(500) + ) + ).scalars().all() + scored: list[dict[str, Any]] = [] + for row in rows: + meta = self._safe_dict(row.chunk_metadata) + if str(meta.get("record_type") or "").strip().lower() != "intent_correction": + continue + try: + chunk_project_id = int(meta.get("project_id")) if meta.get("project_id") is not None else None + except Exception: + chunk_project_id = None + if project_id is not None and chunk_project_id is not None and chunk_project_id != int(project_id): + continue + score = self._cosine_similarity(question_embedding, self._embedding_to_list(row.embedding)) + if score <= 0: + continue + scored.append( + { + "score": round(float(score), 4), + "record_id": str(meta.get("record_id") or ""), + "user_utterance": str(meta.get("user_utterance") or ""), + "wrong_interpretation": str(meta.get("wrong_interpretation") or ""), + "correct_intent": str(meta.get("correct_intent") or ""), + "canonical_action": str(meta.get("canonical_action") or ""), + "clarification_template": str(meta.get("clarification_template") or ""), + "risk_level": str(meta.get("risk_level") or "medium"), + "project_id": chunk_project_id, + } + ) + scored.sort(key=lambda x: float(x.get("score") or 0.0), reverse=True) + return scored[: max(1, min(int(top_k or 3), 10))] + diff --git a/backend/app/services/adaptive_rag_service/mixin_knowledge_docs.py b/backend/app/services/adaptive_rag_service/mixin_knowledge_docs.py new file mode 100644 index 00000000..fe82ecc9 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_knowledge_docs.py @@ -0,0 +1,197 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGKnowledgeDocsMixin: + async def create_knowledge_doc( + self, + *, + title: str, + file_type: str, + raw_content: str, + uploaded_by: Optional[int], + source_type: str = "file", + file_url: Optional[str] = None, + project_id: Optional[int] = None, + ) -> dict[str, Any]: + doc = await self.create_knowledge_doc_placeholder( + title=title, + file_type=file_type, + uploaded_by=uploaded_by, + source_type=source_type, + file_url=file_url, + ) + await self._index_doc_content( + doc=doc, + raw_content=raw_content, + project_id=project_id, + source_type=source_type, + ) + return self._serialize_doc(doc) + + async def create_knowledge_doc_placeholder( + self, + *, + title: str, + file_type: str, + uploaded_by: Optional[int], + source_type: str = "file", + file_url: Optional[str] = None, + ) -> KnowledgeBaseDoc: + doc = KnowledgeBaseDoc( + tenant_id=self.tenant_id, + title=(title or "未命名文档")[:255], + file_type=(file_type or "txt")[:32], + source_type=(source_type or "file")[:32], + file_url=(file_url or "")[:1024] or None, + uploaded_by=uploaded_by, + status="processing", + last_fetched_at=now_cn_naive() if source_type == "url" else None, + ) + self.db.add(doc) + await self.db.commit() + await self.db.refresh(doc) + return doc + + async def process_knowledge_doc_content( + self, + *, + doc_id: int, + raw_content: str, + project_id: Optional[int] = None, + ) -> dict[str, Any]: + doc = ( + await self.db.execute( + select(KnowledgeBaseDoc).where( + and_( + KnowledgeBaseDoc.id == int(doc_id), + KnowledgeBaseDoc.tenant_id == self.tenant_id, + ) + ) + ) + ).scalar_one_or_none() + if not doc: + raise ValueError("doc_not_found") + await self._index_doc_content( + doc=doc, + raw_content=raw_content, + project_id=project_id, + source_type=str(doc.source_type or "file"), + ) + return self._serialize_doc(doc) + + async def _index_doc_content( + self, + *, + doc: KnowledgeBaseDoc, + raw_content: str, + project_id: Optional[int], + source_type: str, + ) -> None: + clean_content = str(raw_content or "").strip() + if not clean_content: + doc.status = "failed" + doc.error_message = "文档内容为空,无法向量化" + await self.db.commit() + return + + try: + await self.db.execute( + delete(KnowledgeBaseChunk).where( + and_( + KnowledgeBaseChunk.tenant_id == self.tenant_id, + KnowledgeBaseChunk.doc_id == int(doc.id), + ) + ) + ) + + chunk_items = self._chunk_text_with_metadata(clean_content, chunk_size=500, overlap=50) + for idx, (part, extra_meta) in enumerate(chunk_items): + emb = await self.llm.get_embedding_async(part, db=self.db, tenant_id=self.tenant_id) + chunk_metadata = { + "title": doc.title, + "file_type": doc.file_type, + "source_type": source_type, + "file_url": doc.file_url, + } + chunk_metadata.update(extra_meta) + self.db.add( + KnowledgeBaseChunk( + tenant_id=self.tenant_id, + doc_id=int(doc.id), + source_type="url" if source_type == "url" else "doc", + project_id=project_id, + content=part, + embedding=emb if emb else None, + chunk_index=idx, + chunk_metadata=chunk_metadata, + ) + ) + doc.status = "complete" + doc.error_message = None + if source_type == "url": + doc.last_fetched_at = now_cn_naive() + await self.db.commit() + except Exception as exc: + doc.status = "failed" + doc.error_message = str(exc)[:500] + await self.db.commit() + raise + + async def list_knowledge_docs(self) -> list[dict[str, Any]]: + docs = ( + await self.db.execute( + select(KnowledgeBaseDoc) + .where(KnowledgeBaseDoc.tenant_id == self.tenant_id) + .order_by(KnowledgeBaseDoc.created_at.desc()) + .limit(200) + ) + ).scalars().all() + return [ + self._serialize_doc(d) + for d in docs + ] + + async def get_knowledge_doc(self, *, doc_id: int) -> dict[str, Any]: + doc = ( + await self.db.execute( + select(KnowledgeBaseDoc).where( + and_( + KnowledgeBaseDoc.id == int(doc_id), + KnowledgeBaseDoc.tenant_id == self.tenant_id, + ) + ) + ) + ).scalar_one_or_none() + if not doc: + raise ValueError("doc_not_found") + return self._serialize_doc(doc) + + async def delete_knowledge_doc(self, *, doc_id: int) -> bool: + doc = ( + await self.db.execute( + select(KnowledgeBaseDoc).where( + and_( + KnowledgeBaseDoc.id == int(doc_id), + KnowledgeBaseDoc.tenant_id == self.tenant_id, + ) + ) + ) + ).scalar_one_or_none() + if not doc: + return False + + await self.db.execute( + delete(KnowledgeBaseChunk).where( + and_( + KnowledgeBaseChunk.doc_id == int(doc_id), + KnowledgeBaseChunk.tenant_id == self.tenant_id, + ) + ) + ) + await self.db.delete(doc) + await self.db.commit() + return True + diff --git a/backend/app/services/adaptive_rag_service/mixin_manifest_core.py b/backend/app/services/adaptive_rag_service/mixin_manifest_core.py new file mode 100644 index 00000000..0c64b185 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_manifest_core.py @@ -0,0 +1,195 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGManifestCoreMixin: + async def get_project_manifest(self, *, project_id: Optional[int] = None) -> dict[str, Any]: + system_snapshot = await self.context_cache.get_system_snapshot( + db=self.db, + tenant_id=self.tenant_id, + ) + symbol_snapshot = await self._get_symbol_snapshot_cached() + + project_info: dict[str, Any] = {} + if project_id is not None: + row = ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == int(project_id), + ) + ) + ) + ).scalar_one_or_none() + if row is not None: + project_info = { + "id": int(row.id), + "title": str(row.title or row.name or f"项目#{int(row.id)}"), + "status": str(row.status or "unknown"), + } + + kg_overview = {"entity_total": 0, "relation_total": 0, "evidence_total": 0} + try: + kg_overview = { + "entity_total": int( + (await self.db.execute(select(func.count(KGEntity.id)).where(KGEntity.tenant_id == self.tenant_id))) + .scalar_one() + or 0 + ), + "relation_total": int( + ( + await self.db.execute(select(func.count(KGRelation.id)).where(KGRelation.tenant_id == self.tenant_id)) + ).scalar_one() + or 0 + ), + "evidence_total": int( + ( + await self.db.execute(select(func.count(KGEvidence.id)).where(KGEvidence.tenant_id == self.tenant_id)) + ).scalar_one() + or 0 + ), + } + except Exception: + kg_overview = {"entity_total": 0, "relation_total": 0, "evidence_total": 0} + + platform = ( + system_snapshot.get("platform_profile") + if isinstance(system_snapshot.get("platform_profile"), dict) + else {} + ) + backend = platform.get("backend") if isinstance(platform.get("backend"), dict) else {} + module_counts = backend.get("module_counts") if isinstance(backend.get("module_counts"), dict) else {} + feature_catalog = platform.get("feature_catalog") if isinstance(platform.get("feature_catalog"), list) else [] + feature_summary = { + "feature_total": len(feature_catalog), + "top_features": [ + { + "name": str(item.get("name") or ""), + "route": str(item.get("route") or ""), + } + for item in feature_catalog[:10] + if isinstance(item, dict) + ], + } + + return { + "tenant_id": self.tenant_id, + "project": project_info, + "module_counts": module_counts, + "feature_summary": feature_summary, + "symbol_summary": symbol_snapshot, + "kg_overview": kg_overview, + "manifest_text": self._compose_manifest_text( + project_info=project_info, + module_counts=module_counts, + feature_summary=feature_summary, + symbol_snapshot=symbol_snapshot, + kg_overview=kg_overview, + ), + "generated_at": now_cn_aware().isoformat(), + } + + async def entity_resolution( + self, + *, + question: str, + project_id: Optional[int] = None, + limit: int = 10, + ) -> dict[str, Any]: + normalized = self._normalize_entity_text(question) + alias_terms = self._collect_alias_terms(normalized) + query_terms = self._extract_query_terms(question, preferred=self._extract_project_lookup_keyword(question)) + for term in alias_terms: + if term not in query_terms: + query_terms.append(term) + + candidates: list[dict[str, Any]] = [] + if query_terms: + try: + max_terms = query_terms[:6] + conditions = [] + for term in max_terms: + like = f"%{term}%" + conditions.append(KGEntity.display_name.ilike(like)) + conditions.append(KGEntity.entity_key.ilike(like)) + rows = ( + await self.db.execute( + select(KGEntity) + .where( + and_( + KGEntity.tenant_id == self.tenant_id, + or_(*conditions), + ) + ) + .order_by(KGEntity.last_seen_at.desc(), KGEntity.id.desc()) + .limit(max(20, int(limit) * 4)) + ) + ).scalars().all() + for row in rows: + payload = self._safe_dict(row.payload) + row_project_id = payload.get("project_id") + if project_id is not None and row_project_id is not None and int(row_project_id) != int(project_id): + continue + score = 0.0 + lowered_name = str(row.display_name or "").lower() + lowered_key = str(row.entity_key or "").lower() + for term in max_terms: + term_lower = term.lower() + if term_lower in lowered_name: + score += 0.34 + if term_lower in lowered_key: + score += 0.22 + if score <= 0: + continue + candidates.append( + { + "entity_id": int(row.id), + "entity_type": str(row.entity_type or ""), + "entity_key": str(row.entity_key or ""), + "display_name": str(row.display_name or ""), + "score": round(min(score, 0.99), 4), + } + ) + except Exception: + candidates = [] + candidates.sort(key=lambda x: float(x.get("score") or 0.0), reverse=True) + + return { + "normalized_question": normalized, + "canonical_terms": alias_terms, + "query_terms": query_terms[:8], + "matched_entities": candidates[: max(1, int(limit))], + } + + async def get_feature_catalog( + self, + *, + query: Optional[str] = None, + limit: int = 20, + ) -> dict[str, Any]: + system_snapshot = await self.context_cache.get_system_snapshot( + db=self.db, + tenant_id=self.tenant_id, + ) + catalog = self._feature_catalog_from_snapshot(system_snapshot) + if query and str(query).strip(): + rows = self._match_feature_catalog_entries( + question=str(query), + system_snapshot=system_snapshot, + limit=max(1, min(int(limit), 100)), + ) + return { + "query": str(query), + "count": len(rows), + "items": rows, + } + max_rows = max(1, min(int(limit), 200)) + return { + "query": str(query or ""), + "count": min(len(catalog), max_rows), + "items": catalog[:max_rows], + } + diff --git a/backend/app/services/adaptive_rag_service/mixin_manifest_feature_p1.py b/backend/app/services/adaptive_rag_service/mixin_manifest_feature_p1.py new file mode 100644 index 00000000..0108437e --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_manifest_feature_p1.py @@ -0,0 +1,226 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGManifestFeatureMixinP1: + def _compose_manifest_text( + self, + *, + project_info: dict[str, Any], + module_counts: dict[str, Any], + feature_summary: dict[str, Any], + symbol_snapshot: dict[str, Any], + kg_overview: dict[str, Any], + ) -> str: + lines: list[str] = [] + if project_info: + lines.append( + f"当前项目:#{int(project_info.get('id') or 0)} {str(project_info.get('title') or 'unknown')} " + f"(status={str(project_info.get('status') or 'unknown')})" + ) + lines.append( + "后端模块规模:" + f"api={int(module_counts.get('api', 0))}," + f"services={int(module_counts.get('services', 0))}," + f"models={int(module_counts.get('models', 0))}," + f"schemas={int(module_counts.get('schemas', 0))}" + ) + lines.append(f"产品功能:total={int(feature_summary.get('feature_total', 0))}") + top_features = feature_summary.get("top_features") + if isinstance(top_features, list) and top_features: + feature_brief = ";".join( + f"{str(item.get('name') or '')}({str(item.get('route') or '')})" + for item in top_features[:6] + if isinstance(item, dict) and str(item.get("name") or "").strip() + ) + if feature_brief: + lines.append(f"核心入口:{feature_brief}") + lines.append( + "符号索引:" + f"files={int(symbol_snapshot.get('indexed_files', 0))}," + f"symbols={int(symbol_snapshot.get('symbol_total', 0))}," + f"python={int(symbol_snapshot.get('python_symbols', 0))}," + f"typescript={int(symbol_snapshot.get('typescript_symbols', 0))}" + ) + lines.append( + "知识图谱:" + f"entities={int(kg_overview.get('entity_total', 0))}," + f"relations={int(kg_overview.get('relation_total', 0))}," + f"evidences={int(kg_overview.get('evidence_total', 0))}" + ) + top_modules = symbol_snapshot.get("top_modules") + if isinstance(top_modules, list) and top_modules: + brief = ";".join( + f"{str(item.get('module') or '')}:{int(item.get('symbols') or 0)}" + for item in top_modules[:5] + if isinstance(item, dict) + ) + if brief: + lines.append(f"热点模块:{brief}") + return "\n".join(lines)[:2200] + + @staticmethod + def _normalize_entity_text(text: str) -> str: + normalized = str(text or "").strip().lower() + normalized = normalized.replace("—", "-").replace("_", " ") + normalized = re.sub(r"[^\w\u4e00-\u9fff\- ]+", " ", normalized) + normalized = re.sub(r"\s+", " ", normalized).strip() + return normalized + + def _collect_alias_terms(self, normalized_question: str) -> list[str]: + aliases: list[str] = [] + for token, canonical in ENTITY_ALIAS_MAP.items(): + if token in normalized_question and canonical not in aliases: + aliases.append(canonical) + return aliases[:8] + + @staticmethod + def _feature_catalog_from_snapshot(system_snapshot: Optional[dict[str, Any]]) -> list[dict[str, Any]]: + if not isinstance(system_snapshot, dict): + return [] + platform = system_snapshot.get("platform_profile") + if not isinstance(platform, dict): + return [] + catalog = platform.get("feature_catalog") + if not isinstance(catalog, list): + return [] + return [item for item in catalog if isinstance(item, dict)] + + def _match_feature_catalog_entries( + self, + *, + question: str, + system_snapshot: Optional[dict[str, Any]], + limit: int = 4, + ) -> list[dict[str, Any]]: + text = str(question or "").strip().lower() + if not text: + return [] + catalog = self._feature_catalog_from_snapshot(system_snapshot) + if not catalog: + return [] + query_terms = [x.lower() for x in self._extract_query_terms(question)[:8] if str(x).strip()] + matched: list[dict[str, Any]] = [] + for item in catalog: + name = str(item.get("name") or "").strip() + route = str(item.get("route") or "").strip() + component = str(item.get("page_component") or "").strip() + aliases_raw = item.get("aliases") + alias_values = aliases_raw if isinstance(aliases_raw, list) else [] + alias_pool = [name, route, component, *[str(x) for x in alias_values]] + alias_pool = [a.strip().lower() for a in alias_pool if str(a).strip()] + if not alias_pool: + continue + + score = 0.0 + for alias in alias_pool: + if alias and alias in text: + score += 0.45 + for term in query_terms: + if term and any(term in alias or alias in term for alias in alias_pool): + score += 0.18 + if score <= 0: + continue + matched.append( + { + **item, + "match_score": round(min(score, 0.99), 4), + } + ) + + matched.sort(key=lambda x: float(x.get("match_score") or 0.0), reverse=True) + return matched[: max(1, int(limit))] + + def _retrieve_feature_catalog_context( + self, + *, + question: str, + system_snapshot: Optional[dict[str, Any]], + max_items: int = 4, + ) -> list[RetrievedItem]: + rows = self._match_feature_catalog_entries( + question=question, + system_snapshot=system_snapshot, + limit=max_items, + ) + out: list[RetrievedItem] = [] + for row in rows: + name = str(row.get("name") or "unknown_feature") + route = str(row.get("route") or "") + summary = str(row.get("summary") or "") + page_file = str(row.get("page_file") or "") + page_component = str(row.get("page_component") or "") + apis = row.get("apis") if isinstance(row.get("apis"), list) else [] + api_text = "、".join(str(x) for x in apis[:4] if str(x).strip()) + content = f"功能: {name}\n用途: {summary}\n入口: {route}\n页面: {page_file}" + if api_text: + content += f"\n相关接口: {api_text}" + score = min(0.99, max(0.62, float(row.get("match_score") or 0.62))) + out.append( + RetrievedItem( + source="feature_catalog", + content=content[:1200], + score=score, + source_label="Feature Catalog", + metadata={ + "feature_name": name, + "route": route, + "page_file": page_file, + "page_component": page_component, + "apis": [str(x) for x in apis[:8] if str(x).strip()], + "match_score": round(score, 4), + }, + ) + ) + return out + + def _collect_feature_evidence( + self, + *, + question: str, + retrieved: list[RetrievedItem], + system_snapshot: Optional[dict[str, Any]], + ) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for item in retrieved: + if str(item.source or "").strip().lower() != "feature_catalog": + continue + meta = item.metadata if isinstance(item.metadata, dict) else {} + route = str(meta.get("route") or "").strip() + page_file = str(meta.get("page_file") or "").strip() + feature_name = str(meta.get("feature_name") or "").strip() + apis = meta.get("apis") if isinstance(meta.get("apis"), list) else [] + clean_apis = [str(x).strip() for x in apis if str(x).strip()] + if not feature_name and not route and not page_file: + continue + rows.append( + { + "feature_name": feature_name, + "route": route, + "page_file": page_file, + "apis": clean_apis[:6], + } + ) + if rows: + return rows[:4] + + # 若检索结果里没有 feature_catalog,回退到 snapshot 匹配结果 + fallback = self._match_feature_catalog_entries( + question=question, + system_snapshot=system_snapshot, + limit=3, + ) + for row in fallback: + apis = row.get("apis") if isinstance(row.get("apis"), list) else [] + rows.append( + { + "feature_name": str(row.get("name") or ""), + "route": str(row.get("route") or ""), + "page_file": str(row.get("page_file") or ""), + "apis": [str(x).strip() for x in apis[:6] if str(x).strip()], + } + ) + return rows[:3] + diff --git a/backend/app/services/adaptive_rag_service/mixin_manifest_feature_p2.py b/backend/app/services/adaptive_rag_service/mixin_manifest_feature_p2.py new file mode 100644 index 00000000..4df3e6e8 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_manifest_feature_p2.py @@ -0,0 +1,51 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGManifestFeatureMixinP2: + def _enforce_feature_evidence_in_answer( + self, + *, + question: str, + answer_text: str, + retrieved: list[RetrievedItem], + system_snapshot: Optional[dict[str, Any]], + ) -> str: + text = str(question or "").strip().lower() + has_feature_token = any(k in text for k in FEATURE_QUERY_KEYWORDS) + ask_feature = has_feature_token and any(k in text for k in FEATURE_PURPOSE_HINTS) + if not ask_feature: + return answer_text + + evidence_rows = self._collect_feature_evidence( + question=question, + retrieved=retrieved, + system_snapshot=system_snapshot, + ) + if not evidence_rows: + return answer_text + + # 已包含明确入口与接口信息时不重复追加 + lowered_answer = str(answer_text or "").lower() + if ("/" in lowered_answer and "接口" in lowered_answer) or "证据补充" in lowered_answer: + return answer_text + + lines: list[str] = [str(answer_text or "").strip(), "", "证据补充(Feature Catalog):"] + for idx, row in enumerate(evidence_rows, start=1): + feature_name = str(row.get("feature_name") or "未知功能") + route = str(row.get("route") or "") + page_file = str(row.get("page_file") or "") + apis = row.get("apis") if isinstance(row.get("apis"), list) else [] + api_text = "、".join(str(x) for x in apis[:3] if str(x).strip()) + line = f"{idx}. {feature_name}" + if route: + line += f" | 路由: {route}" + if page_file: + line += f" | 页面: {page_file}" + if api_text: + line += f" | API: {api_text}" + lines.append(line) + return "\n".join(lines).strip()[:3200] + diff --git a/backend/app/services/adaptive_rag_service/mixin_multistep_core.py b/backend/app/services/adaptive_rag_service/mixin_multistep_core.py new file mode 100644 index 00000000..893295fa --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_multistep_core.py @@ -0,0 +1,239 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGMultiStepCoreMixin: + async def _run_single_retrieval_pass( + self, + *, + question: str, + domains: list[str], + project_id: Optional[int], + source_chat_id: Optional[str], + resolved_entities: Optional[dict[str, Any]] = None, + vector_top_k: Optional[int] = None, + ) -> tuple[list[RetrievedItem], dict[str, Any]]: + async def _run_db_retrieval() -> tuple[list[RetrievedItem], int, bool]: + if "db" not in domains: + return ([], 0, False) + started_at = time.perf_counter() + try: + rows = await asyncio.wait_for( + self._retrieve_structured_db(question, project_id=project_id), + timeout=DB_QUERY_TIMEOUT_SECONDS, + ) + return (rows, int((time.perf_counter() - started_at) * 1000), False) + except asyncio.TimeoutError: + return ([], int((time.perf_counter() - started_at) * 1000), True) + + async def _run_vector_retrieval() -> tuple[list[RetrievedItem], int, bool]: + if not any(x in domains for x in {"doc", "feishu", "pmflow", "url"}): + return ([], 0, False) + started_at = time.perf_counter() + try: + rows = await asyncio.wait_for( + self._retrieve_vector_context( + question, + domains=domains, + project_id=project_id, + source_chat_id=source_chat_id, + top_k=vector_top_k, + ), + timeout=VECTOR_SEARCH_TIMEOUT_SECONDS, + ) + return (rows, int((time.perf_counter() - started_at) * 1000), False) + except asyncio.TimeoutError: + return ([], int((time.perf_counter() - started_at) * 1000), True) + + async def _run_graph_retrieval() -> tuple[list[RetrievedItem], int, bool]: + if "graph" not in domains: + return ([], 0, False) + started_at = time.perf_counter() + try: + rows = await asyncio.wait_for( + self._retrieve_graph_context( + question, + project_id=project_id, + resolved_entities=resolved_entities, + ), + timeout=GRAPH_QUERY_TIMEOUT_SECONDS, + ) + return (rows, int((time.perf_counter() - started_at) * 1000), False) + except asyncio.TimeoutError: + return ([], int((time.perf_counter() - started_at) * 1000), True) + + (db_rows, db_ms, db_timeout), (vec_rows, vec_ms, vec_timeout), (graph_rows, graph_ms, graph_timeout) = ( + await asyncio.gather( + _run_db_retrieval(), + _run_vector_retrieval(), + _run_graph_retrieval(), + ) + ) + merged = [*db_rows, *vec_rows, *graph_rows] + merged.sort(key=lambda x: x.score, reverse=True) + return ( + merged, + { + "db_query_ms": int(db_ms), + "vector_search_ms": int(vec_ms), + "graph_query_ms": int(graph_ms), + "db_timeout": bool(db_timeout), + "vector_timeout": bool(vec_timeout), + "graph_timeout": bool(graph_timeout), + }, + ) + + async def _build_second_pass_query( + self, + *, + question: str, + project_id: Optional[int], + ) -> tuple[str, dict[str, Any]]: + resolved = await self.entity_resolution(question=question, project_id=project_id, limit=5) + manifest = await self.get_project_manifest(project_id=project_id) + hints: list[str] = [] + for term in resolved.get("canonical_terms") or []: + if str(term).strip(): + hints.append(str(term).strip()) + for row in resolved.get("matched_entities") or []: + if not isinstance(row, dict): + continue + name = str(row.get("display_name") or "").strip() + if name: + hints.append(name[:30]) + + symbol_names: list[str] = [] + try: + symbol_service = SymbolIndexService(self.db, tenant_id=self.tenant_id) + symbol_hits = await symbol_service.search_symbols(query=question, limit=8) + for row in symbol_hits.get("items") or []: + if not isinstance(row, dict): + continue + name = str(row.get("qualified_name") or row.get("symbol_name") or "").strip() + if name: + symbol_names.append(name[:60]) + except Exception: + symbol_names = [] + for name in symbol_names[:6]: + hints.append(name) + + feature_names: list[str] = [] + feature_routes: list[str] = [] + try: + system_snapshot = await self.context_cache.get_system_snapshot( + db=self.db, + tenant_id=self.tenant_id, + ) + feature_hits = self._match_feature_catalog_entries( + question=question, + system_snapshot=system_snapshot, + limit=6, + ) + for row in feature_hits: + name = str(row.get("name") or "").strip() + route = str(row.get("route") or "").strip() + if name: + feature_names.append(name[:50]) + if route: + feature_routes.append(route[:80]) + except Exception: + feature_names = [] + feature_routes = [] + for name in feature_names[:4]: + hints.append(name) + for route in feature_routes[:3]: + hints.append(route) + + symbol_top = manifest.get("symbol_summary") if isinstance(manifest.get("symbol_summary"), dict) else {} + modules = symbol_top.get("top_modules") if isinstance(symbol_top.get("top_modules"), list) else [] + module_hint = ";".join( + str(item.get("module") or "") + for item in modules[:3] + if isinstance(item, dict) and str(item.get("module") or "").strip() + ) + clean_hints = [] + for item in hints: + if item and item not in clean_hints: + clean_hints.append(item) + if not clean_hints and not module_hint: + return question[:1200], {"mode": "no_hint"} + + revised = str(question or "").strip() + if clean_hints: + revised = f"{revised}。实体线索:{', '.join(clean_hints[:6])}" + if module_hint: + revised = f"{revised}。候选模块:{module_hint}" + if symbol_names: + revised = f"{revised}。候选符号:{', '.join(symbol_names[:4])}" + if feature_names: + revised = f"{revised}。候选功能:{', '.join(feature_names[:3])}" + if feature_routes: + revised = f"{revised}。候选入口:{', '.join(feature_routes[:2])}" + return revised[:1200], { + "mode": "second_pass_query", + "hint_count": len(clean_hints), + "module_hint": module_hint, + "symbol_hit_count": len(symbol_names), + "feature_hit_count": len(feature_names), + } + + @staticmethod + def _merge_retrieved_items( + base: list[RetrievedItem], + extra: list[RetrievedItem], + *, + limit: Optional[int] = None, + ) -> list[RetrievedItem]: + seen: set[str] = set() + merged: list[RetrievedItem] = [] + for item in [*base, *extra]: + key = f"{item.source}|{item.source_label}|{item.content[:120]}" + if key in seen: + continue + seen.add(key) + merged.append(item) + merged.sort(key=lambda x: x.score, reverse=True) + cap = max(DEFAULT_TOP_K * 2, 8) + if limit is not None: + cap = max(4, min(int(limit), 80)) + return merged[:cap] + + @staticmethod + def _should_second_pass(retrieved: list[RetrievedItem]) -> bool: + if not retrieved: + return True + top_score = float(retrieved[0].score or 0.0) + if top_score < 0.55: + return True + return len(retrieved) < 3 + + @staticmethod + def _merge_search_metrics(base: dict[str, Any], delta: dict[str, Any]) -> dict[str, Any]: + return { + "db_query_ms": int(base.get("db_query_ms", 0)) + int(delta.get("db_query_ms", 0)), + "vector_search_ms": int(base.get("vector_search_ms", 0)) + int(delta.get("vector_search_ms", 0)), + "graph_query_ms": int(base.get("graph_query_ms", 0)) + int(delta.get("graph_query_ms", 0)), + "db_timeout": bool(base.get("db_timeout")) or bool(delta.get("db_timeout")), + "vector_timeout": bool(base.get("vector_timeout")) or bool(delta.get("vector_timeout")), + "graph_timeout": bool(base.get("graph_timeout")) or bool(delta.get("graph_timeout")), + } + + @staticmethod + def _summarize_retrieval_hints(rows: list[RetrievedItem], *, max_items: int = 3) -> str: + hints: list[str] = [] + for row in rows[: max(1, int(max_items))]: + label = str(row.source_label or "").strip() + if label: + hints.append(label[:60]) + continue + content = str(row.content or "").strip() + if content: + hints.append(content.replace("\n", " ")[:60]) + uniq: list[str] = [] + for item in hints: + if item and item not in uniq: + uniq.append(item) + return ";".join(uniq[:3]) + diff --git a/backend/app/services/adaptive_rag_service/mixin_multistep_flow.py b/backend/app/services/adaptive_rag_service/mixin_multistep_flow.py new file mode 100644 index 00000000..d8e08dac --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_multistep_flow.py @@ -0,0 +1,229 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGMultiStepFlowMixin: + async def _multi_step_search( + self, + *, + question: str, + domains: list[str], + project_id: Optional[int], + source_chat_id: Optional[str], + session_state: Optional[QASessionState], + max_steps_override: Optional[int] = None, + vector_top_k: Optional[int] = None, + ) -> tuple[list[RetrievedItem], dict[str, Any]]: + resolved = await self.entity_resolution(question=question, project_id=project_id, limit=6) + manifest = await self.get_project_manifest(project_id=project_id) + system_snapshot = await self.context_cache.get_system_snapshot( + db=self.db, + tenant_id=self.tenant_id, + ) + symbol_hits: dict[str, Any] = {"count": 0, "items": []} + try: + symbol_service = SymbolIndexService(self.db, tenant_id=self.tenant_id) + symbol_hits = await symbol_service.search_symbols(query=question, limit=6) + except Exception: + symbol_hits = {"count": 0, "items": []} + feature_hits = self._match_feature_catalog_entries( + question=question, + system_snapshot=system_snapshot, + limit=6, + ) + max_steps = max(1, min(int(max_steps_override or AGENT_MAX_RETRIEVAL_STEPS), 8)) + tool_trace: list[dict[str, Any]] = [ + { + "step": 0, + "tool": "entity_resolution", + "matched_entities": len(resolved.get("matched_entities") or []), + "canonical_terms": resolved.get("canonical_terms") if isinstance(resolved.get("canonical_terms"), list) else [], + }, + { + "step": 0, + "tool": "get_project_manifest", + "symbol_total": int( + ( + manifest.get("symbol_summary") + if isinstance(manifest.get("symbol_summary"), dict) + else {} + ).get("symbol_total", 0) + ), + "kg_entity_total": int( + ( + manifest.get("kg_overview") + if isinstance(manifest.get("kg_overview"), dict) + else {} + ).get("entity_total", 0) + ), + }, + { + "step": 0, + "tool": "search_symbols", + "hit_count": int(symbol_hits.get("count") or 0), + }, + { + "step": 0, + "tool": "feature_catalog_lookup", + "hit_count": len(feature_hits), + "feature_names": [str(x.get("name") or "") for x in feature_hits[:4]], + }, + ] + if session_state is not None and isinstance(session_state.last_entity_ids, list): + anchor_ids = [int(x) for x in session_state.last_entity_ids if str(x).isdigit()][:6] + else: + anchor_ids = [] + current_question = str(question or "").strip()[:1200] + current_domains: list[str] = [] + for item in domains: + if item not in current_domains: + current_domains.append(item) + merged_rows: list[RetrievedItem] = [] + merged_metrics: dict[str, Any] = { + "db_query_ms": 0, + "vector_search_ms": 0, + "graph_query_ms": 0, + "db_timeout": False, + "vector_timeout": False, + "graph_timeout": False, + } + merge_limit = max(DEFAULT_TOP_K * 2, 8) + if vector_top_k is not None: + merge_limit = max(merge_limit, max(2, int(vector_top_k)) * 3) + + for step in range(1, max_steps + 1): + resolved_payload = { + **resolved, + "anchor_entity_ids": anchor_ids, + } + step_rows, step_metrics = await self._run_single_retrieval_pass( + question=current_question, + domains=current_domains, + project_id=project_id, + source_chat_id=source_chat_id, + resolved_entities=resolved_payload, + vector_top_k=vector_top_k, + ) + feature_rows = self._retrieve_feature_catalog_context( + question=current_question, + system_snapshot=system_snapshot, + max_items=4, + ) + if feature_rows: + step_rows = self._merge_retrieved_items(feature_rows, step_rows, limit=merge_limit) + merged_rows = self._merge_retrieved_items(merged_rows, step_rows, limit=merge_limit) + merged_metrics = self._merge_search_metrics(merged_metrics, step_metrics) + top_score = round(float(step_rows[0].score), 4) if step_rows else 0.0 + tool_trace.append( + { + "step": step, + "tool": "parallel_retrieval", + "query": current_question[:240], + "domains": current_domains, + "items": len(step_rows), + "feature_items": len(feature_rows), + "top_score": top_score, + "merged_items": len(merged_rows), + } + ) + + if step >= max_steps: + tool_trace.append( + { + "step": step, + "tool": "planner_stop", + "reason": "max_steps_reached", + "max_steps": max_steps, + } + ) + break + if not self._should_second_pass(merged_rows): + tool_trace.append( + { + "step": step, + "tool": "planner_stop", + "reason": "enough_evidence", + "top_score": round(float(merged_rows[0].score), 4) if merged_rows else 0.0, + } + ) + break + + next_question, rewrite_meta = await self._build_second_pass_query( + question=current_question, + project_id=project_id, + ) + row_hint = self._summarize_retrieval_hints(step_rows) + if row_hint and row_hint not in next_question: + next_question = f"{next_question}。已命中线索:{row_hint}" + next_question = next_question[:1200] + if next_question.strip() == current_question.strip(): + tool_trace.append( + { + "step": step, + "tool": "planner_stop", + "reason": "query_not_changed", + "rewrite_meta": rewrite_meta, + } + ) + break + + next_domains: list[str] = [] + for item in [*current_domains, "graph", "db", "doc", "pmflow", "feishu"]: + if item not in next_domains: + next_domains.append(item) + tool_trace.append( + { + "step": step + 1, + "tool": "query_refine", + "rewrite_meta": rewrite_meta, + "next_query": next_question[:240], + "next_domains": next_domains, + "anchor_entity_ids": anchor_ids, + } + ) + current_question = next_question + current_domains = next_domains + + result_cap = max(DEFAULT_TOP_K * 2, 8) + if vector_top_k is not None: + result_cap = max(result_cap, max(2, int(vector_top_k)) * 2) + + return merged_rows[:result_cap], { + **merged_metrics, + "domains_used": current_domains, + "tool_trace": tool_trace, + } + + async def multi_step_search( + self, + *, + question: str, + project_id: Optional[int] = None, + source_chat_id: Optional[str] = None, + session_state: Optional[QASessionState] = None, + ) -> dict[str, Any]: + domains = self._route_domains(question) + rows, metrics = await self._multi_step_search( + question=question, + domains=domains, + project_id=project_id, + source_chat_id=source_chat_id, + session_state=session_state, + ) + return { + "question": question, + "domains": metrics.get("domains_used") or domains, + "items": self._dedupe_sources(rows), + "retrieval_metrics": { + "db_query_ms": int(metrics.get("db_query_ms") or 0), + "vector_search_ms": int(metrics.get("vector_search_ms") or 0), + "graph_query_ms": int(metrics.get("graph_query_ms") or 0), + "db_timeout": bool(metrics.get("db_timeout")), + "vector_timeout": bool(metrics.get("vector_timeout")), + "graph_timeout": bool(metrics.get("graph_timeout")), + }, + "tool_trace": metrics.get("tool_trace") if isinstance(metrics.get("tool_trace"), list) else [], + } + diff --git a/backend/app/services/adaptive_rag_service/mixin_qa_feedback_records.py b/backend/app/services/adaptive_rag_service/mixin_qa_feedback_records.py new file mode 100644 index 00000000..4d28ffdb --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_qa_feedback_records.py @@ -0,0 +1,155 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGQAFeedbackRecordsMixin: + async def submit_feedback( + self, + *, + qa_id: int, + rating: str, + submitted_by: Optional[int], + helpfulness: Optional[str] = None, + error_types: Optional[list[str]] = None, + correct_answer: Optional[str] = None, + ) -> dict[str, Any]: + qa = ( + await self.db.execute( + select(QARecord).where( + and_(QARecord.id == int(qa_id), QARecord.tenant_id == self.tenant_id) + ) + ) + ).scalar_one_or_none() + if not qa: + raise ValueError("qa record 不存在") + + feedback = QAFeedback( + tenant_id=self.tenant_id, + qa_id=int(qa_id), + rating=(rating or "").strip().lower()[:16], + helpfulness=(helpfulness or "").strip()[:32] or None, + error_types=[str(x).strip() for x in (error_types or []) if str(x).strip()] or None, + correct_answer=(correct_answer or "").strip()[:4000] or None, + submitted_by=submitted_by, + ) + self.db.add(feedback) + await self.db.commit() + await self.db.refresh(feedback) + if str(feedback.rating or "").strip().lower() == "negative" and ( + str(feedback.correct_answer or "").strip() or [str(x).strip() for x in (feedback.error_types or []) if str(x).strip()] + ): + try: + await self.upsert_intent_correction_sample( + user_utterance=str(qa.question or ""), + wrong_interpretation=str(qa.answer or ""), + correct_intent="query", + canonical_action="clarify_before_answer", + clarification_template=str(feedback.correct_answer or "").strip() or "收到纠正后先澄清范围再回答。", + risk_level="medium", + project_id=int(qa.project_id) if qa.project_id is not None else None, + ) + except Exception: + await self.db.rollback() + return { + "id": int(feedback.id), + "qa_id": int(feedback.qa_id), + "rating": feedback.rating, + "helpfulness": feedback.helpfulness, + "error_types": feedback.error_types or [], + "correct_answer": feedback.correct_answer, + } + + async def list_qa_records( + self, + *, + project_id: Optional[int], + date_from: Optional[datetime], + date_to: Optional[datetime], + rating: Optional[str], + page: int, + page_size: int, + ) -> dict[str, Any]: + page = max(1, int(page)) + page_size = max(1, min(100, int(page_size))) + date_from_naive = self._to_naive_utc(date_from) + date_to_naive = self._to_naive_utc(date_to) + + stmt = select(QARecord).where(QARecord.tenant_id == self.tenant_id) + if project_id is not None: + stmt = stmt.where(QARecord.project_id == int(project_id)) + if date_from_naive is not None: + stmt = stmt.where(QARecord.created_at >= date_from_naive) + if date_to_naive is not None: + stmt = stmt.where(QARecord.created_at <= date_to_naive) + + records = ( + await self.db.execute( + stmt.order_by(QARecord.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + ).scalars().all() + + ids = [int(r.id) for r in records] + feedback_rows = [] + if ids: + fb_stmt = select(QAFeedback).where( + and_( + QAFeedback.tenant_id == self.tenant_id, + QAFeedback.qa_id.in_(ids), + ) + ) + if rating: + fb_stmt = fb_stmt.where(QAFeedback.rating == rating) + feedback_rows = (await self.db.execute(fb_stmt)).scalars().all() + + latest_feedback: dict[int, QAFeedback] = {} + for fb in feedback_rows: + key = int(fb.qa_id) + curr = latest_feedback.get(key) + if curr is None or (fb.created_at and curr.created_at and fb.created_at > curr.created_at): + latest_feedback[key] = fb + + items: list[dict[str, Any]] = [] + for row in records: + fb = latest_feedback.get(int(row.id)) + if rating and not fb: + continue + ack_meta = self._extract_ack_metadata_from_sources(row.sources, answer=row.answer) + items.append( + { + "id": int(row.id), + "question": row.question, + "answer": row.answer, + "sources": row.sources or [], + "intent": row.intent, + "domains_used": row.domains_used or [], + "response_time_ms": row.response_time_ms, + "asked_by": row.asked_by, + "asked_via": row.asked_via, + "project_id": row.project_id, + "session_id": row.session_id, + "turn_index": int(row.turn_index or 1), + "created_at": row.created_at.isoformat() if row.created_at else None, + "feedback": { + "id": int(fb.id), + "rating": fb.rating, + "helpfulness": fb.helpfulness, + "error_types": fb.error_types or [], + "correct_answer": fb.correct_answer, + } + if fb + else None, + **ack_meta, + } + ) + + return { + "items": items, + "page": page, + "page_size": page_size, + "count": len(items), + } + diff --git a/backend/app/services/adaptive_rag_service/mixin_qa_session_core.py b/backend/app/services/adaptive_rag_service/mixin_qa_session_core.py new file mode 100644 index 00000000..a74bc50d --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_qa_session_core.py @@ -0,0 +1,152 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGQASessionCoreMixin: + @staticmethod + def _to_naive_utc(value: Optional[datetime]) -> Optional[datetime]: + if value is None: + return None + if value.tzinfo is None: + return value + return value.astimezone(CN_TZ).replace(tzinfo=None) + + @staticmethod + def _normalize_session_title_raw(text: str, *, max_len: int = 30) -> str: + compact = " ".join(str(text or "").replace("\n", " ").split()).strip() + if not compact: + return "" + return compact[:max_len] + + @classmethod + def _build_auto_session_title(cls, question: str) -> Optional[str]: + normalized = cls._normalize_session_title_raw(question, max_len=30) + if not normalized: + return None + compact = re.sub(r"\s+", "", normalized) + if len(compact) < SESSION_TITLE_MIN_LEN: + return None + return normalized + + @staticmethod + def _is_default_session_title(title: Optional[str]) -> bool: + clean = str(title or "").strip() + return (not clean) or clean == SESSION_DEFAULT_TITLE + + async def _touch_session_for_new_record( + self, + *, + asked_by: Optional[int], + asked_via: str, + project_id: Optional[int], + question: str, + preferred_session_id: Optional[int] = None, + force_new_session: bool = False, + ) -> tuple[Optional[int], int]: + if asked_by is None: + return (None, 1) + + now_utc = now_cn_naive() + chosen: Optional[QASession] = None + preferred_id = int(preferred_session_id) if preferred_session_id else 0 + if preferred_id > 0: + chosen = ( + await self.db.execute( + select(QASession).where( + and_( + QASession.tenant_id == self.tenant_id, + QASession.user_id == int(asked_by), + QASession.id == preferred_id, + ) + ) + ) + ).scalar_one_or_none() + + # 无显式 session_id 时,仅在非强制新会话模式下复用同渠道(asked_via)且时间窗口内的最近会话。 + if chosen is None and not force_new_session: + latest_record = ( + await self.db.execute( + select(QARecord.session_id, QARecord.created_at) + .where( + and_( + QARecord.tenant_id == self.tenant_id, + QARecord.asked_by == int(asked_by), + QARecord.asked_via == (asked_via or "pmflow")[:20], + QARecord.session_id.is_not(None), + ) + ) + .order_by(QARecord.created_at.desc(), QARecord.id.desc()) + .limit(1) + ) + ).first() + if latest_record and latest_record.session_id is not None: + last_at = self._to_naive_utc(latest_record.created_at) + if last_at is not None: + gap_seconds = abs((now_utc - last_at).total_seconds()) + is_same_day = now_utc.date() == last_at.date() + if is_same_day and gap_seconds <= SESSION_GAP_MINUTES * 60: + chosen = ( + await self.db.execute( + select(QASession).where( + and_( + QASession.tenant_id == self.tenant_id, + QASession.user_id == int(asked_by), + QASession.id == int(latest_record.session_id), + ) + ) + ) + ).scalar_one_or_none() + + if chosen is None: + title = self._build_auto_session_title(question) or SESSION_DEFAULT_TITLE + chosen = QASession( + tenant_id=self.tenant_id, + user_id=int(asked_by), + title=title[:120], + project_id=project_id, + started_at=now_utc, + last_message_at=now_utc, + message_count=0, + ) + self.db.add(chosen) + await self.db.flush() + else: + if chosen.project_id is None and project_id is not None: + chosen.project_id = int(project_id) + chosen.last_message_at = now_utc + + chosen.message_count = int(chosen.message_count or 0) + 1 + if self._is_default_session_title(chosen.title): + generated = self._build_auto_session_title(question) + if generated: + chosen.title = generated[:120] + await self.db.flush() + return (int(chosen.id), int(chosen.message_count)) + + async def _latest_feedback_map(self, record_ids: list[int]) -> dict[int, QAFeedback]: + ids = [int(x) for x in record_ids if x is not None] + if not ids: + return {} + feedback_rows = ( + await self.db.execute( + select(QAFeedback).where( + and_( + QAFeedback.tenant_id == self.tenant_id, + QAFeedback.qa_id.in_(ids), + ) + ) + ) + ).scalars().all() + + latest_feedback: dict[int, QAFeedback] = {} + for fb in feedback_rows: + key = int(fb.qa_id) + curr = latest_feedback.get(key) + fb_at = self._to_naive_utc(fb.created_at) or datetime.min + curr_at = self._to_naive_utc(curr.created_at) if curr else None + if curr is None or curr_at is None or fb_at >= curr_at: + latest_feedback[key] = fb + return latest_feedback + diff --git a/backend/app/services/adaptive_rag_service/mixin_qa_session_list_ops_p1.py b/backend/app/services/adaptive_rag_service/mixin_qa_session_list_ops_p1.py new file mode 100644 index 00000000..63559acd --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_qa_session_list_ops_p1.py @@ -0,0 +1,240 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGQASessionListOpsMixinP1: + async def list_qa_sessions( + self, + *, + user_id: int, + date_from: Optional[datetime], + date_to: Optional[datetime], + page: int, + page_size: int, + ) -> dict[str, Any]: + page = max(1, int(page)) + page_size = max(1, min(100, int(page_size))) + date_from_naive = self._to_naive_utc(date_from) + date_to_naive = self._to_naive_utc(date_to) + + base_stmt = select(QASession).where( + and_( + QASession.tenant_id == self.tenant_id, + QASession.user_id == int(user_id), + ) + ) + if date_from_naive is not None: + base_stmt = base_stmt.where(QASession.last_message_at >= date_from_naive) + if date_to_naive is not None: + base_stmt = base_stmt.where(QASession.last_message_at <= date_to_naive) + + total = int((await self.db.execute(select(func.count()).select_from(base_stmt.subquery()))).scalar() or 0) + rows = ( + await self.db.execute( + base_stmt.order_by(QASession.last_message_at.desc(), QASession.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + ).scalars().all() + if not rows: + return { + "items": [], + "page": page, + "page_size": page_size, + "total": total, + "total_pages": 0, + "count": 0, + } + + session_ids = [int(x.id) for x in rows] + record_rows = ( + await self.db.execute( + select(QARecord).where( + and_( + QARecord.tenant_id == self.tenant_id, + QARecord.session_id.in_(session_ids), + ) + ) + ) + ).scalars().all() + + records_by_session: dict[int, list[QARecord]] = {} + for row in record_rows: + sid = int(row.session_id or 0) + if sid <= 0: + continue + records_by_session.setdefault(sid, []).append(row) + + feedback_map = await self._latest_feedback_map([int(x.id) for x in record_rows]) + items: list[dict[str, Any]] = [] + for row in rows: + sid = int(row.id) + session_records = sorted( + records_by_session.get(sid, []), + key=lambda r: (int(r.turn_index or 0), self._to_naive_utc(r.created_at) or datetime.min), + ) + positive = 0 + negative = 0 + corrected = 0 + last_asked_via = "pmflow" + for rec in session_records: + last_asked_via = str(rec.asked_via or last_asked_via) + fb = feedback_map.get(int(rec.id)) + if not fb: + continue + rating = str(fb.rating or "").strip().lower() + if rating == "positive": + positive += 1 + elif rating == "negative": + negative += 1 + if str(fb.correct_answer or "").strip(): + corrected += 1 + + message_count = int(row.message_count or len(session_records)) + unrated = max(message_count - positive - negative, 0) + first_question = session_records[0].question if session_records else "" + items.append( + { + "id": sid, + "title": str(row.title or SESSION_DEFAULT_TITLE), + "project_id": row.project_id, + "started_at": row.started_at.isoformat() if row.started_at else None, + "last_message_at": row.last_message_at.isoformat() if row.last_message_at else None, + "message_count": message_count, + "turn_count": message_count, + "rating_summary": { + "positive": positive, + "negative": negative, + "unrated": unrated, + "corrected": corrected, + }, + "last_asked_via": last_asked_via, + "preview_question": str(first_question or "")[:120], + } + ) + + total_pages = math.ceil(total / page_size) if total > 0 else 0 + return { + "items": items, + "page": page, + "page_size": page_size, + "total": total, + "total_pages": total_pages, + "count": len(items), + } + + async def get_qa_session_detail(self, *, session_id: int, user_id: int) -> dict[str, Any]: + session_row = ( + await self.db.execute( + select(QASession).where( + and_( + QASession.id == int(session_id), + QASession.tenant_id == self.tenant_id, + QASession.user_id == int(user_id), + ) + ) + ) + ).scalar_one_or_none() + if not session_row: + raise ValueError("qa session 不存在") + + records = ( + await self.db.execute( + select(QARecord).where( + and_( + QARecord.tenant_id == self.tenant_id, + QARecord.session_id == int(session_id), + ) + ).order_by(QARecord.turn_index.asc(), QARecord.created_at.asc()) + ) + ).scalars().all() + feedback_map = await self._latest_feedback_map([int(x.id) for x in records]) + + messages: list[dict[str, Any]] = [] + positive = 0 + negative = 0 + corrected = 0 + for row in records: + fb = feedback_map.get(int(row.id)) + ack_meta = self._extract_ack_metadata_from_sources(row.sources, answer=row.answer) + if fb: + rating = str(fb.rating or "").strip().lower() + if rating == "positive": + positive += 1 + elif rating == "negative": + negative += 1 + if str(fb.correct_answer or "").strip(): + corrected += 1 + messages.append( + { + "id": int(row.id), + "question": row.question, + "answer": row.answer, + "sources": row.sources or [], + "intent": row.intent, + "domains_used": row.domains_used or [], + "response_time_ms": row.response_time_ms, + "asked_by": row.asked_by, + "asked_via": row.asked_via, + "project_id": row.project_id, + "session_id": row.session_id, + "turn_index": int(row.turn_index or 1), + "created_at": row.created_at.isoformat() if row.created_at else None, + "feedback": { + "id": int(fb.id), + "rating": fb.rating, + "helpfulness": fb.helpfulness, + "error_types": fb.error_types or [], + "correct_answer": fb.correct_answer, + } + if fb + else None, + **ack_meta, + } + ) + + message_count = int(session_row.message_count or len(records)) + unrated = max(message_count - positive - negative, 0) + return { + "session": { + "id": int(session_row.id), + "title": str(session_row.title or SESSION_DEFAULT_TITLE), + "project_id": session_row.project_id, + "started_at": session_row.started_at.isoformat() if session_row.started_at else None, + "last_message_at": session_row.last_message_at.isoformat() if session_row.last_message_at else None, + "message_count": message_count, + "turn_count": message_count, + "rating_summary": { + "positive": positive, + "negative": negative, + "unrated": unrated, + "corrected": corrected, + }, + }, + "messages": messages, + } + + async def rename_qa_session(self, *, session_id: int, user_id: int, title: str) -> dict[str, Any]: + session_row = ( + await self.db.execute( + select(QASession).where( + and_( + QASession.id == int(session_id), + QASession.tenant_id == self.tenant_id, + QASession.user_id == int(user_id), + ) + ) + ) + ).scalar_one_or_none() + if not session_row: + raise ValueError("qa session 不存在") + + cleaned = self._normalize_session_title_raw(title, max_len=120) + if not cleaned: + raise ValueError("会话标题不能为空") + session_row.title = cleaned + await self.db.commit() + return {"id": int(session_row.id), "title": cleaned} + diff --git a/backend/app/services/adaptive_rag_service/mixin_qa_session_list_ops_p2.py b/backend/app/services/adaptive_rag_service/mixin_qa_session_list_ops_p2.py new file mode 100644 index 00000000..5855eca0 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_qa_session_list_ops_p2.py @@ -0,0 +1,54 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGQASessionListOpsMixinP2: + async def delete_qa_session(self, *, session_id: int, user_id: int) -> bool: + session_row = ( + await self.db.execute( + select(QASession).where( + and_( + QASession.id == int(session_id), + QASession.tenant_id == self.tenant_id, + QASession.user_id == int(user_id), + ) + ) + ) + ).scalar_one_or_none() + if not session_row: + return False + + qa_ids = ( + await self.db.execute( + select(QARecord.id).where( + and_( + QARecord.tenant_id == self.tenant_id, + QARecord.session_id == int(session_id), + ) + ) + ) + ).scalars().all() + id_list = [int(x) for x in qa_ids] + if id_list: + await self.db.execute( + delete(QAFeedback).where( + and_( + QAFeedback.tenant_id == self.tenant_id, + QAFeedback.qa_id.in_(id_list), + ) + ) + ) + await self.db.execute( + delete(QARecord).where( + and_( + QARecord.tenant_id == self.tenant_id, + QARecord.session_id == int(session_id), + ) + ) + ) + await self.db.delete(session_row) + await self.db.commit() + return True + diff --git a/backend/app/services/adaptive_rag_service/mixin_qa_stats_export.py b/backend/app/services/adaptive_rag_service/mixin_qa_stats_export.py new file mode 100644 index 00000000..f0f59500 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_qa_stats_export.py @@ -0,0 +1,185 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGQAStatsExportMixin: + async def get_qa_stats( + self, + *, + project_id: Optional[int], + date_from: Optional[datetime], + date_to: Optional[datetime], + ) -> dict[str, Any]: + date_from_naive = self._to_naive_utc(date_from) + date_to_naive = self._to_naive_utc(date_to) + record_stmt = select(QARecord).where(QARecord.tenant_id == self.tenant_id) + if project_id is not None: + record_stmt = record_stmt.where(QARecord.project_id == int(project_id)) + if date_from_naive is not None: + record_stmt = record_stmt.where(QARecord.created_at >= date_from_naive) + if date_to_naive is not None: + record_stmt = record_stmt.where(QARecord.created_at <= date_to_naive) + + records = (await self.db.execute(record_stmt)).scalars().all() + if not records: + return { + "total_sessions": 0, + "total_qa": 0, + "rated_count": 0, + "rated_ratio": 0.0, + "average_helpfulness": 0.0, + "exportable_pairs": 0, + } + + record_ids = [int(r.id) for r in records] + feedback_rows = ( + await self.db.execute( + select(QAFeedback).where( + and_( + QAFeedback.tenant_id == self.tenant_id, + QAFeedback.qa_id.in_(record_ids), + ) + ) + ) + ).scalars().all() + + latest_feedback: dict[int, QAFeedback] = {} + for fb in feedback_rows: + key = int(fb.qa_id) + curr = latest_feedback.get(key) + fb_at = fb.created_at or datetime.min + curr_at = curr.created_at if curr and curr.created_at else datetime.min + if curr is None or fb_at >= curr_at: + latest_feedback[key] = fb + + rated_count = len(latest_feedback) + rated_ratio = round(rated_count / max(len(records), 1), 4) + + helpful_scores: list[float] = [] + helpful_map = { + "fully": 5.0, + "could_be_better": 4.0, + "partially": 3.0, + } + for fb in latest_feedback.values(): + rating = str(fb.rating or "").strip().lower() + if rating == "positive": + score = helpful_map.get(str(fb.helpfulness or "").strip().lower(), 4.0) + elif rating == "negative": + score = 1.0 + else: + continue + helpful_scores.append(score) + + avg_helpfulness = round(sum(helpful_scores) / len(helpful_scores), 2) if helpful_scores else 0.0 + + exportable_pairs = 0 + for fb in latest_feedback.values(): + rating = str(fb.rating or "").strip().lower() + if rating == "positive" and str(fb.helpfulness or "").strip(): + exportable_pairs += 1 + continue + if rating == "negative": + exportable_pairs += 1 + + return { + "total_sessions": len({int(r.session_id) for r in records if r.session_id is not None}), + "total_qa": len(records), + "rated_count": rated_count, + "rated_ratio": rated_ratio, + "average_helpfulness": avg_helpfulness, + "exportable_pairs": exportable_pairs, + } + + async def export_qa_jsonl( + self, + *, + include_unrated: bool, + project_id: Optional[int], + rating: Optional[str], + date_from: Optional[datetime], + date_to: Optional[datetime], + domain: Optional[str], + ) -> str: + date_from_naive = self._to_naive_utc(date_from) + date_to_naive = self._to_naive_utc(date_to) + record_stmt = select(QARecord).where(QARecord.tenant_id == self.tenant_id) + if project_id is not None: + record_stmt = record_stmt.where(QARecord.project_id == int(project_id)) + if date_from_naive is not None: + record_stmt = record_stmt.where(QARecord.created_at >= date_from_naive) + if date_to_naive is not None: + record_stmt = record_stmt.where(QARecord.created_at <= date_to_naive) + if domain: + record_stmt = record_stmt.where(QARecord.domains_used.is_not(None)) + + records = (await self.db.execute(record_stmt.order_by(QARecord.created_at.asc()))).scalars().all() + if not records: + return "" + + feedback_map = await self._latest_feedback_map([int(r.id) for r in records]) + session_ids = sorted({int(r.session_id) for r in records if r.session_id is not None}) + session_map: dict[int, QASession] = {} + if session_ids: + rows = ( + await self.db.execute( + select(QASession).where( + and_( + QASession.tenant_id == self.tenant_id, + QASession.id.in_(session_ids), + ) + ) + ) + ).scalars().all() + session_map = {int(x.id): x for x in rows} + + lines: list[str] = [] + normalized_rating = str(rating or "").strip().lower() or None + for record in records: + feedback = feedback_map.get(int(record.id)) + if normalized_rating == "unrated": + if feedback: + continue + else: + if not feedback and not include_unrated: + continue + if normalized_rating in {"positive", "negative"}: + if not feedback or str(feedback.rating or "").strip().lower() != normalized_rating: + continue + if domain: + domains = [str(x).lower() for x in (record.domains_used or [])] + if domain.lower() not in domains: + continue + + session = session_map.get(int(record.session_id)) if record.session_id is not None else None + row = { + "session_id": int(record.session_id) if record.session_id is not None else None, + "session_title": str(session.title or SESSION_DEFAULT_TITLE) if session else SESSION_DEFAULT_TITLE, + "turn_index": int(record.turn_index or 1), + "question": record.question, + "answer": record.answer, + "sources": record.sources or [], + "domains_used": record.domains_used or [], + "asked_via": record.asked_via, + "project_id": record.project_id, + "created_at": record.created_at.isoformat() if record.created_at else None, + # 兼容历史导出字段 + "prompt": record.question, + "response": record.answer, + "rating": str(feedback.rating) if feedback else "unrated", + } + if feedback: + if feedback.helpfulness: + row["helpfulness"] = feedback.helpfulness + errors = [str(x) for x in (feedback.error_types or []) if str(x).strip()] + if errors: + row["error_type"] = errors[0] + row["error_types"] = errors + if feedback.correct_answer: + row["correct_answer"] = feedback.correct_answer + lines.append(json.dumps(row, ensure_ascii=False)) + + return "\n".join(lines) + diff --git a/backend/app/services/adaptive_rag_service/mixin_requirement_ack_core.py b/backend/app/services/adaptive_rag_service/mixin_requirement_ack_core.py new file mode 100644 index 00000000..a29d8488 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_requirement_ack_core.py @@ -0,0 +1,197 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGRequirementAckCoreMixin: + @staticmethod + def _normalize_action_mode(value: Optional[str]) -> str: + text = str(value or "").strip().lower() + if text in {ACTION_MODE_READ, ACTION_MODE_WRITE, ACTION_MODE_READ_WRITE}: + return text + return ACTION_MODE_AUTO + + @classmethod + def _looks_like_write_intent(cls, question: str) -> bool: + text = str(question or "").strip().lower() + if not text: + return False + has_verb = any(k in text for k in REQUIREMENT_WRITE_VERB_KEYWORDS) + has_object = any(k in text for k in REQUIREMENT_WRITE_OBJECT_KEYWORDS) + if not has_verb or not has_object: + return False + if any(prefix in text for prefix in WRITE_EXPRESSIONS): + return True + if "新增" in text or "增加" in text or "记录" in text or "入库" in text: + return True + return False + + @classmethod + def _resolve_action_mode( + cls, + *, + requested_mode: str, + question: str, + effective_question: str, + ) -> str: + mode = cls._normalize_action_mode(requested_mode) + if mode != ACTION_MODE_AUTO: + return mode + + merged = f"{question} {effective_question}".strip() + if not cls._looks_like_write_intent(merged): + return ACTION_MODE_READ + has_question_form = ("?" in merged) or ("?" in merged) or cls._contains_any(merged, QUESTION_HINTS) + if has_question_form: + return ACTION_MODE_READ_WRITE + return ACTION_MODE_WRITE + + @staticmethod + def _normalize_requirement_summary(text: str) -> str: + normalized = re.sub(r"\s+", " ", str(text or "").strip()) + normalized = normalized.strip(",,。.!!??::;;") + return normalized[:300] + + @staticmethod + def _build_ack_trace_id(question: str) -> str: + base = str(question or "").strip() + digest = hashlib.sha1(base.encode("utf-8")).hexdigest()[:10] if base else "empty" + return f"rag-ack-{int(time.time() * 1000)}-{digest}" + + async def _ensure_project_signal_link( + self, + *, + signal_id: int, + project_id: int, + link_type: str = "related", + ) -> None: + existing = ( + await self.db.execute( + select(ProjectSignalLink.id).where( + and_( + ProjectSignalLink.tenant_id == self.tenant_id, + ProjectSignalLink.project_id == int(project_id), + ProjectSignalLink.signal_id == int(signal_id), + ) + ).limit(1) + ) + ).scalar_one_or_none() + if existing is not None: + return + self.db.add( + ProjectSignalLink( + tenant_id=self.tenant_id, + project_id=int(project_id), + signal_id=int(signal_id), + link_type=link_type, + relevance_score=1.0, + ) + ) + + async def _create_or_reuse_requirement_signal( + self, + *, + question: str, + effective_question: str, + asked_by: Optional[int], + project_id: Optional[int], + ) -> tuple[str, Optional[int], Optional[int], str]: + summary = self._normalize_requirement_summary(effective_question or question) + resolved_project_id: Optional[int] = project_id + if resolved_project_id is None: + scoped_id, _, _ = await self._resolve_project_scope_for_question( + question=effective_question or question, + project_id=project_id, + ) + resolved_project_id = scoped_id + + existing_stmt = select(Signal).where( + and_( + Signal.tenant_id == self.tenant_id, + Signal.source == "PMFlow", + Signal.summary == summary, + ) + ) + if resolved_project_id is None: + existing_stmt = existing_stmt.where(Signal.matched_project_id.is_(None)) + else: + existing_stmt = existing_stmt.where(Signal.matched_project_id == int(resolved_project_id)) + existing = ( + await self.db.execute( + existing_stmt.order_by(Signal.created_at.desc(), Signal.id.desc()).limit(1) + ) + ).scalar_one_or_none() + if existing is not None: + if resolved_project_id is not None: + try: + await self._ensure_project_signal_link( + signal_id=int(existing.id), + project_id=int(resolved_project_id), + ) + await self.db.commit() + except Exception as exc: + await self.db.rollback() + return ("signal_failed", None, resolved_project_id, str(exc)[:240]) + return ("signal_deduped", int(existing.id), resolved_project_id, "") + + actor = f"PMFlow用户#{int(asked_by)}" if asked_by is not None else "PMFlow用户" + content = f"[{actor}]: {summary}" + strategic_payload = { + "source": "pmflow_qa", + "original_question": str(question or "")[:4000], + "normalized_question": summary, + "write_mode": "ack_first", + "created_at": now_cn_aware().isoformat(), + } + signal = Signal( + source="PMFlow", + content=content[:2000], + impact_score=0.65, + priority="medium", + processed=False, + strategic_analysis=json.dumps(strategic_payload, ensure_ascii=False), + tasks_generated=json.dumps([], ensure_ascii=False), + matched_project_id=int(resolved_project_id) if resolved_project_id is not None else None, + title=summary[:120] or "PMFlow 新需求信号", + signal_type="新需求", + summary=summary, + reasoning=json.dumps(["来自 PMFlow 问答写入意图,按 ACK-first 策略快速入库。"], ensure_ascii=False), + confidence=0.74, + recommended_action="enter_only", + status="待处理", + tenant_id=self.tenant_id, + ) + try: + self.db.add(signal) + await self.db.flush() + if resolved_project_id is not None: + await self._ensure_project_signal_link( + signal_id=int(signal.id), + project_id=int(resolved_project_id), + ) + await self.db.commit() + await self.db.refresh(signal) + return ("signal_created", int(signal.id), resolved_project_id, "") + except Exception as exc: + await self.db.rollback() + return ("signal_failed", None, resolved_project_id, str(exc)[:240]) + + @staticmethod + def _build_requirement_ack_text( + *, + action_taken: str, + signal_id: Optional[int], + project_id: Optional[int], + error_reason: str, + ) -> str: + project_text = f"项目#{int(project_id)}" if project_id is not None else "未指定项目" + if action_taken == "signal_created" and signal_id is not None: + return f"已入库:已为{project_text}创建信号 #{int(signal_id)},并进入待处理队列。" + if action_taken == "signal_deduped" and signal_id is not None: + return f"已入库:检测到重复需求,已复用{project_text}的信号 #{int(signal_id)}。" + if action_taken == "signal_failed": + reason = error_reason or "写入异常" + return f"未完成入库:{reason}。我已记录本次失败,请稍后重试。" + return "已收到需求,正在处理中。" + diff --git a/backend/app/services/adaptive_rag_service/mixin_requirement_ack_flow.py b/backend/app/services/adaptive_rag_service/mixin_requirement_ack_flow.py new file mode 100644 index 00000000..4f8294cf --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_requirement_ack_flow.py @@ -0,0 +1,173 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGRequirementAckFlowMixin: + async def _handle_ack_first_requirement( + self, + *, + question: str, + effective_question: str, + asked_by: Optional[int], + asked_via: str, + project_id: Optional[int], + session_id: Optional[int], + turn_index: int, + state_row: Optional[QASessionState], + rewrite_meta: dict[str, Any], + context_session_id: Optional[int], + query_type: str, + action_mode: str, + started_at: float, + ) -> Optional[dict[str, Any]]: + action_taken, linked_signal_id, resolved_project_id, error_reason = await self._create_or_reuse_requirement_signal( + question=question, + effective_question=effective_question, + asked_by=asked_by, + project_id=project_id, + ) + if not action_taken: + return None + + ack_latency_ms = int((time.perf_counter() - started_at) * 1000) + trace_id = self._build_ack_trace_id(question) + reply_text = self._build_requirement_ack_text( + action_taken=action_taken, + signal_id=linked_signal_id, + project_id=resolved_project_id, + error_reason=error_reason, + ) + source_orchestration_result: Optional[dict[str, Any]] = None + if ( + linked_signal_id is not None + and resolved_project_id is not None + and action_taken in {"signal_created", "signal_deduped"} + and SignalSourceOrchestrationService.looks_like_signal_source_request(effective_question or question) + ): + try: + source_orchestration_result = await SignalSourceOrchestrationService(self.db).orchestrate_from_signal( + tenant_id=self.tenant_id, + project_id=int(resolved_project_id), + signal_id=int(linked_signal_id), + initiating_text=effective_question or question, + requested_via="pmflow", + operator_user_id=asked_by, + dry_run=False, + ) + except Exception as exc: + source_orchestration_result = { + "status": "failed", + "reason": str(exc), + } + orchestration_status = str(source_orchestration_result.get("status") or "").lower() + if orchestration_status in {"completed", "partial", "planned", "pending_manual"}: + source_payload = source_orchestration_result.get("signal_source") if isinstance(source_orchestration_result.get("signal_source"), dict) else {} + worker_executions = ( + source_orchestration_result.get("worker_executions") + if isinstance(source_orchestration_result.get("worker_executions"), list) + else [] + ) + if orchestration_status == "pending_manual": + reply_text = ( + f"{reply_text}" + f" 已记录信号源“{str(source_payload.get('source_name') or '未命名信号源')}”的接入请求," + "但当前仍缺少可直接执行的接入配置,已转待确认。" + ) + else: + reply_text = ( + f"{reply_text}" + f" 已登记信号源“{str(source_payload.get('source_name') or '未命名信号源')}”," + f"并已下发 {len(worker_executions)} 个 AI worker 执行。" + ) + answer_text = reply_text + sources = [ + { + "source": "pmflow", + "label": "Signal Queue", + "score": 0.98 if action_taken != "signal_failed" else 0.42, + "metadata": { + "action_taken": action_taken, + "signal_id": linked_signal_id, + "project_id": resolved_project_id, + "action_mode": action_mode, + "processing_stage": "ack_only", + "ack_latency_ms": ack_latency_ms, + "trace_id": trace_id, + "source_orchestration": source_orchestration_result, + }, + } + ] + + qa_id: Optional[int] = None + try: + qa = QARecord( + tenant_id=self.tenant_id, + question=question, + answer=answer_text, + sources=sources, + intent="statement", + domains_used=["signal_write"], + response_time_ms=ack_latency_ms, + asked_by=asked_by, + asked_via=(asked_via or "pmflow")[:20], + project_id=resolved_project_id if resolved_project_id is not None else project_id, + session_id=session_id, + turn_index=int(turn_index or 1), + ) + self.db.add(qa) + await self._update_session_state_after_turn( + session_state=state_row, + question=question, + effective_question=effective_question, + answer=answer_text, + query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_STATUS, + ambiguity="clear" if action_taken != "signal_failed" else "low_evidence", + rewrite_meta=rewrite_meta, + sources=sources, + ) + await self.db.commit() + await self.db.refresh(qa) + qa_id = int(qa.id) + except Exception: + await self.db.rollback() + + try: + await self.context_cache.append_session_messages( + tenant_id=self.tenant_id, + session_id=context_session_id, + question=question, + answer=answer_text, + ) + except Exception: + pass + + confidence = 0.94 if action_taken in {"signal_created", "signal_deduped"} else 0.35 + missing_fields = [] if resolved_project_id is not None else ["project_id"] + return self._build_response_payload( + answer=answer_text, + sources=sources, + domains_used=["signal_write"], + response_time_ms=ack_latency_ms, + db_query_ms=0, + vector_search_ms=0, + db_timeout=False, + vector_timeout=False, + qa_id=qa_id, + session_id=int(session_id) if session_id is not None else None, + turn_index=int(turn_index or 1), + relevance_max=0.99 if action_taken != "signal_failed" else 0.30, + query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_STATUS, + confidence=confidence, + missing_fields=missing_fields, + thinking_mode=THINKING_MODE_NORMAL, + action_taken=action_taken, + signal_id=linked_signal_id, + reply_required=True, + reply_text=reply_text, + ack_latency_ms=ack_latency_ms, + trace_id=trace_id, + processing_stage="ack_only", + ) + diff --git a/backend/app/services/adaptive_rag_service/mixin_retrieval_graph.py b/backend/app/services/adaptive_rag_service/mixin_retrieval_graph.py new file mode 100644 index 00000000..0e7a690a --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_retrieval_graph.py @@ -0,0 +1,217 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGRetrievalGraphMixin: + async def _retrieve_graph_context( + self, + question: str, + *, + project_id: Optional[int], + resolved_entities: Optional[dict[str, Any]] = None, + ) -> list[RetrievedItem]: + try: + resolution = resolved_entities or {} + matched_entities = ( + resolution.get("matched_entities") if isinstance(resolution.get("matched_entities"), list) else [] + ) + anchor_ids_raw = ( + resolution.get("anchor_entity_ids") if isinstance(resolution.get("anchor_entity_ids"), list) else [] + ) + anchor_ids: list[int] = [] + for value in anchor_ids_raw: + try: + anchor_ids.append(int(value)) + except Exception: + continue + + seed_entities: list[KGEntity] = [] + if matched_entities: + ids: list[int] = [] + for row in matched_entities: + if not isinstance(row, dict): + continue + try: + ids.append(int(row.get("entity_id"))) + except Exception: + continue + if ids: + seed_entities = ( + await self.db.execute( + select(KGEntity).where( + and_( + KGEntity.tenant_id == self.tenant_id, + KGEntity.id.in_(ids), + ) + ) + ) + ).scalars().all() + + if not seed_entities: + terms = self._extract_query_terms(question, preferred=self._extract_project_lookup_keyword(question)) + if terms: + conds = [] + for term in terms[:6]: + like = f"%{term}%" + conds.append(KGEntity.display_name.ilike(like)) + conds.append(KGEntity.entity_key.ilike(like)) + seed_entities = ( + await self.db.execute( + select(KGEntity) + .where( + and_( + KGEntity.tenant_id == self.tenant_id, + or_(*conds), + ) + ) + .order_by(KGEntity.last_seen_at.desc(), KGEntity.id.desc()) + .limit(15) + ) + ).scalars().all() + if not seed_entities and anchor_ids: + seed_entities = ( + await self.db.execute( + select(KGEntity).where( + and_( + KGEntity.tenant_id == self.tenant_id, + KGEntity.id.in_(anchor_ids[:10]), + ) + ) + ) + ).scalars().all() + if not seed_entities: + return [] + + seed_ids = [int(x.id) for x in seed_entities] + relation_rows = ( + await self.db.execute( + select(KGRelation) + .where( + and_( + KGRelation.tenant_id == self.tenant_id, + or_( + KGRelation.subject_entity_id.in_(seed_ids), + KGRelation.object_entity_id.in_(seed_ids), + ), + ) + ) + .order_by(KGRelation.weight.desc(), KGRelation.id.desc()) + .limit(120) + ) + ).scalars().all() + + entity_ids: set[int] = set(seed_ids) + for rel in relation_rows: + entity_ids.add(int(rel.subject_entity_id)) + entity_ids.add(int(rel.object_entity_id)) + entities = ( + await self.db.execute( + select(KGEntity).where( + and_( + KGEntity.tenant_id == self.tenant_id, + KGEntity.id.in_(list(entity_ids)), + ) + ) + ) + ).scalars().all() + entity_map = {int(row.id): row for row in entities} + + relation_ids = [int(row.id) for row in relation_rows] + evidence_rows: list[KGEvidence] = [] + if relation_ids or entity_ids: + evidence_rows = ( + await self.db.execute( + select(KGEvidence) + .where( + and_( + KGEvidence.tenant_id == self.tenant_id, + or_( + KGEvidence.relation_id.in_(relation_ids or [-1]), + KGEvidence.entity_id.in_(list(entity_ids) or [-1]), + ), + ) + ) + .order_by(KGEvidence.occurred_at.desc(), KGEvidence.id.desc()) + .limit(220) + ) + ).scalars().all() + evidence_by_relation: dict[int, str] = {} + evidence_by_entity: dict[int, str] = {} + for ev in evidence_rows: + excerpt = str(ev.content_excerpt or "").strip() + if not excerpt: + continue + if ev.relation_id is not None and int(ev.relation_id) not in evidence_by_relation: + evidence_by_relation[int(ev.relation_id)] = excerpt[:320] + if ev.entity_id is not None and int(ev.entity_id) not in evidence_by_entity: + evidence_by_entity[int(ev.entity_id)] = excerpt[:320] + + out: list[RetrievedItem] = [] + for rel in relation_rows: + subject = entity_map.get(int(rel.subject_entity_id)) + target = entity_map.get(int(rel.object_entity_id)) + if subject is None or target is None: + continue + subject_payload = self._safe_dict(subject.payload) + target_payload = self._safe_dict(target.payload) + if project_id is not None: + subject_pid = subject_payload.get("project_id") + target_pid = target_payload.get("project_id") + if subject_pid is not None and int(subject_pid) != int(project_id): + if target_pid is not None and int(target_pid) != int(project_id): + continue + evidence_text = evidence_by_relation.get(int(rel.id)) + if not evidence_text: + evidence_text = evidence_by_entity.get(int(subject.id)) or evidence_by_entity.get(int(target.id)) or "" + content = ( + f"图路径: {str(subject.display_name or subject.entity_key)} --[{str(rel.relation_type)}]--> " + f"{str(target.display_name or target.entity_key)}\n" + f"证据: {evidence_text or '无显式证据摘录'}" + ) + weight = self._to_float(rel.weight, 1.0) + score = min(0.99, max(0.45, 0.62 + min(weight, 1.5) * 0.18)) + out.append( + RetrievedItem( + source="graph", + content=content[:1200], + score=score, + source_label="Knowledge Graph", + metadata={ + "relation_id": int(rel.id), + "relation_type": str(rel.relation_type or ""), + "subject_entity_id": int(subject.id), + "object_entity_id": int(target.id), + "subject_name": str(subject.display_name or subject.entity_key), + "object_name": str(target.display_name or target.entity_key), + "path": f"{subject.id}->{str(rel.relation_type)}->{target.id}", + }, + ) + ) + + if not out: + for seed in seed_entities[:DEFAULT_TOP_K]: + payload = self._safe_dict(seed.payload) + if project_id is not None and payload.get("project_id") is not None: + if int(payload.get("project_id")) != int(project_id): + continue + excerpt = evidence_by_entity.get(int(seed.id)) or "" + out.append( + RetrievedItem( + source="graph", + content=f"实体: {str(seed.display_name or seed.entity_key)}\n证据: {excerpt or '无显式证据摘录'}", + score=0.58, + source_label="Knowledge Graph", + metadata={ + "entity_id": int(seed.id), + "entity_type": str(seed.entity_type or ""), + "entity_key": str(seed.entity_key or ""), + }, + ) + ) + out.sort(key=lambda x: x.score, reverse=True) + return out[: max(DEFAULT_TOP_K, 6)] + except Exception: + return [] + diff --git a/backend/app/services/adaptive_rag_service/mixin_retrieval_router_db.py b/backend/app/services/adaptive_rag_service/mixin_retrieval_router_db.py new file mode 100644 index 00000000..fdf5b3ae --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_retrieval_router_db.py @@ -0,0 +1,210 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGRetrievalRouterDBMixin: + def _route_domains(self, question: str) -> list[str]: + text = question.lower() + domains: list[str] = [] + is_referential = any(k in text for k in FOLLOWUP_REFERENTIAL_KEYWORDS) + + if self._is_people_query(text): + domains.append("db") + if any(k in text for k in PROJECT_KEYWORDS): + domains.append("db") + domains.append("graph") + if is_referential: + domains.append("graph") + if any(k in text for k in CODE_KEYWORDS): + domains.append("graph") + domains.append("doc") + if any(k in text for k in FEATURE_QUERY_KEYWORDS): + domains.append("doc") + domains.append("graph") + domains.append("pmflow") + if any(k in text for k in DOC_KEYWORDS): + domains.append("doc") + if any(k in text for k in HISTORY_KEYWORDS): + domains.append("feishu") + if any(k in text for k in PMFLOW_KEYWORDS): + domains.append("pmflow") + if self._is_engine_process_query(text): + domains.append("graph") + domains.append("doc") + domains.append("pmflow") + domains.append("db") + if URL_RE.search(text) or "根据" in text and "http" in text: + domains.append("url") + + if not domains: + # 模糊问题:并行检索所有向量域 + domains.extend(["graph", "doc", "feishu", "pmflow", "url"]) + + # 去重并保持顺序 + uniq: list[str] = [] + for item in domains: + if item not in uniq: + uniq.append(item) + return uniq + + async def _retrieve_structured_db(self, question: str, *, project_id: Optional[int]) -> list[RetrievedItem]: + text = question.lower() + rows: list[RetrievedItem] = [] + ask_people = self._is_people_query(text) + + if ask_people: + scoped_project_id: Optional[int] = None + scoped_project_name: Optional[str] = None + if "项目" in text or project_id is not None: + scoped_project_id, scoped_project_name, _ = await self._resolve_project_scope_for_question( + question=text, + project_id=project_id, + ) + user_stmt = select(User).where( + and_( + User.tenant_id == self.tenant_id, + User.is_active.is_(True), + ) + ) + if scoped_project_id is not None: + member_subq = select(UserProject.user_id).where( + and_( + UserProject.tenant_id == self.tenant_id, + UserProject.project_id == int(scoped_project_id), + ) + ) + user_stmt = user_stmt.where(User.id.in_(member_subq)) + members = ( + await self.db.execute( + user_stmt.order_by(User.updated_at.desc(), User.id.desc()).limit(20) + ) + ).scalars().all() + if members: + preview = ";".join( + f"#{int(row.id)} {str(row.name or f'User#{int(row.id)}')}(角色:{str(row.role or 'unknown')})" + for row in members[:10] + ) + rows.append( + RetrievedItem( + source="db", + content=( + f"成员清单({len(members)}人):{preview}" + if scoped_project_name is None + else f"项目「{scoped_project_name}」成员清单({len(members)}人):{preview}" + ), + score=0.95, + source_label="User database", + metadata={ + "table": "users", + "count": len(members), + "project_id": scoped_project_id, + "project_name": scoped_project_name, + }, + ) + ) + + if "项目" in text and not ask_people: + stmt = select(Project).where(Project.tenant_id == self.tenant_id) + if project_id: + stmt = stmt.where(Project.id == project_id) + projects = (await self.db.execute(stmt.limit(8))).scalars().all() + if projects: + summary = ";".join( + f"#{p.id} {p.title or p.name}(状态:{p.status})" for p in projects + ) + rows.append( + RetrievedItem( + source="db", + content=f"项目列表:{summary}", + score=0.93, + source_label="Project database", + metadata={"table": "projects", "count": len(projects)}, + ) + ) + + if "信号" in text or "待处理" in text: + signal_stmt = select(Signal).where(Signal.tenant_id == self.tenant_id) + if project_id: + signal_stmt = signal_stmt.where( + or_( + Signal.matched_project_id == project_id, + Signal.content.ilike(f"%#{project_id}%"), + ) + ) + pending = ( + await self.db.execute(signal_stmt.order_by(Signal.created_at.desc()).limit(10)) + ).scalars().all() + if pending: + preview = ";".join((s.summary or s.content or "")[:80] for s in pending[:5]) + rows.append( + RetrievedItem( + source="db", + content=f"最近信号({len(pending)}条):{preview}", + score=0.9, + source_label="Project database", + metadata={"table": "signals", "count": len(pending)}, + ) + ) + + if "任务" in text or "进度" in text: + task_stmt = select(Task).where(Task.tenant_id == self.tenant_id) + if project_id: + task_stmt = task_stmt.where(Task.project_id == project_id) + tasks = (await self.db.execute(task_stmt.order_by(Task.updated_at.desc()).limit(12))).scalars().all() + if tasks: + open_tasks = [t for t in tasks if str(t.status or "").lower() not in {"done", "completed", "已完成"}] + owners = [str(t.assignee_id) for t in open_tasks if t.assignee_id is not None] + rows.append( + RetrievedItem( + source="db", + content=( + f"任务总数 {len(tasks)},未完成 {len(open_tasks)}。" + f"当前负责人ID(去重): {', '.join(sorted(set(owners))[:8]) or '暂无'}" + ), + score=0.91, + source_label="Project database", + metadata={"table": "tasks", "open_count": len(open_tasks)}, + ) + ) + + if "宪章" in text or "charter" in text: + charter_stmt = select(TruthNode).where(TruthNode.tenant_id == self.tenant_id) + if project_id: + charter_stmt = charter_stmt.where(TruthNode.project_id == project_id) + charters = (await self.db.execute(charter_stmt.order_by(TruthNode.updated_at.desc()).limit(6))).scalars().all() + if charters: + rows.append( + RetrievedItem( + source="db", + content="\n".join( + f"宪章#{c.id}: {(c.title or '无标题')} / {str(c.content or '')[:200]}" for c in charters + ), + score=0.92, + source_label="Project database", + metadata={"table": "truth_nodes", "count": len(charters)}, + ) + ) + + if "工作量" in text or "负载" in text: + workload_stmt = select(UserWorkload).where(UserWorkload.tenant_id == self.tenant_id) + workloads = (await self.db.execute(workload_stmt.order_by(UserWorkload.updated_at.desc()).limit(10))).scalars().all() + if workloads: + content = ";".join( + f"user#{w.user_id} 已分配 {float(w.current_assigned_hours or 0):.1f}h / " + f"最大 {float(w.max_available_hours_per_week or 0):.1f}h,负载比 {float(w.workload_ratio or 0):.2f}" + for w in workloads + ) + rows.append( + RetrievedItem( + source="db", + content=f"团队工作量:{content}", + score=0.88, + source_label="Project database", + metadata={"table": "user_workloads", "count": len(workloads)}, + ) + ) + + return rows[:DEFAULT_TOP_K] + diff --git a/backend/app/services/adaptive_rag_service/mixin_retrieval_vector.py b/backend/app/services/adaptive_rag_service/mixin_retrieval_vector.py new file mode 100644 index 00000000..9533239c --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_retrieval_vector.py @@ -0,0 +1,221 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGRetrievalVectorMixin: + async def _retrieve_vector_context( + self, + question: str, + *, + domains: list[str], + project_id: Optional[int], + source_chat_id: Optional[str], + top_k: Optional[int] = None, + ) -> list[RetrievedItem]: + question_embedding = await self.llm.get_embedding_async( + question, + db=self.db, + tenant_id=self.tenant_id, + ) + if not question_embedding: + return [] + + source_types = self._map_domains_to_source_types(domains) + if not source_types: + return [] + limit = max(1, min(int(top_k or DEFAULT_TOP_K), 40)) + if self._is_postgresql_session(): + pg_rows = await self._retrieve_vector_context_pg( + question_embedding=question_embedding, + source_types=source_types, + project_id=project_id, + source_chat_id=source_chat_id, + limit=limit, + ) + if pg_rows is not None: + return pg_rows + + return await self._retrieve_vector_context_legacy( + question_embedding=question_embedding, + source_types=source_types, + project_id=project_id, + source_chat_id=source_chat_id, + limit=limit, + ) + + def _is_postgresql_session(self) -> bool: + try: + bind = self.db.get_bind() + except Exception: + return False + dialect_name = str(getattr(getattr(bind, "dialect", None), "name", "")).lower() + return dialect_name.startswith("postgres") + + @staticmethod + def _vector_literal(values: list[float]) -> str: + normalized: list[str] = [] + for raw in values: + try: + normalized.append(f"{float(raw):.8f}") + except Exception: + normalized.append("0.0") + return "[" + ",".join(normalized) + "]" + + async def _retrieve_vector_context_pg( + self, + *, + question_embedding: list[float], + source_types: list[str], + project_id: Optional[int], + source_chat_id: Optional[str], + limit: int, + ) -> Optional[list[RetrievedItem]]: + query_vec = self._vector_literal(question_embedding) + scoped_project_id = int(project_id) if project_id is not None else 0 + scoped_source_chat_id = str(source_chat_id or "").strip() + stmt = text( + """ + SELECT + id, + source_type, + content, + project_id, + "metadata" AS chunk_metadata, + 1 - (embedding <=> CAST(:query_vec AS vector)) AS similarity + FROM knowledge_base_chunks + WHERE tenant_id = :tenant_id + AND source_type IN :source_types + AND embedding IS NOT NULL + AND ( + :project_scope = FALSE + OR (source_type IN ('feishu', 'pmflow') AND project_id = :project_id) + OR (source_type NOT IN ('feishu', 'pmflow') AND (project_id = :project_id OR project_id IS NULL)) + ) + AND ( + :source_chat_id = '' + OR source_type <> 'feishu' + OR COALESCE("metadata"->>'chat_id', '') = '' + OR "metadata"->>'chat_id' = :source_chat_id + ) + ORDER BY embedding <=> CAST(:query_vec AS vector) + LIMIT :limit + """ + ).bindparams(bindparam("source_types", expanding=True)) + try: + rows = ( + await self.db.execute( + stmt, + { + "tenant_id": self.tenant_id, + "source_types": list(source_types), + "project_scope": project_id is not None, + "project_id": scoped_project_id, + "source_chat_id": scoped_source_chat_id, + "query_vec": query_vec, + "limit": int(limit), + }, + ) + ).mappings().all() + except Exception: + return None + + scored: list[RetrievedItem] = [] + for row in rows: + source_type = str(row.get("source_type") or "doc") + meta = self._safe_dict(row.get("chunk_metadata")).copy() + chunk_project_id_raw = row.get("project_id") + chunk_project_id = int(chunk_project_id_raw) if chunk_project_id_raw is not None else None + if chunk_project_id is not None: + meta.setdefault("project_id", chunk_project_id) + + if source_type == "pmflow": + target_url = str(meta.get("target_url") or "").strip() + if not target_url: + target_url = self._build_pmflow_target_url( + record_type=str(meta.get("record_type") or ""), + record_id=meta.get("record_id"), + project_id=meta.get("project_id"), + ) + if target_url: + meta["target_url"] = target_url + + score = self._to_float(row.get("similarity"), 0.0) + if score <= 0: + continue + label = self._source_label(source_type, meta) + scored.append( + RetrievedItem( + source=source_type, + content=str(row.get("content") or "")[:1200], + score=score, + source_label=label, + metadata=meta, + ) + ) + scored.sort(key=lambda x: x.score, reverse=True) + return scored[:limit] + + async def _retrieve_vector_context_legacy( + self, + *, + question_embedding: list[float], + source_types: list[str], + project_id: Optional[int], + source_chat_id: Optional[str], + limit: int, + ) -> list[RetrievedItem]: + stmt = select(KnowledgeBaseChunk).where( + and_( + KnowledgeBaseChunk.tenant_id == self.tenant_id, + KnowledgeBaseChunk.source_type.in_(source_types), + ) + ) + + scan_limit = max(120, min(1200, limit * 20)) + chunks = (await self.db.execute(stmt.limit(scan_limit))).scalars().all() + scored: list[RetrievedItem] = [] + for chunk in chunks: + meta = self._safe_dict(chunk.chunk_metadata).copy() + chunk_project_id = int(chunk.project_id) if chunk.project_id is not None else None + if chunk_project_id is not None: + meta.setdefault("project_id", chunk_project_id) + + if project_id is not None: + if chunk.source_type in {"feishu", "pmflow"}: + if chunk_project_id != int(project_id): + continue + elif chunk_project_id is not None and chunk_project_id != int(project_id): + continue + + if chunk.source_type == "feishu" and source_chat_id: + chat_id = str(meta.get("chat_id") or "").strip() + if chat_id and chat_id != str(source_chat_id): + continue + if chunk.source_type == "pmflow": + target_url = str(meta.get("target_url") or "").strip() + if not target_url: + target_url = self._build_pmflow_target_url( + record_type=str(meta.get("record_type") or ""), + record_id=meta.get("record_id"), + project_id=meta.get("project_id"), + ) + if target_url: + meta["target_url"] = target_url + score = self._cosine_similarity(question_embedding, self._embedding_to_list(chunk.embedding)) + if score <= 0: + continue + label = self._source_label(chunk.source_type, meta) + scored.append( + RetrievedItem( + source=str(chunk.source_type or "doc"), + content=str(chunk.content or "")[:1200], + score=score, + source_label=label, + metadata=meta, + ) + ) + scored.sort(key=lambda x: x.score, reverse=True) + return scored[:limit] + diff --git a/backend/app/services/adaptive_rag_service/mixin_session_state.py b/backend/app/services/adaptive_rag_service/mixin_session_state.py new file mode 100644 index 00000000..bbe1d30d --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_session_state.py @@ -0,0 +1,225 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGSessionStateMixin: + async def _ensure_session_state(self, *, session_id: Optional[int]) -> Optional[QASessionState]: + if session_id is None: + return None + row = ( + await self.db.execute( + select(QASessionState).where( + and_( + QASessionState.tenant_id == self.tenant_id, + QASessionState.session_id == int(session_id), + ) + ) + ) + ).scalar_one_or_none() + if row is None: + row = QASessionState( + tenant_id=self.tenant_id, + session_id=int(session_id), + last_subject=None, + last_entity_ids=[], + last_intent=None, + ambiguity="unknown", + rewrite_history=[], + ) + self.db.add(row) + await self.db.flush() + return row + + def _rewrite_question_with_state( + self, + *, + question: str, + conversation: list[dict[str, str]], + session_state: Optional[QASessionState], + ) -> tuple[str, dict[str, Any]]: + expanded = self._expand_followup_question(question, conversation) + current = str(expanded or question or "").strip() + if not current: + return current, {"mode": "empty"} + + lower = current.lower() + has_referential = any(k in lower for k in FOLLOWUP_REFERENTIAL_KEYWORDS) + if not has_referential: + return current, {"mode": "direct"} + + state_subject = "" + state_entity_ids: list[int] = [] + if session_state is not None: + state_subject = str(session_state.last_subject or "").strip() + if isinstance(session_state.last_entity_ids, list): + for value in session_state.last_entity_ids: + try: + state_entity_ids.append(int(value)) + except Exception: + continue + if state_subject: + rewritten = f"围绕“{state_subject}”回答:{current}" + return rewritten[:1200], {"mode": "state_subject", "subject": state_subject} + if state_entity_ids: + anchor = ",".join(str(x) for x in state_entity_ids[:6]) + rewritten = f"围绕会话实体ID[{anchor}]回答:{current}" + return rewritten[:1200], {"mode": "state_entities", "entity_ids": state_entity_ids[:6]} + return current, {"mode": "referential_without_subject"} + + def _build_clarification_question( + self, + *, + original_question: str, + effective_question: str, + session_state: Optional[QASessionState], + ) -> Optional[str]: + text = str(original_question or "").strip().lower() + if not text: + return None + if self._is_correction_query(text): + if self._has_explicit_target_scope(text): + return None + return ( + "收到,你在纠正上一条回答。请直接说你要的目标范围,例如:" + "`我问的是 AI 引擎驱动方式,第一层是否调用 openclaw?`" + ) + has_referential = any(k in text for k in FOLLOWUP_REFERENTIAL_KEYWORDS) + if not has_referential: + return None + state_subject = str(getattr(session_state, "last_subject", "") or "").strip() + state_entity_ids = getattr(session_state, "last_entity_ids", None) + has_state_entities = isinstance(state_entity_ids, list) and any(str(x).strip() for x in state_entity_ids) + if state_subject or has_state_entities: + return None + if str(effective_question or "").strip() != text and "补充问题" in str(effective_question): + return None + return "你这句缺少明确主语。请先指明对象,例如:`分别是哪些项目?` 或 `请列出这些项目的名称和ID`。" + + async def _update_session_state_after_turn( + self, + *, + session_state: Optional[QASessionState], + question: str, + effective_question: str, + answer: str, + query_type: str, + ambiguity: str, + rewrite_meta: dict[str, Any], + sources: list[dict[str, Any]], + ) -> None: + if session_state is None: + return + subject = self._extract_state_subject(question=question, answer=answer, sources=sources) + if subject: + session_state.last_subject = subject[:255] + session_state.last_intent = "question" + session_state.ambiguity = str(ambiguity or "clear")[:20] + session_state.last_entity_ids = self._collect_entity_ids_from_sources(sources) + + history = session_state.rewrite_history if isinstance(session_state.rewrite_history, list) else [] + history.append( + { + "at": now_cn_aware().isoformat(), + "question": question[:500], + "effective_question": effective_question[:500], + "query_type": str(query_type or QUERY_TYPE_UNKNOWN), + "ambiguity": str(ambiguity or "clear"), + "rewrite_meta": rewrite_meta if isinstance(rewrite_meta, dict) else {}, + } + ) + session_state.rewrite_history = history[-20:] + await self.db.flush() + + def _collect_entity_ids_from_sources(self, sources: list[dict[str, Any]]) -> list[int]: + ids: list[int] = [] + for source in sources: + if not isinstance(source, dict): + continue + meta = source.get("metadata") if isinstance(source.get("metadata"), dict) else {} + for key in ( + "project_id", + "task_id", + "signal_id", + "user_id", + "entity_id", + "subject_entity_id", + "object_entity_id", + ): + value = meta.get(key) + try: + if value is not None: + ids.append(int(value)) + except Exception: + continue + matched = meta.get("matched_project_ids") + if isinstance(matched, list): + for value in matched: + try: + ids.append(int(value)) + except Exception: + continue + uniq: list[int] = [] + for value in ids: + if value not in uniq: + uniq.append(value) + return uniq[:20] + + def _extract_state_subject( + self, + *, + question: str, + answer: str, + sources: list[dict[str, Any]], + ) -> str: + for source in sources: + if not isinstance(source, dict): + continue + meta = source.get("metadata") if isinstance(source.get("metadata"), dict) else {} + project_keyword = str(meta.get("query_keyword") or "").strip() + if project_keyword: + return project_keyword[:255] + subject_name = str(meta.get("subject_name") or "").strip() + object_name = str(meta.get("object_name") or "").strip() + if subject_name and object_name: + return f"{subject_name}->{object_name}"[:255] + + text = str(question or "").strip() + keyword = self._extract_project_lookup_keyword(text) + if len(keyword) >= 2: + return keyword[:255] + + answer_text = str(answer or "").strip() + m = re.search(r"项目[「\"]?([^」\"\n]{2,40})", answer_text) + if m: + return str(m.group(1)).strip()[:255] + return "" + + async def get_session_state_replay(self, *, session_id: int, user_id: int) -> dict[str, Any]: + session = ( + await self.db.execute( + select(QASession).where( + and_( + QASession.tenant_id == self.tenant_id, + QASession.id == int(session_id), + QASession.user_id == int(user_id), + ) + ) + ) + ).scalar_one_or_none() + if session is None: + raise ValueError("qa session 不存在") + + state = await self._ensure_session_state(session_id=int(session_id)) + history = state.rewrite_history if isinstance(state.rewrite_history, list) else [] + return { + "session_id": int(session_id), + "last_subject": str(state.last_subject or "") if state else "", + "last_entity_ids": state.last_entity_ids if state else [], + "last_intent": str(state.last_intent or "") if state else "", + "ambiguity": str(state.ambiguity or "") if state else "", + "rewrite_history": history[-20:], + "updated_at": state.updated_at.isoformat() if state and state.updated_at else None, + } + diff --git a/backend/app/services/adaptive_rag_service/mixin_symbol_snapshot.py b/backend/app/services/adaptive_rag_service/mixin_symbol_snapshot.py new file mode 100644 index 00000000..d699cd11 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_symbol_snapshot.py @@ -0,0 +1,124 @@ +"""Adaptive RAG mixin module.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGSymbolSnapshotMixin: + def _symbol_cache_key(self) -> str: + return f"{self.tenant_id}:backend_symbol_snapshot" + + async def _get_symbol_snapshot_cached(self, *, ttl_seconds: int = 300) -> dict[str, Any]: + now = time.time() + key = self._symbol_cache_key() + cached = self._symbol_snapshot_cache.get(key) + if cached and cached[0] > now: + return dict(cached[1]) + try: + service = SymbolIndexService(self.db, tenant_id=self.tenant_id) + built = await service.get_overview(top_n_files=10, auto_sync_if_empty=True) + except Exception: + built = self._build_symbol_index_snapshot() + self._symbol_snapshot_cache[key] = (now + max(60, int(ttl_seconds)), dict(built)) + return built + + def _build_symbol_index_snapshot( + self, + *, + max_files: int = 240, + max_symbols: int = 4000, + ) -> dict[str, Any]: + backend_root = Path(__file__).resolve().parents[2] + app_root = backend_root / "app" + if not app_root.exists(): + return { + "root": str(app_root), + "indexed_files": 0, + "symbol_total": 0, + "python_symbols": 0, + "typescript_symbols": 0, + "top_modules": [], + "generated_at": now_cn_aware().isoformat(), + } + + candidates = [ + p + for p in app_root.rglob("*") + if p.is_file() and p.suffix.lower() in {".py", ".ts", ".tsx", ".js", ".jsx"} + ] + candidates.sort(key=lambda p: str(p)) + file_paths = candidates[: max(1, int(max_files))] + + python_symbols = 0 + ts_symbols = 0 + total_symbols = 0 + module_counter: dict[str, int] = {} + + for path in file_paths: + rel = str(path.relative_to(backend_root)) + module = rel.split("/", 2)[:2] + module_name = "/".join(module) if module else rel + try: + text = path.read_text(encoding="utf-8", errors="ignore") + except Exception: + continue + + symbols_in_file = 0 + if path.suffix.lower() == ".py": + extracted = self._extract_python_symbols(text) + symbols_in_file = len(extracted) + python_symbols += symbols_in_file + else: + extracted = self._extract_text_symbols(text) + symbols_in_file = len(extracted) + ts_symbols += symbols_in_file + if symbols_in_file <= 0: + continue + module_counter[module_name] = int(module_counter.get(module_name, 0)) + symbols_in_file + total_symbols += symbols_in_file + if total_symbols >= max_symbols: + break + + top_modules = [ + {"module": name, "symbols": int(count)} + for name, count in sorted(module_counter.items(), key=lambda item: item[1], reverse=True)[:10] + ] + return { + "root": str(app_root), + "indexed_files": len(file_paths), + "symbol_total": int(total_symbols), + "python_symbols": int(python_symbols), + "typescript_symbols": int(ts_symbols), + "top_modules": top_modules, + "generated_at": now_cn_aware().isoformat(), + } + + @staticmethod + def _extract_python_symbols(text: str) -> list[str]: + raw = str(text or "") + if not raw.strip(): + return [] + try: + tree = ast.parse(raw) + except Exception: + return [] + out: list[str] = [] + for node in tree.body: + if isinstance(node, ast.ClassDef): + out.append(f"class:{node.name}") + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + out.append(f"function:{node.name}") + return out + + @staticmethod + def _extract_text_symbols(text: str) -> list[str]: + raw = str(text or "") + if not raw.strip(): + return [] + out: list[str] = [] + for match in TS_SYMBOL_RE.finditer(raw): + name = str(match.group(1) or "").strip() + if name: + out.append(name) + return out + diff --git a/backend/app/services/adaptive_rag_service/service.py b/backend/app/services/adaptive_rag_service/service.py new file mode 100644 index 00000000..3092a2b5 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/service.py @@ -0,0 +1,83 @@ +"""Adaptive RAG service entry class.""" +from __future__ import annotations + +from .mixin_core_intent import AdaptiveRAGCoreIntentMixin +from .mixin_answer_entry import AdaptiveRAGAnswerEntryMixin +from .mixin_answer_precheck import AdaptiveRAGAnswerPrecheckMixin +from .mixin_answer_retrieval import AdaptiveRAGAnswerRetrievalMixin +from .mixin_answer_payload import AdaptiveRAGAnswerPayloadMixin +from .mixin_requirement_ack_core import AdaptiveRAGRequirementAckCoreMixin +from .mixin_requirement_ack_flow import AdaptiveRAGRequirementAckFlowMixin +from .mixin_deterministic_query_core import AdaptiveRAGDeterministicQueryCoreMixin +from .mixin_deterministic_query_count import AdaptiveRAGDeterministicQueryCountMixin +from .mixin_deterministic_query_list import AdaptiveRAGDeterministicQueryListMixin +from .mixin_session_state import AdaptiveRAGSessionStateMixin +from .mixin_fast_answer_semantic_p1 import AdaptiveRAGFastAnswerSemanticMixinP1 +from .mixin_fast_answer_semantic_p2 import AdaptiveRAGFastAnswerSemanticMixinP2 +from .mixin_fast_answer_project_main import AdaptiveRAGFastAnswerProjectMainMixin +from .mixin_fast_answer_project_utils import AdaptiveRAGFastAnswerProjectUtilsMixin +from .mixin_manifest_core import AdaptiveRAGManifestCoreMixin +from .mixin_manifest_feature_p1 import AdaptiveRAGManifestFeatureMixinP1 +from .mixin_manifest_feature_p2 import AdaptiveRAGManifestFeatureMixinP2 +from .mixin_symbol_snapshot import AdaptiveRAGSymbolSnapshotMixin +from .mixin_multistep_core import AdaptiveRAGMultiStepCoreMixin +from .mixin_multistep_flow import AdaptiveRAGMultiStepFlowMixin +from .mixin_retrieval_router_db import AdaptiveRAGRetrievalRouterDBMixin +from .mixin_retrieval_graph import AdaptiveRAGRetrievalGraphMixin +from .mixin_retrieval_vector import AdaptiveRAGRetrievalVectorMixin +from .mixin_knowledge_docs import AdaptiveRAGKnowledgeDocsMixin +from .mixin_ingest import AdaptiveRAGIngestMixin +from .mixin_intent_correction import AdaptiveRAGIntentCorrectionMixin +from .mixin_qa_session_core import AdaptiveRAGQASessionCoreMixin +from .mixin_qa_session_list_ops_p1 import AdaptiveRAGQASessionListOpsMixinP1 +from .mixin_qa_session_list_ops_p2 import AdaptiveRAGQASessionListOpsMixinP2 +from .mixin_qa_feedback_records import AdaptiveRAGQAFeedbackRecordsMixin +from .mixin_qa_stats_export import AdaptiveRAGQAStatsExportMixin +from .mixin_deep_guard_rules import AdaptiveRAGDeepGuardRulesMixin +from .mixin_deep_guard_answer import AdaptiveRAGDeepGuardAnswerMixin +from .mixin_conversation_utils_a_p1 import AdaptiveRAGConversationUtilsAMixinP1 +from .mixin_conversation_utils_a_p2 import AdaptiveRAGConversationUtilsAMixinP2 +from .mixin_conversation_utils_b import AdaptiveRAGConversationUtilsBMixin + + +class AdaptiveRAGService( + AdaptiveRAGCoreIntentMixin, + AdaptiveRAGAnswerEntryMixin, + AdaptiveRAGAnswerPrecheckMixin, + AdaptiveRAGAnswerRetrievalMixin, + AdaptiveRAGAnswerPayloadMixin, + AdaptiveRAGRequirementAckCoreMixin, + AdaptiveRAGRequirementAckFlowMixin, + AdaptiveRAGDeterministicQueryCoreMixin, + AdaptiveRAGDeterministicQueryCountMixin, + AdaptiveRAGDeterministicQueryListMixin, + AdaptiveRAGSessionStateMixin, + AdaptiveRAGFastAnswerSemanticMixinP1, + AdaptiveRAGFastAnswerSemanticMixinP2, + AdaptiveRAGFastAnswerProjectMainMixin, + AdaptiveRAGFastAnswerProjectUtilsMixin, + AdaptiveRAGManifestCoreMixin, + AdaptiveRAGManifestFeatureMixinP1, + AdaptiveRAGManifestFeatureMixinP2, + AdaptiveRAGSymbolSnapshotMixin, + AdaptiveRAGMultiStepCoreMixin, + AdaptiveRAGMultiStepFlowMixin, + AdaptiveRAGRetrievalRouterDBMixin, + AdaptiveRAGRetrievalGraphMixin, + AdaptiveRAGRetrievalVectorMixin, + AdaptiveRAGKnowledgeDocsMixin, + AdaptiveRAGIngestMixin, + AdaptiveRAGIntentCorrectionMixin, + AdaptiveRAGQASessionCoreMixin, + AdaptiveRAGQASessionListOpsMixinP1, + AdaptiveRAGQASessionListOpsMixinP2, + AdaptiveRAGQAFeedbackRecordsMixin, + AdaptiveRAGQAStatsExportMixin, + AdaptiveRAGDeepGuardRulesMixin, + AdaptiveRAGDeepGuardAnswerMixin, + AdaptiveRAGConversationUtilsAMixinP1, + AdaptiveRAGConversationUtilsAMixinP2, + AdaptiveRAGConversationUtilsBMixin, +): + """项目内统一 RAG 服务。""" + _symbol_snapshot_cache: dict[str, tuple[float, dict[str, Any]]] = {} diff --git a/backend/app/services/adaptive_rag_service/shared.py b/backend/app/services/adaptive_rag_service/shared.py new file mode 100644 index 00000000..99594ea0 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/shared.py @@ -0,0 +1,7 @@ +"""Adaptive RAG shared exports.""" +from __future__ import annotations + +from .shared_imports import * +from .shared_utils import * +from .shared_part_01 import * +from .shared_part_02 import * diff --git a/backend/app/services/adaptive_rag_service/shared_imports.py b/backend/app/services/adaptive_rag_service/shared_imports.py new file mode 100644 index 00000000..90f58275 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/shared_imports.py @@ -0,0 +1,57 @@ +"""全自适应 RAG 服务。 + +能力: +1. 意图识别(question / statement / mixed) +2. 多域路由(结构化 DB + 向量检索) +3. 回答生成与来源引用 +4. QA 记录、反馈、JSONL 导出 +5. 知识库文档/URL/飞书消息入库向量化 +""" + +from __future__ import annotations + +import ast +import asyncio +import hashlib +import json +import math +import os +from pathlib import Path +import re +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Optional + +import httpx +from sqlalchemy import and_, bindparam, delete, func, or_, select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from ..config.ai_prompts import PROMPT_RAG_ANSWER, PROMPT_RAG_DB_PROJECT_QA, PROMPT_RAG_INTENT_CLASSIFICATION +from ..database import AsyncSessionLocal +from ..models import ( + KnowledgeBaseChunk, + KnowledgeBaseDoc, + KGEvidence, + KGEntity, + KGRelation, + Project, + ProjectSignalLink, + QAFeedback, + QARecord, + QASession, + QASessionState, + Signal, + Task, + TruthNode, + User, + UserProject, + UserWorkload, +) +from .context_cache import context_cache_service +from .llm import LLMService +from .signal_source_orchestration_service import SignalSourceOrchestrationService +from .symbol_index_service import SymbolIndexService + + +from ..utils.timezone import CN_TZ, now_cn_aware, now_cn_naive diff --git a/backend/app/services/adaptive_rag_service/shared_part_01.py b/backend/app/services/adaptive_rag_service/shared_part_01.py new file mode 100644 index 00000000..e3d628a3 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/shared_part_01.py @@ -0,0 +1,205 @@ +"""Adaptive RAG shared constants/types part.""" +from __future__ import annotations + +from .shared_imports import * +from .shared_utils import * + +QUESTION_HINTS = ( + "吗", + "么", + "呢", + "呀", + "啊", + "?", + "?", + "如何", + "怎么", + "为什么", + "为何", + "谁", + "哪里", + "有没有", + "有无", + "有吗", + "是否", + "是否有", + "是否存在", + "哪些", + "哪个", + "什么", + "是什么", + "多少", + "几", +) +PROJECT_EXISTENCE_RE = re.compile(r"(有没有|是否有|有).*?(项目|系统)|项目.*?(有没有|是否有|有).*(吗|\?)") +PROJECT_PROGRESS_RE = re.compile(r"(进度|阶段|完成度|完成率|推进|里程碑|状态)") +QUESTION_NOISE_RE = re.compile( + r"(目前|当前|现在|请问|一下|系统中|项目中|系统内|项目内|是否|有没有|有无|有|吗|呢|呀|啊|什么|哪个|多少|查询|看看)" +) +STATEMENT_HINTS = ( + "需要", + "请", + "新增", + "优化", + "修复", + "安排", + "立项", + "开发", + "上线", +) +PROJECT_KEYWORDS = ( + "项目", + "信号", + "任务", + "宪章", + "进度", + "状态", + "工作量", + "负责人", +) +PEOPLE_KEYWORDS = ( + "成员", + "人员", + "同事", + "负责人", + "owner", + "assignee", + "谁负责", + "谁在做", + "名单", +) +DOC_KEYWORDS = ( + "文档", + "规范", + "spec", + "说明", + "流程", + "模板", + "手册", + "how to", + "怎么做", +) +FEATURE_QUERY_KEYWORDS = ( + "功能", + "用途", + "是干嘛", + "做什么", + "有什么用", + "页面", + "菜单", + "入口", + "在哪", + "ai配置", + "智能配置", + "配置中心", + "能力管理", + "用户管理", + "项目列表", + "团队负载", + "ai管家", +) +FEATURE_PURPOSE_HINTS = ( + "用途", + "作用", + "干嘛", + "做什么", + "是什么", + "入口", + "在哪", + "页面", +) +CORRECTION_CUE_KEYWORDS = ( + "我说的是", + "我说的不是", + "不是这个", + "不是系统架构", + "不是啊", + "不对", + "错了", + "答错", + "跑偏", + "偏了", + "不是这个意思", + "你理解错", + "你答偏了", + "答非所问", + "请纠正", + "纠正一下", + "请更正", + "上一条", + "上一个回答", + "不要模板", + "模板化", + "我要的是", + "我问的是", + "不是在问", +) +ENGINE_PROCESS_KEYWORDS = ( + "ai引擎", + "引擎驱动", + "驱动方式", + "调用链", + "调用流程", + "调用路径", + "模型路由", + "模型调用", + "路由策略", + "openclaw", + "网关", + "ai架构", + "ai 管家", + "ai管家", + "第一层", + "第二层", + "第三层", + "fallback", + "兜底", +) +DEEP_EXPLICIT_KEYWORDS = ( + "深入", + "深度", + "详细分析", + "证据链", + "调用链", + "逐层", + "逐步", + "详细展开", + "deep", +) +BRIEF_NEGATIVE_FEEDBACK_RE = re.compile( + r"^(不是啊?|不对|错了|答错了?|跑偏了?|偏了|不是这个(意思)?|不(是|对)啊)$" +) +HISTORY_KEYWORDS = ("之前", "上次", "历史", "曾经", "之前说", "last time") +PMFLOW_KEYWORDS = ("复盘", "charter", "宪章", "task", "任务描述") +FOLLOWUP_REFERENTIAL_KEYWORDS = ( + "这些", + "它们", + "这个", + "那个", + "上面", + "前面", + "刚才", + "分别", + "具体", + "都有哪些", + "分别是什么", +) +FOLLOWUP_ENUMERATION_KEYWORDS = ( + "分别是什么", + "分别是啥", + "都有哪些", + "具体有哪些", + "列出来", + "清单", + "名单", +) +IDENTITY_KEYWORDS = ( + "你是谁", + "你是", + "自我介绍", + "介绍你自己", + "你能做什么", + "你会什么", + "你负责什么", + "你叫什么", +) diff --git a/backend/app/services/adaptive_rag_service/shared_part_02.py b/backend/app/services/adaptive_rag_service/shared_part_02.py new file mode 100644 index 00000000..9a238887 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/shared_part_02.py @@ -0,0 +1,146 @@ +"""Adaptive RAG shared constants/types part.""" +from __future__ import annotations + +from .shared_imports import * +from .shared_utils import * + +ARCHITECTURE_KEYWORDS = ( + "系统架构", + "架构", + "运行机制", + "技术栈", + "模块", + "服务组成", + "docker", + "数据库", + "redis", + "后端", + "前端", +) +CODE_KEYWORDS = ( + "代码", + "函数", + "方法", + "类", + "模块", + "文件", + "api", + "接口实现", + "service", + "model", + "schema", + "where defined", +) +URL_RE = re.compile(r"https?://\S+", flags=re.IGNORECASE) +SENTENCE_SPLIT_RE = re.compile(r"(?<=[。!?!?\n])") +PAGE_MARKER_RE = re.compile(r"\[\[PAGE:(\d{1,6})\]\]") +MAX_CONTEXT_TOKENS = 8000 +DEFAULT_TOP_K = 5 +DB_QUERY_TIMEOUT_SECONDS = 5.0 +VECTOR_SEARCH_TIMEOUT_SECONDS = 10.0 +GRAPH_QUERY_TIMEOUT_SECONDS = 4.0 +AGENT_MAX_RETRIEVAL_STEPS = 3 +SESSION_GAP_MINUTES = 30 +SESSION_DEFAULT_TITLE = "新会话" +SESSION_TITLE_MIN_LEN = 5 +THINKING_MODE_NORMAL = "normal" +THINKING_MODE_DEEP = "deep" +DEEP_RETRIEVAL_STEPS = _env_int("RAG_DEEP_RETRIEVAL_STEPS", 5, minimum=2, maximum=8) +DEEP_VECTOR_TOP_K = _env_int("RAG_DEEP_VECTOR_TOP_K", 12, minimum=6, maximum=40) +DEEP_CONTEXT_BLOCKS = _env_int("RAG_DEEP_CONTEXT_BLOCKS", 16, minimum=8, maximum=32) +DEEP_ANSWER_MAX_TOKENS = _env_int("RAG_DEEP_ANSWER_MAX_TOKENS", 1400, minimum=900, maximum=3000) +QUERY_TYPE_COUNT = "count" +QUERY_TYPE_LIST = "list" +QUERY_TYPE_STATUS = "status" +QUERY_TYPE_EXPLAIN = "explain" +QUERY_TYPE_UNKNOWN = "unknown" +ACTION_MODE_AUTO = "auto" +ACTION_MODE_READ = "read" +ACTION_MODE_WRITE = "write" +ACTION_MODE_READ_WRITE = "read_write" +REQUIREMENT_WRITE_VERB_KEYWORDS = ( + "新增", + "增加", + "添加", + "接入", + "支持", + "实现", + "创建", + "新建", + "记录", + "入库", + "同步", + "补充", +) +REQUIREMENT_WRITE_OBJECT_KEYWORDS = ( + "信号", + "需求", + "任务", + "项目", + "功能", + "接口", + "页面", + "按钮", + "规则", + "模块", + "卡路里", + "食谱", +) +WRITE_EXPRESSIONS = ( + "给", + "帮我", + "请帮", + "请给", + "麻烦", +) +COUNT_HINTS = ("多少", "几个", "数量", "总数", "count") +LIST_HINTS = ("哪些", "列表", "清单", "列出", "分别", "名单", "都有哪些") +STATUS_HINTS = ("进度", "状态", "阶段", "完成率", "完成度", "情况") +ENTITY_ALIAS_MAP: dict[str, str] = { + "能力": "skill", + "技能": "skill", + "ability": "skill", + "abilities": "skill", + "skill": "skill", + "skills": "skill", + "负责人": "owner", + "owner": "owner", + "assignee": "owner", + "成员": "member", + "人员": "member", + "member": "member", + "任务": "task", + "task": "task", + "tasks": "task", + "项目": "project", + "project": "project", + "projects": "project", + "信号": "signal", + "signal": "signal", + "signals": "signal", + "函数": "function", + "function": "function", + "method": "function", + "类": "class", + "class": "class", + "模块": "module", + "module": "module", + "智能配置": "ai_config", + "ai配置": "ai_config", + "配置中心": "ai_config", + "能力管理": "skills", + "技能管理": "skills", + "用户管理": "users", + "项目列表": "projects", + "团队负载": "workloads", + "ai管家": "ai_steward", +} +TS_SYMBOL_RE = re.compile( + r"(?:export\s+)?(?:async\s+)?(?:function|class|interface|type|const)\s+([A-Za-z_][A-Za-z0-9_]*)" +) +class RetrievedItem: + source: str + content: str + score: float + source_label: str + metadata: dict[str, Any] diff --git a/backend/app/services/adaptive_rag_service/shared_utils.py b/backend/app/services/adaptive_rag_service/shared_utils.py new file mode 100644 index 00000000..101c4dc8 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/shared_utils.py @@ -0,0 +1,17 @@ +"""Adaptive RAG shared utils.""" +from __future__ import annotations + +from .shared_imports import * + +def _env_int(name: str, default: int, *, minimum: int, maximum: int) -> int: + raw = str(os.getenv(name, "") or "").strip() + if not raw: + value = int(default) + else: + try: + value = int(raw) + except Exception: + value = int(default) + return max(minimum, min(maximum, value)) + + diff --git a/backend/app/services/ai_pmo_cadence/__init__.py b/backend/app/services/ai_pmo_cadence/__init__.py new file mode 100644 index 00000000..044235a1 --- /dev/null +++ b/backend/app/services/ai_pmo_cadence/__init__.py @@ -0,0 +1,5 @@ +"""AI PMO cadence service exports.""" + +from .service import AIPMOCadenceService + +__all__ = ["AIPMOCadenceService"] diff --git a/backend/app/services/ai_pmo_cadence/mixin_builders.py b/backend/app/services/ai_pmo_cadence/mixin_builders.py new file mode 100644 index 00000000..accfcb5d --- /dev/null +++ b/backend/app/services/ai_pmo_cadence/mixin_builders.py @@ -0,0 +1,106 @@ +"""Cadence payload builders for AI PMO cadence service.""" + +from __future__ import annotations + +from .shared import Any, Optional, Project, select, timedelta, utc_now_naive + + +class CadenceBuildersMixin: + """Builds cadence snapshots for daily/weekly/risk/milestone views.""" + + async def _build_daily_standup(self, *, project_id: Optional[int]) -> dict[str, Any]: + now = utc_now_naive() + since = now - timedelta(days=1) + work_items = await self._list_recent_work_items(since=since, project_id=project_id) + blocked_tasks = await self._list_blocked_tasks(project_id=project_id) + recent_signals = await self._list_recent_signals(since=since, project_id=project_id) + pending_manual_count = await self._count_work_items(state="waiting_manual", project_id=project_id) + return { + "summary": { + "cadence_type": "daily_standup", + "recent_work_item_count": len(work_items), + "pending_manual_count": pending_manual_count, + "blocked_task_count": len(blocked_tasks), + "recent_signal_count": len(recent_signals), + }, + "work_items": work_items[:10], + "blocked_tasks": blocked_tasks[:10], + "recent_signals": recent_signals[:10], + "focus_actions": self._build_focus_actions( + pending_manual_count=pending_manual_count, + blocked_task_count=len(blocked_tasks), + ), + } + + async def _build_weekly_review(self, *, project_id: Optional[int]) -> dict[str, Any]: + now = utc_now_naive() + since = now - timedelta(days=7) + weekly_rows = await self._list_recent_work_items(since=since, project_id=project_id) + budgets = await self._list_budget_hotspots(project_id=project_id) + capacity = await self._list_capacity_hotspots(project_id=project_id) + program_view = await self.build_program_view() if project_id is None else None + by_state: dict[str, int] = {} + for item in weekly_rows: + state = str(item.get("state") or "unknown") + by_state[state] = by_state.get(state, 0) + 1 + return { + "summary": { + "cadence_type": "weekly_review", + "work_item_total": len(weekly_rows), + "by_state": by_state, + "budget_hotspot_count": len(budgets), + "capacity_hotspot_count": len(capacity), + }, + "work_items": weekly_rows[:20], + "budget_hotspots": budgets[:10], + "capacity_hotspots": capacity[:10], + "program_view": program_view, + } + + async def _build_risk_review(self, *, project_id: Optional[int]) -> dict[str, Any]: + blocked_tasks = await self._list_blocked_tasks(project_id=project_id) + signal_sources = await self._list_pending_signal_sources(project_id=project_id) + notifications = await self._list_unread_risk_notifications(project_id=project_id) + return { + "summary": { + "cadence_type": "risk_review", + "blocked_task_count": len(blocked_tasks), + "pending_signal_source_count": len(signal_sources), + "unread_risk_notification_count": len(notifications), + }, + "blocked_tasks": blocked_tasks[:20], + "pending_signal_sources": signal_sources[:20], + "risk_notifications": notifications[:20], + } + + async def _build_milestone_check(self, *, project_id: Optional[int]) -> dict[str, Any]: + overdue_tasks = await self._list_overdue_tasks(project_id=project_id) + work_items = await self._count_work_items(state="waiting_manual", project_id=project_id) + stmt = select(Project.id, Project.title, Project.status).where(Project.tenant_id == self.tenant_id) + if project_id is not None: + stmt = stmt.where(Project.id == int(project_id)) + projects = (await self.db.execute(stmt)).all() + return { + "summary": { + "cadence_type": "milestone_check", + "project_count": len(projects), + "overdue_task_count": len(overdue_tasks), + "pending_manual_count": work_items, + }, + "projects": [ + {"project_id": int(pid), "title": str(title or ""), "status": str(status or "")} + for pid, title, status in projects + ], + "overdue_tasks": overdue_tasks[:20], + } + + @staticmethod + def _build_focus_actions(*, pending_manual_count: int, blocked_task_count: int) -> list[str]: + actions: list[str] = [] + if pending_manual_count > 0: + actions.append("优先清理待人工确认队列,避免自动执行链路阻塞。") + if blocked_task_count > 0: + actions.append("优先处理 blocked tasks,降低关键依赖压力。") + if not actions: + actions.append("当前节奏平稳,继续按计划推进。") + return actions diff --git a/backend/app/services/ai_pmo_cadence/mixin_program_view.py b/backend/app/services/ai_pmo_cadence/mixin_program_view.py new file mode 100644 index 00000000..d1ad6347 --- /dev/null +++ b/backend/app/services/ai_pmo_cadence/mixin_program_view.py @@ -0,0 +1,123 @@ +"""Program view aggregation for AI PMO cadence service.""" + +from __future__ import annotations + +from .shared import Any, Budget, Project, ProjectDomain, Task, WorkItem, and_, func, select + + +class CadenceProgramViewMixin: + """Builds cross-project domain program metrics.""" + + async def build_program_view(self) -> dict[str, Any]: + projects = ( + await self.db.execute( + select(Project, ProjectDomain) + .outerjoin( + ProjectDomain, + and_( + ProjectDomain.id == Project.domain_id, + ProjectDomain.tenant_id == self.tenant_id, + ), + ) + .where( + and_( + Project.tenant_id == self.tenant_id, + Project.status.in_(["active", "planning", "doing", "todo"]), + ) + ) + .order_by(Project.id.asc()) + ) + ).all() + + domain_groups: dict[str, dict[str, Any]] = {} + for project, domain in projects: + key = str((domain.slug if domain is not None else "unscoped") or "unscoped") + group = domain_groups.setdefault( + key, + { + "program_key": key, + "program_name": str((domain.name if domain is not None else "未分组项目") or "未分组项目"), + "project_count": 0, + "project_ids": [], + "blocked_task_count": 0, + "pending_manual_count": 0, + "budget_usage_ratio_max": 0.0, + }, + ) + group["project_count"] += 1 + group["project_ids"].append(int(project.id)) + + if not domain_groups: + return {"summary": {"program_count": 0, "active_project_count": 0}, "programs": []} + + project_ids = [pid for group in domain_groups.values() for pid in group["project_ids"]] + task_rows = ( + await self.db.execute( + select(Task.project_id, func.count(Task.id)) + .where( + and_( + Task.tenant_id == self.tenant_id, + Task.project_id.in_(project_ids), + Task.status == "blocked", + ) + ) + .group_by(Task.project_id) + ) + ).all() + task_map = {int(project_id): int(count or 0) for project_id, count in task_rows} + + work_rows = ( + await self.db.execute( + select(WorkItem.project_id, func.count(WorkItem.id)) + .where( + and_( + WorkItem.tenant_id == self.tenant_id, + WorkItem.project_id.in_(project_ids), + WorkItem.state == "waiting_manual", + ) + ) + .group_by(WorkItem.project_id) + ) + ).all() + work_map = {int(project_id): int(count or 0) for project_id, count in work_rows} + + budget_rows = ( + await self.db.execute( + select(Budget.project_id, Budget.budget_amount_usd, Budget.current_usage_usd).where( + and_( + Budget.tenant_id == self.tenant_id, + Budget.project_id.in_(project_ids), + ) + ) + ) + ).all() + budget_ratio_map = { + int(project_id): (float(usage or 0.0) / float(amount or 1.0)) if float(amount or 0.0) > 0 else 0.0 + for project_id, amount, usage in budget_rows + if project_id is not None + } + + for group in domain_groups.values(): + group["blocked_task_count"] = sum(task_map.get(pid, 0) for pid in group["project_ids"]) + group["pending_manual_count"] = sum(work_map.get(pid, 0) for pid in group["project_ids"]) + ratios = [budget_ratio_map.get(pid, 0.0) for pid in group["project_ids"]] + group["budget_usage_ratio_max"] = round(max(ratios, default=0.0), 4) + + programs = sorted( + domain_groups.values(), + key=lambda item: ( + item["pending_manual_count"], + item["blocked_task_count"], + item["project_count"], + ), + reverse=True, + ) + return { + "summary": { + "program_count": len(programs), + "active_project_count": len(project_ids), + "pending_manual_total": sum(item["pending_manual_count"] for item in programs), + "blocked_task_total": sum(item["blocked_task_count"] for item in programs), + }, + "programs": programs, + } diff --git a/backend/app/services/ai_pmo_cadence/mixin_queries_a.py b/backend/app/services/ai_pmo_cadence/mixin_queries_a.py new file mode 100644 index 00000000..2d12bf24 --- /dev/null +++ b/backend/app/services/ai_pmo_cadence/mixin_queries_a.py @@ -0,0 +1,129 @@ +"""Query helpers (part A) for AI PMO cadence service.""" + +from __future__ import annotations + +from .shared import ( + Any, + Optional, + ProjectSignalLink, + Signal, + Task, + WorkItem, + and_, + func, + or_, + select, + utc_now_naive, +) + + +class CadenceQueriesAMixin: + """Work item/task/signal query helpers.""" + + async def _list_recent_work_items(self, *, since, project_id: Optional[int]) -> list[dict[str, Any]]: + stmt = select(WorkItem).where( + and_( + WorkItem.tenant_id == self.tenant_id, + WorkItem.created_at >= since, + ) + ) + if project_id is not None: + stmt = stmt.where(WorkItem.project_id == int(project_id)) + rows = (await self.db.execute(stmt.order_by(WorkItem.created_at.desc()).limit(50))).scalars().all() + return [ + { + "id": int(row.id), + "state": row.state, + "intent": row.intent, + "project_id": row.project_id, + "signal_id": row.signal_id, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + for row in rows + ] + + async def _count_work_items(self, *, state: str, project_id: Optional[int]) -> int: + stmt = select(func.count(WorkItem.id)).where( + and_( + WorkItem.tenant_id == self.tenant_id, + WorkItem.state == state, + ) + ) + if project_id is not None: + stmt = stmt.where(WorkItem.project_id == int(project_id)) + return int((await self.db.execute(stmt)).scalar_one() or 0) + + async def _list_blocked_tasks(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: + stmt = select(Task).where( + and_( + Task.tenant_id == self.tenant_id, + Task.status == "blocked", + ) + ) + if project_id is not None: + stmt = stmt.where(Task.project_id == int(project_id)) + rows = (await self.db.execute(stmt.order_by(Task.updated_at.desc()).limit(50))).scalars().all() + return [ + { + "id": int(row.id), + "project_id": int(row.project_id), + "title": str(row.title or ""), + "priority": str(row.priority or ""), + } + for row in rows + ] + + async def _list_overdue_tasks(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: + now = utc_now_naive() + stmt = select(Task).where( + and_( + Task.tenant_id == self.tenant_id, + Task.due_date.is_not(None), + Task.due_date < now, + Task.status.not_in(["done", "completed", "closed"]), + ) + ) + if project_id is not None: + stmt = stmt.where(Task.project_id == int(project_id)) + rows = (await self.db.execute(stmt.order_by(Task.due_date.asc()).limit(50))).scalars().all() + return [ + { + "id": int(row.id), + "project_id": int(row.project_id), + "title": str(row.title or ""), + "due_date": row.due_date.isoformat() if row.due_date else None, + } + for row in rows + ] + + async def _list_recent_signals(self, *, since, project_id: Optional[int]) -> list[dict[str, Any]]: + stmt = select(Signal).where( + and_( + Signal.tenant_id == self.tenant_id, + Signal.created_at >= since, + ) + ) + if project_id is not None: + stmt = stmt.where( + or_( + Signal.matched_project_id == int(project_id), + Signal.id.in_( + select(ProjectSignalLink.signal_id).where( + and_( + ProjectSignalLink.tenant_id == self.tenant_id, + ProjectSignalLink.project_id == int(project_id), + ) + ) + ), + ) + ) + rows = (await self.db.execute(stmt.order_by(Signal.created_at.desc()).limit(50))).scalars().all() + return [ + { + "id": int(row.id), + "title": str(row.title or ""), + "status": str(row.status or ""), + "signal_type": str(row.signal_type or ""), + } + for row in rows + ] diff --git a/backend/app/services/ai_pmo_cadence/mixin_queries_b.py b/backend/app/services/ai_pmo_cadence/mixin_queries_b.py new file mode 100644 index 00000000..7ed0560d --- /dev/null +++ b/backend/app/services/ai_pmo_cadence/mixin_queries_b.py @@ -0,0 +1,124 @@ +"""Query helpers (part B) for AI PMO cadence service.""" + +from __future__ import annotations + +from .shared import ( + Any, + Budget, + Notification, + Optional, + SignalSourceRegistry, + UserProject, + UserWorkload, + and_, + select, +) + + +class CadenceQueriesBMixin: + """Budget/capacity/signal-source/notification query helpers.""" + + async def _list_budget_hotspots(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: + stmt = select(Budget).where(Budget.tenant_id == self.tenant_id) + if project_id is not None: + stmt = stmt.where(Budget.project_id == int(project_id)) + rows = (await self.db.execute(stmt)).scalars().all() + hotspots = [] + for row in rows: + amount = float(row.budget_amount_usd or 0.0) + usage = float(row.current_usage_usd or 0.0) + ratio = (usage / amount) if amount > 0 else 0.0 + if ratio >= 0.8: + hotspots.append( + { + "budget_id": int(row.id), + "project_id": row.project_id, + "usage_ratio": round(ratio, 4), + "hard_limit_enabled": bool(row.hard_limit_enabled), + } + ) + return sorted(hotspots, key=lambda item: item["usage_ratio"], reverse=True) + + async def _list_capacity_hotspots(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: + stmt = select(UserWorkload).where( + and_( + UserWorkload.tenant_id == self.tenant_id, + UserWorkload.workload_ratio >= 0.85, + ) + ) + if project_id is not None: + stmt = stmt.where( + UserWorkload.user_id.in_( + select(UserProject.user_id).where( + and_( + UserProject.tenant_id == self.tenant_id, + UserProject.project_id == int(project_id), + ) + ) + ) + ) + rows = (await self.db.execute(stmt.limit(50))).scalars().all() + return [ + { + "user_id": int(row.user_id), + "workload_ratio": float(row.workload_ratio or 0.0), + "current_assigned_hours": float(row.current_assigned_hours or 0.0), + } + for row in rows + ] + + async def _list_pending_signal_sources(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: + stmt = select(SignalSourceRegistry).where( + and_( + SignalSourceRegistry.tenant_id == self.tenant_id, + SignalSourceRegistry.onboarding_status == "pending_manual", + ) + ) + if project_id is not None: + stmt = stmt.where(SignalSourceRegistry.project_id == int(project_id)) + rows = ( + await self.db.execute( + stmt.order_by(SignalSourceRegistry.updated_at.desc()).limit(50) + ) + ).scalars().all() + return [ + { + "registry_id": int(row.id), + "project_id": int(row.project_id), + "source_kind": str(row.source_kind or ""), + "source_name": str(row.source_name or ""), + "status": str(row.status or ""), + "onboarding_status": str(row.onboarding_status or ""), + } + for row in rows + ] + + async def _list_unread_risk_notifications(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: + stmt = select(Notification).where( + and_( + Notification.tenant_id == self.tenant_id, + Notification.is_read.is_(False), + Notification.kind.in_(["risk", "alert", "action_required"]), + ) + ) + if project_id is not None: + stmt = stmt.where( + and_( + Notification.entity_type == "project", + Notification.entity_id == int(project_id), + ) + ) + rows = ( + await self.db.execute( + stmt.order_by(Notification.updated_at.desc()).limit(50) + ) + ).scalars().all() + return [ + { + "id": int(row.id), + "title": str(row.title or ""), + "status": str(row.status or ""), + "priority": str(row.priority or ""), + } + for row in rows + ] diff --git a/backend/app/services/ai_pmo_cadence/mixin_snapshots.py b/backend/app/services/ai_pmo_cadence/mixin_snapshots.py new file mode 100644 index 00000000..6016cc1c --- /dev/null +++ b/backend/app/services/ai_pmo_cadence/mixin_snapshots.py @@ -0,0 +1,80 @@ +"""Snapshot persistence APIs for AI PMO cadence service.""" + +from __future__ import annotations + +from .shared import Any, Optional, PortfolioSnapshot, and_, select + + +class CadenceSnapshotsMixin: + """Generates and retrieves cadence snapshots.""" + + async def generate_snapshot( + self, + *, + cadence_type: str, + project_id: Optional[int] = None, + ) -> dict[str, Any]: + normalized_type = str(cadence_type or "").strip().lower() + if normalized_type not in self.SUPPORTED_CADENCE_TYPES: + raise ValueError("unsupported cadence_type") + + if normalized_type == "daily_standup": + payload = await self._build_daily_standup(project_id=project_id) + elif normalized_type == "weekly_review": + payload = await self._build_weekly_review(project_id=project_id) + elif normalized_type == "risk_review": + payload = await self._build_risk_review(project_id=project_id) + else: + payload = await self._build_milestone_check(project_id=project_id) + + scope_ref = f"project:{int(project_id)}" if project_id is not None else f"tenant:{self.tenant_id}" + row = PortfolioSnapshot( + tenant_id=self.tenant_id, + scope_type=normalized_type[:20], + scope_ref=scope_ref[:64], + summary=payload.get("summary"), + payload=payload, + ) + self.db.add(row) + await self.db.flush() + return { + "id": int(row.id), + "scope_type": row.scope_type, + "scope_ref": row.scope_ref, + "summary": row.summary, + "payload": row.payload, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + + async def get_latest_snapshot( + self, + *, + cadence_type: str, + project_id: Optional[int] = None, + ) -> Optional[dict[str, Any]]: + normalized_type = str(cadence_type or "").strip().lower() + scope_ref = f"project:{int(project_id)}" if project_id is not None else f"tenant:{self.tenant_id}" + row = ( + await self.db.execute( + select(PortfolioSnapshot) + .where( + and_( + PortfolioSnapshot.tenant_id == self.tenant_id, + PortfolioSnapshot.scope_type == normalized_type, + PortfolioSnapshot.scope_ref == scope_ref, + ) + ) + .order_by(PortfolioSnapshot.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if row is None: + return None + return { + "id": int(row.id), + "scope_type": row.scope_type, + "scope_ref": row.scope_ref, + "summary": row.summary, + "payload": row.payload, + "created_at": row.created_at.isoformat() if row.created_at else None, + } diff --git a/backend/app/services/ai_pmo_cadence/service.py b/backend/app/services/ai_pmo_cadence/service.py new file mode 100644 index 00000000..7bc1ff05 --- /dev/null +++ b/backend/app/services/ai_pmo_cadence/service.py @@ -0,0 +1,31 @@ +"""AI PMO cadence service composition.""" + +from __future__ import annotations + +from .mixin_builders import CadenceBuildersMixin +from .mixin_program_view import CadenceProgramViewMixin +from .mixin_queries_a import CadenceQueriesAMixin +from .mixin_queries_b import CadenceQueriesBMixin +from .mixin_snapshots import CadenceSnapshotsMixin +from .shared import AsyncSession + + +class AIPMOCadenceService( + CadenceSnapshotsMixin, + CadenceProgramViewMixin, + CadenceBuildersMixin, + CadenceQueriesAMixin, + CadenceQueriesBMixin, +): + """Daily/weekly/risk/milestone cadence engine.""" + + SUPPORTED_CADENCE_TYPES = { + "daily_standup", + "weekly_review", + "risk_review", + "milestone_check", + } + + def __init__(self, db: AsyncSession, tenant_id: int) -> None: + self.db = db + self.tenant_id = int(tenant_id) diff --git a/backend/app/services/ai_pmo_cadence/shared.py b/backend/app/services/ai_pmo_cadence/shared.py new file mode 100644 index 00000000..e184e9ed --- /dev/null +++ b/backend/app/services/ai_pmo_cadence/shared.py @@ -0,0 +1,56 @@ +"""Shared imports for AI PMO cadence service.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any, Optional + +from sqlalchemy import and_, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...models import ( + Budget, + Notification, + PortfolioSnapshot, + Project, + ProjectDomain, + ProjectSignalLink, + Signal, + SignalSourceRegistry, + Task, + UserProject, + UserWorkload, + WorkItem, +) +from ...utils.timezone import now_cn_naive + + +def utc_now_naive() -> datetime: + return now_cn_naive() + + +__all__ = [ + "Any", + "AsyncSession", + "Budget", + "Notification", + "Optional", + "PortfolioSnapshot", + "Project", + "ProjectDomain", + "ProjectSignalLink", + "Signal", + "SignalSourceRegistry", + "Task", + "UserProject", + "UserWorkload", + "WorkItem", + "and_", + "datetime", + "func", + "or_", + "select", + "timedelta", + "timezone", + "utc_now_naive", +] diff --git a/backend/app/services/ai_pmo_cadence_service.py b/backend/app/services/ai_pmo_cadence_service.py index 27632c5b..b4cd5985 100644 --- a/backend/app/services/ai_pmo_cadence_service.py +++ b/backend/app/services/ai_pmo_cadence_service.py @@ -1,484 +1,5 @@ -"""AI PMO cadence engine。""" +"""Compatibility exports for AI PMO cadence service.""" -from __future__ import annotations +from .ai_pmo_cadence import AIPMOCadenceService -from datetime import datetime, timedelta, timezone -from typing import Any, Optional - -from sqlalchemy import and_, func, or_, select -from sqlalchemy.ext.asyncio import AsyncSession - -from ..models import Budget, Notification, PortfolioSnapshot, Project, ProjectDomain, ProjectSignalLink, Signal, SignalSourceRegistry, Task, UserProject, UserWorkload, WorkItem - - -from ..utils.timezone import now_cn_naive -def _utc_now_naive() -> datetime: - return now_cn_naive() - - -class AIPMOCadenceService: - """日会 / 周会 / 风险例会 / 里程碑检查 / 项目群视角。""" - - SUPPORTED_CADENCE_TYPES = {"daily_standup", "weekly_review", "risk_review", "milestone_check"} - - def __init__(self, db: AsyncSession, tenant_id: int) -> None: - self.db = db - self.tenant_id = int(tenant_id) - - async def generate_snapshot(self, *, cadence_type: str, project_id: Optional[int] = None) -> dict[str, Any]: - normalized_type = str(cadence_type or "").strip().lower() - if normalized_type not in self.SUPPORTED_CADENCE_TYPES: - raise ValueError("unsupported cadence_type") - if normalized_type == "daily_standup": - payload = await self._build_daily_standup(project_id=project_id) - elif normalized_type == "weekly_review": - payload = await self._build_weekly_review(project_id=project_id) - elif normalized_type == "risk_review": - payload = await self._build_risk_review(project_id=project_id) - else: - payload = await self._build_milestone_check(project_id=project_id) - - scope_ref = f"project:{int(project_id)}" if project_id is not None else f"tenant:{self.tenant_id}" - row = PortfolioSnapshot( - tenant_id=self.tenant_id, - scope_type=normalized_type[:20], - scope_ref=scope_ref[:64], - summary=payload.get("summary"), - payload=payload, - ) - self.db.add(row) - await self.db.flush() - return { - "id": int(row.id), - "scope_type": row.scope_type, - "scope_ref": row.scope_ref, - "summary": row.summary, - "payload": row.payload, - "created_at": row.created_at.isoformat() if row.created_at else None, - } - - async def get_latest_snapshot(self, *, cadence_type: str, project_id: Optional[int] = None) -> Optional[dict[str, Any]]: - normalized_type = str(cadence_type or "").strip().lower() - scope_ref = f"project:{int(project_id)}" if project_id is not None else f"tenant:{self.tenant_id}" - row = ( - await self.db.execute( - select(PortfolioSnapshot).where( - and_( - PortfolioSnapshot.tenant_id == self.tenant_id, - PortfolioSnapshot.scope_type == normalized_type, - PortfolioSnapshot.scope_ref == scope_ref, - ) - ).order_by(PortfolioSnapshot.id.desc()).limit(1) - ) - ).scalar_one_or_none() - if row is None: - return None - return { - "id": int(row.id), - "scope_type": row.scope_type, - "scope_ref": row.scope_ref, - "summary": row.summary, - "payload": row.payload, - "created_at": row.created_at.isoformat() if row.created_at else None, - } - - async def build_program_view(self) -> dict[str, Any]: - projects = ( - await self.db.execute( - select(Project, ProjectDomain) - .outerjoin(ProjectDomain, and_(ProjectDomain.id == Project.domain_id, ProjectDomain.tenant_id == self.tenant_id)) - .where( - and_( - Project.tenant_id == self.tenant_id, - Project.status.in_(["active", "planning", "doing", "todo"]), - ) - ) - .order_by(Project.id.asc()) - ) - ).all() - - domain_groups: dict[str, dict[str, Any]] = {} - for project, domain in projects: - key = str((domain.slug if domain is not None else "unscoped") or "unscoped") - group = domain_groups.setdefault( - key, - { - "program_key": key, - "program_name": str((domain.name if domain is not None else "未分组项目") or "未分组项目"), - "project_count": 0, - "project_ids": [], - "blocked_task_count": 0, - "pending_manual_count": 0, - "budget_usage_ratio_max": 0.0, - }, - ) - group["project_count"] += 1 - group["project_ids"].append(int(project.id)) - - if not domain_groups: - return {"summary": {"program_count": 0, "active_project_count": 0}, "programs": []} - - project_ids = [pid for group in domain_groups.values() for pid in group["project_ids"]] - task_rows = ( - await self.db.execute( - select(Task.project_id, func.count(Task.id)) - .where( - and_( - Task.tenant_id == self.tenant_id, - Task.project_id.in_(project_ids), - Task.status == "blocked", - ) - ) - .group_by(Task.project_id) - ) - ).all() - task_map = {int(project_id): int(count or 0) for project_id, count in task_rows} - - work_rows = ( - await self.db.execute( - select(WorkItem.project_id, func.count(WorkItem.id)) - .where( - and_( - WorkItem.tenant_id == self.tenant_id, - WorkItem.project_id.in_(project_ids), - WorkItem.state == "waiting_manual", - ) - ) - .group_by(WorkItem.project_id) - ) - ).all() - work_map = {int(project_id): int(count or 0) for project_id, count in work_rows} - - budget_rows = ( - await self.db.execute( - select(Budget.project_id, Budget.budget_amount_usd, Budget.current_usage_usd) - .where( - and_( - Budget.tenant_id == self.tenant_id, - Budget.project_id.in_(project_ids), - ) - ) - ) - ).all() - budget_ratio_map = { - int(project_id): (float(usage or 0.0) / float(amount or 1.0)) if float(amount or 0.0) > 0 else 0.0 - for project_id, amount, usage in budget_rows - if project_id is not None - } - - for group in domain_groups.values(): - group["blocked_task_count"] = sum(task_map.get(pid, 0) for pid in group["project_ids"]) - group["pending_manual_count"] = sum(work_map.get(pid, 0) for pid in group["project_ids"]) - ratios = [budget_ratio_map.get(pid, 0.0) for pid in group["project_ids"]] - group["budget_usage_ratio_max"] = round(max(ratios, default=0.0), 4) - - programs = sorted(domain_groups.values(), key=lambda item: (item["pending_manual_count"], item["blocked_task_count"], item["project_count"]), reverse=True) - return { - "summary": { - "program_count": len(programs), - "active_project_count": len(project_ids), - "pending_manual_total": sum(item["pending_manual_count"] for item in programs), - "blocked_task_total": sum(item["blocked_task_count"] for item in programs), - }, - "programs": programs, - } - - async def _build_daily_standup(self, *, project_id: Optional[int]) -> dict[str, Any]: - now = _utc_now_naive() - since = now - timedelta(days=1) - work_items = await self._list_recent_work_items(since=since, project_id=project_id) - blocked_tasks = await self._list_blocked_tasks(project_id=project_id) - recent_signals = await self._list_recent_signals(since=since, project_id=project_id) - pending_manual_count = await self._count_work_items(state="waiting_manual", project_id=project_id) - return { - "summary": { - "cadence_type": "daily_standup", - "recent_work_item_count": len(work_items), - "pending_manual_count": pending_manual_count, - "blocked_task_count": len(blocked_tasks), - "recent_signal_count": len(recent_signals), - }, - "work_items": work_items[:10], - "blocked_tasks": blocked_tasks[:10], - "recent_signals": recent_signals[:10], - "focus_actions": self._build_focus_actions(pending_manual_count=pending_manual_count, blocked_task_count=len(blocked_tasks)), - } - - async def _build_weekly_review(self, *, project_id: Optional[int]) -> dict[str, Any]: - now = _utc_now_naive() - since = now - timedelta(days=7) - weekly_rows = await self._list_recent_work_items(since=since, project_id=project_id) - budgets = await self._list_budget_hotspots(project_id=project_id) - capacity = await self._list_capacity_hotspots(project_id=project_id) - program_view = await self.build_program_view() if project_id is None else None - by_state: dict[str, int] = {} - for item in weekly_rows: - state = str(item.get("state") or "unknown") - by_state[state] = by_state.get(state, 0) + 1 - return { - "summary": { - "cadence_type": "weekly_review", - "work_item_total": len(weekly_rows), - "by_state": by_state, - "budget_hotspot_count": len(budgets), - "capacity_hotspot_count": len(capacity), - }, - "work_items": weekly_rows[:20], - "budget_hotspots": budgets[:10], - "capacity_hotspots": capacity[:10], - "program_view": program_view, - } - - async def _build_risk_review(self, *, project_id: Optional[int]) -> dict[str, Any]: - blocked_tasks = await self._list_blocked_tasks(project_id=project_id) - signal_sources = await self._list_pending_signal_sources(project_id=project_id) - notifications = await self._list_unread_risk_notifications(project_id=project_id) - return { - "summary": { - "cadence_type": "risk_review", - "blocked_task_count": len(blocked_tasks), - "pending_signal_source_count": len(signal_sources), - "unread_risk_notification_count": len(notifications), - }, - "blocked_tasks": blocked_tasks[:20], - "pending_signal_sources": signal_sources[:20], - "risk_notifications": notifications[:20], - } - - async def _build_milestone_check(self, *, project_id: Optional[int]) -> dict[str, Any]: - overdue_tasks = await self._list_overdue_tasks(project_id=project_id) - work_items = await self._count_work_items(state="waiting_manual", project_id=project_id) - stmt = select(Project.id, Project.title, Project.status).where(Project.tenant_id == self.tenant_id) - if project_id is not None: - stmt = stmt.where(Project.id == int(project_id)) - projects = (await self.db.execute(stmt)).all() - return { - "summary": { - "cadence_type": "milestone_check", - "project_count": len(projects), - "overdue_task_count": len(overdue_tasks), - "pending_manual_count": work_items, - }, - "projects": [ - {"project_id": int(pid), "title": str(title or ""), "status": str(status or "")} - for pid, title, status in projects - ], - "overdue_tasks": overdue_tasks[:20], - } - - async def _list_recent_work_items(self, *, since: datetime, project_id: Optional[int]) -> list[dict[str, Any]]: - stmt = select(WorkItem).where( - and_( - WorkItem.tenant_id == self.tenant_id, - WorkItem.created_at >= since, - ) - ) - if project_id is not None: - stmt = stmt.where(WorkItem.project_id == int(project_id)) - rows = (await self.db.execute(stmt.order_by(WorkItem.created_at.desc()).limit(50))).scalars().all() - return [ - { - "id": int(row.id), - "state": row.state, - "intent": row.intent, - "project_id": row.project_id, - "signal_id": row.signal_id, - "created_at": row.created_at.isoformat() if row.created_at else None, - } - for row in rows - ] - - async def _count_work_items(self, *, state: str, project_id: Optional[int]) -> int: - stmt = select(func.count(WorkItem.id)).where( - and_( - WorkItem.tenant_id == self.tenant_id, - WorkItem.state == state, - ) - ) - if project_id is not None: - stmt = stmt.where(WorkItem.project_id == int(project_id)) - return int((await self.db.execute(stmt)).scalar_one() or 0) - - async def _list_blocked_tasks(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: - stmt = select(Task).where( - and_( - Task.tenant_id == self.tenant_id, - Task.status == "blocked", - ) - ) - if project_id is not None: - stmt = stmt.where(Task.project_id == int(project_id)) - rows = (await self.db.execute(stmt.order_by(Task.updated_at.desc()).limit(50))).scalars().all() - return [ - { - "id": int(row.id), - "project_id": int(row.project_id), - "title": str(row.title or ""), - "priority": str(row.priority or ""), - } - for row in rows - ] - - async def _list_overdue_tasks(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: - now = _utc_now_naive() - stmt = select(Task).where( - and_( - Task.tenant_id == self.tenant_id, - Task.due_date.is_not(None), - Task.due_date < now, - Task.status.not_in(["done", "completed", "closed"]), - ) - ) - if project_id is not None: - stmt = stmt.where(Task.project_id == int(project_id)) - rows = (await self.db.execute(stmt.order_by(Task.due_date.asc()).limit(50))).scalars().all() - return [ - { - "id": int(row.id), - "project_id": int(row.project_id), - "title": str(row.title or ""), - "due_date": row.due_date.isoformat() if row.due_date else None, - } - for row in rows - ] - - async def _list_recent_signals(self, *, since: datetime, project_id: Optional[int]) -> list[dict[str, Any]]: - stmt = select(Signal).where( - and_( - Signal.tenant_id == self.tenant_id, - Signal.created_at >= since, - ) - ) - if project_id is not None: - stmt = stmt.where( - or_( - Signal.matched_project_id == int(project_id), - Signal.id.in_( - select(ProjectSignalLink.signal_id).where( - and_( - ProjectSignalLink.tenant_id == self.tenant_id, - ProjectSignalLink.project_id == int(project_id), - ) - ) - ), - ) - ) - rows = (await self.db.execute(stmt.order_by(Signal.created_at.desc()).limit(50))).scalars().all() - return [ - { - "id": int(row.id), - "title": str(row.title or ""), - "status": str(row.status or ""), - "signal_type": str(row.signal_type or ""), - } - for row in rows - ] - - async def _list_budget_hotspots(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: - stmt = select(Budget).where(Budget.tenant_id == self.tenant_id) - if project_id is not None: - stmt = stmt.where(Budget.project_id == int(project_id)) - rows = (await self.db.execute(stmt)).scalars().all() - hotspots = [] - for row in rows: - amount = float(row.budget_amount_usd or 0.0) - usage = float(row.current_usage_usd or 0.0) - ratio = (usage / amount) if amount > 0 else 0.0 - if ratio >= 0.8: - hotspots.append( - { - "budget_id": int(row.id), - "project_id": row.project_id, - "usage_ratio": round(ratio, 4), - "hard_limit_enabled": bool(row.hard_limit_enabled), - } - ) - return sorted(hotspots, key=lambda item: item["usage_ratio"], reverse=True) - - async def _list_capacity_hotspots(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: - stmt = select(UserWorkload).where( - and_( - UserWorkload.tenant_id == self.tenant_id, - UserWorkload.workload_ratio >= 0.85, - ) - ) - if project_id is not None: - stmt = stmt.where( - UserWorkload.user_id.in_( - select(UserProject.user_id).where( - and_( - UserProject.tenant_id == self.tenant_id, - UserProject.project_id == int(project_id), - ) - ) - ) - ) - rows = (await self.db.execute(stmt.limit(50))).scalars().all() - items = [ - { - "user_id": int(row.user_id), - "workload_ratio": float(row.workload_ratio or 0.0), - "current_assigned_hours": float(row.current_assigned_hours or 0.0), - } - for row in rows - ] - return items - - async def _list_pending_signal_sources(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: - stmt = select(SignalSourceRegistry).where( - and_( - SignalSourceRegistry.tenant_id == self.tenant_id, - SignalSourceRegistry.onboarding_status == "pending_manual", - ) - ) - if project_id is not None: - stmt = stmt.where(SignalSourceRegistry.project_id == int(project_id)) - rows = (await self.db.execute(stmt.order_by(SignalSourceRegistry.updated_at.desc()).limit(50))).scalars().all() - return [ - { - "registry_id": int(row.id), - "project_id": int(row.project_id), - "source_kind": str(row.source_kind or ""), - "source_name": str(row.source_name or ""), - "status": str(row.status or ""), - "onboarding_status": str(row.onboarding_status or ""), - } - for row in rows - ] - - async def _list_unread_risk_notifications(self, *, project_id: Optional[int]) -> list[dict[str, Any]]: - stmt = select(Notification).where( - and_( - Notification.tenant_id == self.tenant_id, - Notification.is_read.is_(False), - Notification.kind.in_(["risk", "alert", "action_required"]), - ) - ) - if project_id is not None: - stmt = stmt.where( - and_( - Notification.entity_type == "project", - Notification.entity_id == int(project_id), - ) - ) - rows = (await self.db.execute(stmt.order_by(Notification.updated_at.desc()).limit(50))).scalars().all() - return [ - { - "id": int(row.id), - "title": str(row.title or ""), - "status": str(row.status or ""), - "priority": str(row.priority or ""), - } - for row in rows - ] - - @staticmethod - def _build_focus_actions(*, pending_manual_count: int, blocked_task_count: int) -> list[str]: - actions: list[str] = [] - if pending_manual_count > 0: - actions.append("优先清理待人工确认队列,避免自动执行链路阻塞。") - if blocked_task_count > 0: - actions.append("优先处理 blocked tasks,降低关键依赖压力。") - if not actions: - actions.append("当前节奏平稳,继续按计划推进。") - return actions +__all__ = ["AIPMOCadenceService"] diff --git a/backend/app/services/ai_pmo_decision/__init__.py b/backend/app/services/ai_pmo_decision/__init__.py new file mode 100644 index 00000000..ee1bd0ce --- /dev/null +++ b/backend/app/services/ai_pmo_decision/__init__.py @@ -0,0 +1,19 @@ +"""AI PMO decision runtime exports.""" + +from .engine import DecisionEngine +from .policy import ExecutionPolicyBrain +from .shared import normalize_workload_ratio as _normalize_workload_ratio +from .shared import safe_int as _safe_int +from .simulation import SimulationEngine +from .strategic import StrategicBrain +from .tactical import TacticalBrain + +__all__ = [ + "StrategicBrain", + "TacticalBrain", + "SimulationEngine", + "ExecutionPolicyBrain", + "DecisionEngine", + "_normalize_workload_ratio", + "_safe_int", +] diff --git a/backend/app/services/ai_pmo_decision/engine.py b/backend/app/services/ai_pmo_decision/engine.py new file mode 100644 index 00000000..4a17a1c6 --- /dev/null +++ b/backend/app/services/ai_pmo_decision/engine.py @@ -0,0 +1,40 @@ +"""Decision engine composition for AI PMO runtime.""" + +from __future__ import annotations + +from .policy import ExecutionPolicyBrain +from .shared import Any, CanonicalIntent +from .simulation import SimulationEngine +from .strategic import StrategicBrain +from .tactical import TacticalBrain + + +class DecisionEngine: + """汇总三脑输出。""" + + def __init__(self, *, policy_gate_rules: tuple[tuple[str, tuple[str, ...]], ...]) -> None: + self.strategic_brain = StrategicBrain() + self.tactical_brain = TacticalBrain() + self.simulation_engine = SimulationEngine() + self.execution_policy_brain = ExecutionPolicyBrain(policy_gate_rules=policy_gate_rules) + + def decide(self, *, intent: CanonicalIntent, world_model: dict[str, Any], message_text: str) -> dict[str, Any]: + strategic = self.strategic_brain.evaluate(intent=intent, world_model=world_model, message_text=message_text) + strategic_plan, execution_plan = self.tactical_brain.plan(intent=intent, world_model=world_model, decision=strategic) + simulation = self.simulation_engine.simulate(intent=intent, world_model=world_model, execution_plan=execution_plan) + strategic_plan["simulation"] = simulation + execution_plan["simulation"] = simulation + policy = self.execution_policy_brain.evaluate( + message_text=message_text, + world_model=world_model, + intent=intent, + simulation=simulation, + ) + strategic["risk_score"] = max(float(strategic.get("risk_score") or 0.0), float(simulation.get("projected_risk_score") or 0.0)) + return { + "strategic": strategic, + "strategic_plan": strategic_plan, + "execution_plan": execution_plan, + "simulation": simulation, + "policy": policy, + } diff --git a/backend/app/services/ai_pmo_decision/policy.py b/backend/app/services/ai_pmo_decision/policy.py new file mode 100644 index 00000000..6a485be2 --- /dev/null +++ b/backend/app/services/ai_pmo_decision/policy.py @@ -0,0 +1,128 @@ +"""Execution policy brain for AI PMO decision runtime.""" + +from __future__ import annotations + +from .shared import ( + CanonicalIntent, + INTENT_QUERY, + INTENT_SIGNAL_CREATE, + INTENT_SIGNAL_SOURCE_ONBOARD, + re, +) + + +class ExecutionPolicyBrain: + """自动执行边界。""" + + def __init__(self, *, policy_gate_rules: tuple[tuple[str, tuple[str, ...]], ...]) -> None: + self.policy_gate_rules = policy_gate_rules + + def evaluate( + self, + *, + message_text: str, + world_model: dict[str, object], + intent: CanonicalIntent, + simulation: dict[str, object], + ) -> dict[str, object]: + normalized = re.sub(r"\s+", "", str(message_text or "")).lower() + for gate_code, tokens in self.policy_gate_rules: + if any(token.lower().replace(" ", "") in normalized for token in tokens): + return { + "auto_execute": False, + "policy_gate_hit": True, + "policy_gate_code": gate_code, + "approval_requirement": { + "required": True, + "reason": gate_code, + "message": "命中硬策略边界,必须进入显式审批/人工确认。", + }, + } + + if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD and intent.project_id is None: + return { + "auto_execute": False, + "policy_gate_hit": True, + "policy_gate_code": "missing_project_scope", + "approval_requirement": { + "required": True, + "reason": "missing_project_scope", + "message": "信号源接入缺少项目归属,必须先确认项目。", + }, + } + + if intent.name in {INTENT_QUERY, INTENT_SIGNAL_CREATE}: + return { + "auto_execute": True, + "policy_gate_hit": False, + "policy_gate_code": None, + "approval_requirement": {"required": False}, + } + + budget = world_model.get("budget") if isinstance(world_model.get("budget"), dict) else {} + if bool(budget.get("hard_limit_enabled")) and float(budget.get("usage_ratio") or 0.0) >= 1.0: + return { + "auto_execute": False, + "policy_gate_hit": True, + "policy_gate_code": "budget_hard_limit", + "approval_requirement": { + "required": True, + "reason": "budget_hard_limit", + "message": "项目预算已触达硬限制,自动执行被拦截。", + }, + } + + if float(simulation.get("projected_budget_usage_ratio") or 0.0) >= 1.0: + return { + "auto_execute": False, + "policy_gate_hit": True, + "policy_gate_code": "simulation_budget_limit", + "approval_requirement": { + "required": True, + "reason": "simulation_budget_limit", + "message": "执行前仿真显示预算将触顶,自动执行被拦截。", + }, + } + + if float(simulation.get("projected_capacity_peak_ratio") or 0.0) >= 1.0: + return { + "auto_execute": False, + "policy_gate_hit": True, + "policy_gate_code": "simulation_capacity_limit", + "approval_requirement": { + "required": True, + "reason": "simulation_capacity_limit", + "message": "执行前仿真显示产能会超载,自动执行被拦截。", + }, + } + + if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD and int(simulation.get("missing_skill_count") or 0) > 0: + return { + "auto_execute": False, + "policy_gate_hit": True, + "policy_gate_code": "simulation_skill_gap", + "approval_requirement": { + "required": True, + "reason": "simulation_skill_gap", + "message": "执行前仿真显示当前项目存在技能缺口,信号源接入先进入待确认。", + }, + } + + if intent.name in {INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_SIGNAL_CREATE} and str(simulation.get("dependency_pressure") or "") == "high": + return { + "auto_execute": False, + "policy_gate_hit": True, + "policy_gate_code": "simulation_dependency_pressure", + "approval_requirement": { + "required": True, + "reason": "simulation_dependency_pressure", + "message": "执行前仿真显示关键依赖压力过高,当前先进入待人工确认。", + }, + } + + return { + "auto_execute": True, + "policy_gate_hit": False, + "policy_gate_code": None, + "approval_requirement": {"required": False}, + } diff --git a/backend/app/services/ai_pmo_decision/shared.py b/backend/app/services/ai_pmo_decision/shared.py new file mode 100644 index 00000000..d7855a5b --- /dev/null +++ b/backend/app/services/ai_pmo_decision/shared.py @@ -0,0 +1,58 @@ +"""Shared imports/helpers for AI PMO decision runtime.""" + +from __future__ import annotations + +import re +from typing import Any + +from ..ai_pmo_intent_routing import ( + INTENT_FOLLOWUP_CONFIRM, + INTENT_FOLLOWUP_CONTINUE, + INTENT_PROJECT_CREATE, + INTENT_QUERY, + INTENT_SIGNAL_CREATE, + INTENT_SIGNAL_SOURCE_ONBOARD, + ROUTE_PROJECT_SIGNAL_CANCEL, + ROUTE_PROJECT_SIGNAL_COMMIT, + ROUTE_PROJECT_SIGNAL_DISCOVER, + ROUTE_PROJECT_SIGNAL_REFRESH, + CanonicalIntent, +) + + +def normalize_workload_ratio(value: Any) -> float: + try: + ratio = float(value or 0.0) + except (TypeError, ValueError): + return 0.0 + if ratio <= 0: + return 0.0 + return ratio / 100.0 if ratio > 1.0 else ratio + + +def safe_int(value: Any) -> int | None: + try: + if value is None or value == "": + return None + return int(value) + except (TypeError, ValueError): + return None + + +__all__ = [ + "Any", + "CanonicalIntent", + "INTENT_FOLLOWUP_CONFIRM", + "INTENT_FOLLOWUP_CONTINUE", + "INTENT_PROJECT_CREATE", + "INTENT_QUERY", + "INTENT_SIGNAL_CREATE", + "INTENT_SIGNAL_SOURCE_ONBOARD", + "ROUTE_PROJECT_SIGNAL_CANCEL", + "ROUTE_PROJECT_SIGNAL_COMMIT", + "ROUTE_PROJECT_SIGNAL_DISCOVER", + "ROUTE_PROJECT_SIGNAL_REFRESH", + "normalize_workload_ratio", + "re", + "safe_int", +] diff --git a/backend/app/services/ai_pmo_decision/simulation.py b/backend/app/services/ai_pmo_decision/simulation.py new file mode 100644 index 00000000..6d9c3299 --- /dev/null +++ b/backend/app/services/ai_pmo_decision/simulation.py @@ -0,0 +1,87 @@ +"""Simulation engine for AI PMO decision runtime.""" + +from __future__ import annotations + +from .shared import ( + CanonicalIntent, + ROUTE_PROJECT_SIGNAL_CANCEL, + ROUTE_PROJECT_SIGNAL_COMMIT, + ROUTE_PROJECT_SIGNAL_DISCOVER, + ROUTE_PROJECT_SIGNAL_REFRESH, + Any, + normalize_workload_ratio, +) + + +class SimulationEngine: + """执行前仿真。""" + + BUDGET_COST_BY_OPERATION = { + "query.answer": 1.0, + "project.create": 120.0, + "signal.enter": 8.0, + "signal_source.onboard": 40.0, + ROUTE_PROJECT_SIGNAL_DISCOVER: 3.0, + ROUTE_PROJECT_SIGNAL_COMMIT: 8.0, + ROUTE_PROJECT_SIGNAL_REFRESH: 3.0, + ROUTE_PROJECT_SIGNAL_CANCEL: 0.2, + } + HOURS_BY_OPERATION = { + "query.answer": 0.2, + "project.create": 12.0, + "signal.enter": 1.5, + "signal_source.onboard": 6.0, + ROUTE_PROJECT_SIGNAL_DISCOVER: 0.8, + ROUTE_PROJECT_SIGNAL_COMMIT: 1.5, + ROUTE_PROJECT_SIGNAL_REFRESH: 0.8, + ROUTE_PROJECT_SIGNAL_CANCEL: 0.1, + } + + @classmethod + def simulate(cls, *, intent: CanonicalIntent, world_model: dict[str, Any], execution_plan: dict[str, Any]) -> dict[str, Any]: + actions = execution_plan.get("actions") if isinstance(execution_plan.get("actions"), list) else [] + operation_codes = [str(item.get("operation_code") or "").strip() for item in actions if isinstance(item, dict)] + estimated_budget_delta = sum(cls.BUDGET_COST_BY_OPERATION.get(code, 0.0) for code in operation_codes) + estimated_capacity_delta = sum(cls.HOURS_BY_OPERATION.get(code, 0.0) for code in operation_codes) + + budget = world_model.get("budget") if isinstance(world_model.get("budget"), dict) else {} + budget_amount = float(budget.get("budget_amount_usd") or 0.0) + current_ratio = float(budget.get("usage_ratio") or 0.0) + projected_budget_ratio = current_ratio + (estimated_budget_delta / budget_amount if budget_amount > 0 else 0.0) + + capacity = world_model.get("capacity") if isinstance(world_model.get("capacity"), list) else [] + current_peak_ratio = max([normalize_workload_ratio(item.get("workload_ratio")) for item in capacity], default=0.0) + project_member_count = max(1, int(world_model.get("member_count") or len(capacity) or 1)) + projected_capacity_peak_ratio = current_peak_ratio + (estimated_capacity_delta / (40.0 * project_member_count)) + + task_summary = world_model.get("task_summary") if isinstance(world_model.get("task_summary"), dict) else {} + blocked_count = int((task_summary.get("by_status") or {}).get("blocked") or 0) + overdue_count = int(task_summary.get("overdue_count") or 0) + missing_skill_tag_ids = task_summary.get("uncovered_skill_tag_ids") if isinstance(task_summary.get("uncovered_skill_tag_ids"), list) else [] + + charter = world_model.get("charter") if isinstance(world_model.get("charter"), list) else [] + dependency_pressure = "high" if blocked_count > 0 or overdue_count > 0 else ("medium" if not charter else "low") + projected_risk_score = min( + 1.0, + 0.12 + + projected_budget_ratio * 0.24 + + projected_capacity_peak_ratio * 0.28 + + blocked_count * 0.08 + + overdue_count * 0.05 + + len(missing_skill_tag_ids) * 0.07, + ) + + return { + "intent": intent.name, + "operation_codes": operation_codes, + "estimated_budget_delta_usd": round(estimated_budget_delta, 2), + "projected_budget_usage_ratio": round(projected_budget_ratio, 4), + "estimated_capacity_delta_hours": round(estimated_capacity_delta, 2), + "projected_capacity_peak_ratio": round(projected_capacity_peak_ratio, 4), + "blocked_task_count": blocked_count, + "overdue_task_count": overdue_count, + "dependency_pressure": dependency_pressure, + "missing_skill_tag_ids": missing_skill_tag_ids[:20], + "missing_skill_count": len(missing_skill_tag_ids), + "projected_risk_score": round(projected_risk_score, 4), + } diff --git a/backend/app/services/ai_pmo_decision/strategic.py b/backend/app/services/ai_pmo_decision/strategic.py new file mode 100644 index 00000000..45e1bfed --- /dev/null +++ b/backend/app/services/ai_pmo_decision/strategic.py @@ -0,0 +1,56 @@ +"""Strategic brain for AI PMO decision runtime.""" + +from __future__ import annotations + +from .shared import ( + CanonicalIntent, + INTENT_PROJECT_CREATE, + INTENT_SIGNAL_SOURCE_ONBOARD, + Any, + normalize_workload_ratio, + safe_int, +) + + +class StrategicBrain: + """组合级判断。""" + + @staticmethod + def evaluate(*, intent: CanonicalIntent, world_model: dict[str, Any], message_text: str) -> dict[str, Any]: + project = world_model.get("project") if isinstance(world_model.get("project"), dict) else None + budget = world_model.get("budget") if isinstance(world_model.get("budget"), dict) else {} + capacity = world_model.get("capacity") if isinstance(world_model.get("capacity"), list) else [] + risk_count = int((world_model.get("risk_summary") or {}).get("open_risk_count") or 0) + active_projects = int((world_model.get("portfolio") or {}).get("active_project_count") or 0) + + budget_ratio = float(budget.get("usage_ratio") or 0.0) + overloaded_people = [row for row in capacity if normalize_workload_ratio(row.get("workload_ratio")) >= 0.85] + risk_score = min(1.0, 0.18 + risk_count * 0.08 + budget_ratio * 0.25 + len(overloaded_people) * 0.1) + if intent.name == INTENT_PROJECT_CREATE: + risk_score = max(risk_score, 0.62) + if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: + risk_score = max(risk_score, 0.58) + + return { + "decision_level": "project" if project else "portfolio", + "decision_object_type": "project" if project else "portfolio", + "decision_object_id": safe_int((project or {}).get("id")), + "risk_score": round(risk_score, 3), + "budget_impact": { + "budget_id": budget.get("budget_id"), + "usage_ratio": budget_ratio, + "hard_limit_enabled": bool(budget.get("hard_limit_enabled")), + }, + "resource_impact": { + "overloaded_user_ids": [row.get("user_id") for row in overloaded_people], + "active_project_count": active_projects, + }, + "rationale": ( + f"intent={intent.name}; " + f"project_scope={'project' if project else 'portfolio'}; " + f"budget_ratio={budget_ratio:.2f}; " + f"open_risks={risk_count}; " + f"overloaded={len(overloaded_people)}" + ), + "message_excerpt": message_text[:240], + } diff --git a/backend/app/services/ai_pmo_decision/tactical.py b/backend/app/services/ai_pmo_decision/tactical.py new file mode 100644 index 00000000..2c9bc5b7 --- /dev/null +++ b/backend/app/services/ai_pmo_decision/tactical.py @@ -0,0 +1,68 @@ +"""Tactical brain for AI PMO decision runtime.""" + +from __future__ import annotations + +from .shared import ( + CanonicalIntent, + INTENT_FOLLOWUP_CONFIRM, + INTENT_FOLLOWUP_CONTINUE, + INTENT_PROJECT_CREATE, + INTENT_QUERY, + INTENT_SIGNAL_CREATE, + INTENT_SIGNAL_SOURCE_ONBOARD, + ROUTE_PROJECT_SIGNAL_CANCEL, + ROUTE_PROJECT_SIGNAL_COMMIT, + ROUTE_PROJECT_SIGNAL_DISCOVER, + ROUTE_PROJECT_SIGNAL_REFRESH, + Any, +) + + +class TacticalBrain: + """周/日执行计划。""" + + @staticmethod + def plan(*, intent: CanonicalIntent, world_model: dict[str, Any], decision: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + project = world_model.get("project") if isinstance(world_model.get("project"), dict) else None + project_ref = f"项目#{project['id']}" if project and project.get("id") is not None else "项目群" + strategic_plan = { + "priority": "high" if float(decision.get("risk_score") or 0.0) >= 0.6 else "medium", + "objectives": [ + f"围绕 {project_ref} 控制风险与节奏", + "优先保证关键路径和资源边界不被破坏", + ], + "signals": [ + f"budget_usage_ratio={float((decision.get('budget_impact') or {}).get('usage_ratio') or 0.0):.2f}", + f"overloaded_users={len((decision.get('resource_impact') or {}).get('overloaded_user_ids') or [])}", + ], + } + execution_actions = [] + workflow_route = str((intent.metadata or {}).get("workflow_route") or "").strip().lower() + if intent.name == INTENT_QUERY: + execution_actions.append({"operation_code": "query.answer", "mode": "read_only"}) + elif intent.name == INTENT_PROJECT_CREATE: + execution_actions.append({"operation_code": "project.create", "mode": "write"}) + elif intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: + execution_actions.append({"operation_code": "signal.enter", "mode": "write"}) + execution_actions.append({"operation_code": "signal_source.onboard", "mode": "write"}) + elif intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: + execution_actions.append({"operation_code": "work_item.resume", "mode": "write"}) + elif intent.name == INTENT_SIGNAL_CREATE: + if workflow_route == ROUTE_PROJECT_SIGNAL_DISCOVER: + execution_actions.append({"operation_code": ROUTE_PROJECT_SIGNAL_DISCOVER, "mode": "write"}) + elif workflow_route == ROUTE_PROJECT_SIGNAL_COMMIT: + execution_actions.append({"operation_code": ROUTE_PROJECT_SIGNAL_COMMIT, "mode": "write"}) + elif workflow_route == ROUTE_PROJECT_SIGNAL_REFRESH: + execution_actions.append({"operation_code": ROUTE_PROJECT_SIGNAL_REFRESH, "mode": "write"}) + elif workflow_route == ROUTE_PROJECT_SIGNAL_CANCEL: + execution_actions.append({"operation_code": ROUTE_PROJECT_SIGNAL_CANCEL, "mode": "write"}) + else: + execution_actions.append({"operation_code": "signal.enter", "mode": "write"}) + + execution_plan = { + "intent": intent.name, + "project_id": intent.project_id, + "actions": execution_actions, + "summary": f"执行 {intent.name},共 {len(execution_actions)} 个 typed actions", + } + return strategic_plan, execution_plan diff --git a/backend/app/services/ai_pmo_decision_runtime.py b/backend/app/services/ai_pmo_decision_runtime.py index 054a3865..e49ca4d8 100644 --- a/backend/app/services/ai_pmo_decision_runtime.py +++ b/backend/app/services/ai_pmo_decision_runtime.py @@ -1,355 +1,21 @@ -"""AI PMO decision brains (strategic/tactical/simulation/policy).""" +"""Compatibility exports for AI PMO decision runtime.""" -from __future__ import annotations - -import re -from typing import Any - -from .ai_pmo_intent_routing import ( - INTENT_FOLLOWUP_CONFIRM, - INTENT_FOLLOWUP_CONTINUE, - INTENT_PROJECT_CREATE, - INTENT_QUERY, - INTENT_SIGNAL_CREATE, - INTENT_SIGNAL_SOURCE_ONBOARD, - ROUTE_PROJECT_SIGNAL_CANCEL, - ROUTE_PROJECT_SIGNAL_COMMIT, - ROUTE_PROJECT_SIGNAL_DISCOVER, - ROUTE_PROJECT_SIGNAL_REFRESH, - CanonicalIntent, +from .ai_pmo_decision import ( + DecisionEngine, + ExecutionPolicyBrain, + SimulationEngine, + StrategicBrain, + TacticalBrain, + _normalize_workload_ratio, + _safe_int, ) - -def _normalize_workload_ratio(value: Any) -> float: - try: - ratio = float(value or 0.0) - except (TypeError, ValueError): - return 0.0 - if ratio <= 0: - return 0.0 - return ratio / 100.0 if ratio > 1.0 else ratio - - -class StrategicBrain: - """组合级判断。""" - - @staticmethod - def evaluate(*, intent: CanonicalIntent, world_model: dict[str, Any], message_text: str) -> dict[str, Any]: - project = world_model.get("project") if isinstance(world_model.get("project"), dict) else None - budget = world_model.get("budget") if isinstance(world_model.get("budget"), dict) else {} - capacity = world_model.get("capacity") if isinstance(world_model.get("capacity"), list) else [] - risk_count = int((world_model.get("risk_summary") or {}).get("open_risk_count") or 0) - active_projects = int((world_model.get("portfolio") or {}).get("active_project_count") or 0) - - budget_ratio = float(budget.get("usage_ratio") or 0.0) - overloaded_people = [row for row in capacity if _normalize_workload_ratio(row.get("workload_ratio")) >= 0.85] - risk_score = min(1.0, 0.18 + risk_count * 0.08 + budget_ratio * 0.25 + len(overloaded_people) * 0.1) - if intent.name == INTENT_PROJECT_CREATE: - risk_score = max(risk_score, 0.62) - if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: - risk_score = max(risk_score, 0.58) - - return { - "decision_level": "project" if project else "portfolio", - "decision_object_type": "project" if project else "portfolio", - "decision_object_id": _safe_int((project or {}).get("id")), - "risk_score": round(risk_score, 3), - "budget_impact": { - "budget_id": budget.get("budget_id"), - "usage_ratio": budget_ratio, - "hard_limit_enabled": bool(budget.get("hard_limit_enabled")), - }, - "resource_impact": { - "overloaded_user_ids": [row.get("user_id") for row in overloaded_people], - "active_project_count": active_projects, - }, - "rationale": ( - f"intent={intent.name}; " - f"project_scope={'project' if project else 'portfolio'}; " - f"budget_ratio={budget_ratio:.2f}; " - f"open_risks={risk_count}; " - f"overloaded={len(overloaded_people)}" - ), - "message_excerpt": message_text[:240], - } - - -class TacticalBrain: - """周/日执行计划。""" - - @staticmethod - def plan(*, intent: CanonicalIntent, world_model: dict[str, Any], decision: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: - project = world_model.get("project") if isinstance(world_model.get("project"), dict) else None - project_ref = f"项目#{project['id']}" if project and project.get("id") is not None else "项目群" - strategic_plan = { - "priority": "high" if float(decision.get("risk_score") or 0.0) >= 0.6 else "medium", - "objectives": [ - f"围绕 {project_ref} 控制风险与节奏", - "优先保证关键路径和资源边界不被破坏", - ], - "signals": [ - f"budget_usage_ratio={float((decision.get('budget_impact') or {}).get('usage_ratio') or 0.0):.2f}", - f"overloaded_users={len((decision.get('resource_impact') or {}).get('overloaded_user_ids') or [])}", - ], - } - execution_actions = [] - workflow_route = str((intent.metadata or {}).get("workflow_route") or "").strip().lower() - if intent.name == INTENT_QUERY: - execution_actions.append({"operation_code": "query.answer", "mode": "read_only"}) - elif intent.name == INTENT_PROJECT_CREATE: - execution_actions.append({"operation_code": "project.create", "mode": "write"}) - elif intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: - execution_actions.append({"operation_code": "signal.enter", "mode": "write"}) - execution_actions.append({"operation_code": "signal_source.onboard", "mode": "write"}) - elif intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: - execution_actions.append({"operation_code": "work_item.resume", "mode": "write"}) - elif intent.name == INTENT_SIGNAL_CREATE: - if workflow_route == ROUTE_PROJECT_SIGNAL_DISCOVER: - execution_actions.append({"operation_code": ROUTE_PROJECT_SIGNAL_DISCOVER, "mode": "write"}) - elif workflow_route == ROUTE_PROJECT_SIGNAL_COMMIT: - execution_actions.append({"operation_code": ROUTE_PROJECT_SIGNAL_COMMIT, "mode": "write"}) - elif workflow_route == ROUTE_PROJECT_SIGNAL_REFRESH: - execution_actions.append({"operation_code": ROUTE_PROJECT_SIGNAL_REFRESH, "mode": "write"}) - elif workflow_route == ROUTE_PROJECT_SIGNAL_CANCEL: - execution_actions.append({"operation_code": ROUTE_PROJECT_SIGNAL_CANCEL, "mode": "write"}) - else: - execution_actions.append({"operation_code": "signal.enter", "mode": "write"}) - - execution_plan = { - "intent": intent.name, - "project_id": intent.project_id, - "actions": execution_actions, - "summary": f"执行 {intent.name},共 {len(execution_actions)} 个 typed actions", - } - return strategic_plan, execution_plan - - -class SimulationEngine: - """执行前仿真。""" - - BUDGET_COST_BY_OPERATION = { - "query.answer": 1.0, - "project.create": 120.0, - "signal.enter": 8.0, - "signal_source.onboard": 40.0, - ROUTE_PROJECT_SIGNAL_DISCOVER: 3.0, - ROUTE_PROJECT_SIGNAL_COMMIT: 8.0, - ROUTE_PROJECT_SIGNAL_REFRESH: 3.0, - ROUTE_PROJECT_SIGNAL_CANCEL: 0.2, - } - HOURS_BY_OPERATION = { - "query.answer": 0.2, - "project.create": 12.0, - "signal.enter": 1.5, - "signal_source.onboard": 6.0, - ROUTE_PROJECT_SIGNAL_DISCOVER: 0.8, - ROUTE_PROJECT_SIGNAL_COMMIT: 1.5, - ROUTE_PROJECT_SIGNAL_REFRESH: 0.8, - ROUTE_PROJECT_SIGNAL_CANCEL: 0.1, - } - - @classmethod - def simulate(cls, *, intent: CanonicalIntent, world_model: dict[str, Any], execution_plan: dict[str, Any]) -> dict[str, Any]: - actions = execution_plan.get("actions") if isinstance(execution_plan.get("actions"), list) else [] - operation_codes = [str(item.get("operation_code") or "").strip() for item in actions if isinstance(item, dict)] - estimated_budget_delta = sum(cls.BUDGET_COST_BY_OPERATION.get(code, 0.0) for code in operation_codes) - estimated_capacity_delta = sum(cls.HOURS_BY_OPERATION.get(code, 0.0) for code in operation_codes) - - budget = world_model.get("budget") if isinstance(world_model.get("budget"), dict) else {} - budget_amount = float(budget.get("budget_amount_usd") or 0.0) - current_ratio = float(budget.get("usage_ratio") or 0.0) - projected_budget_ratio = current_ratio + (estimated_budget_delta / budget_amount if budget_amount > 0 else 0.0) - - capacity = world_model.get("capacity") if isinstance(world_model.get("capacity"), list) else [] - current_peak_ratio = max([_normalize_workload_ratio(item.get("workload_ratio")) for item in capacity], default=0.0) - project_member_count = max(1, int(world_model.get("member_count") or len(capacity) or 1)) - projected_capacity_peak_ratio = current_peak_ratio + (estimated_capacity_delta / (40.0 * project_member_count)) - - task_summary = world_model.get("task_summary") if isinstance(world_model.get("task_summary"), dict) else {} - blocked_count = int((task_summary.get("by_status") or {}).get("blocked") or 0) - overdue_count = int(task_summary.get("overdue_count") or 0) - missing_skill_tag_ids = task_summary.get("uncovered_skill_tag_ids") if isinstance(task_summary.get("uncovered_skill_tag_ids"), list) else [] - - charter = world_model.get("charter") if isinstance(world_model.get("charter"), list) else [] - dependency_pressure = "high" if blocked_count > 0 or overdue_count > 0 else ("medium" if not charter else "low") - projected_risk_score = min( - 1.0, - 0.12 - + projected_budget_ratio * 0.24 - + projected_capacity_peak_ratio * 0.28 - + blocked_count * 0.08 - + overdue_count * 0.05 - + len(missing_skill_tag_ids) * 0.07, - ) - - return { - "intent": intent.name, - "operation_codes": operation_codes, - "estimated_budget_delta_usd": round(estimated_budget_delta, 2), - "projected_budget_usage_ratio": round(projected_budget_ratio, 4), - "estimated_capacity_delta_hours": round(estimated_capacity_delta, 2), - "projected_capacity_peak_ratio": round(projected_capacity_peak_ratio, 4), - "blocked_task_count": blocked_count, - "overdue_task_count": overdue_count, - "dependency_pressure": dependency_pressure, - "missing_skill_tag_ids": missing_skill_tag_ids[:20], - "missing_skill_count": len(missing_skill_tag_ids), - "projected_risk_score": round(projected_risk_score, 4), - } - - -class ExecutionPolicyBrain: - """自动执行边界。""" - - def __init__(self, *, policy_gate_rules: tuple[tuple[str, tuple[str, ...]], ...]) -> None: - self.policy_gate_rules = policy_gate_rules - - def evaluate( - self, - *, - message_text: str, - world_model: dict[str, Any], - intent: CanonicalIntent, - simulation: dict[str, Any], - ) -> dict[str, Any]: - normalized = re.sub(r"\s+", "", str(message_text or "")).lower() - for gate_code, tokens in self.policy_gate_rules: - if any(token.lower().replace(" ", "") in normalized for token in tokens): - return { - "auto_execute": False, - "policy_gate_hit": True, - "policy_gate_code": gate_code, - "approval_requirement": { - "required": True, - "reason": gate_code, - "message": "命中硬策略边界,必须进入显式审批/人工确认。", - }, - } - - if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD and intent.project_id is None: - return { - "auto_execute": False, - "policy_gate_hit": True, - "policy_gate_code": "missing_project_scope", - "approval_requirement": { - "required": True, - "reason": "missing_project_scope", - "message": "信号源接入缺少项目归属,必须先确认项目。", - }, - } - - if intent.name in {INTENT_QUERY, INTENT_SIGNAL_CREATE}: - return { - "auto_execute": True, - "policy_gate_hit": False, - "policy_gate_code": None, - "approval_requirement": {"required": False}, - } - - budget = world_model.get("budget") if isinstance(world_model.get("budget"), dict) else {} - if bool(budget.get("hard_limit_enabled")) and float(budget.get("usage_ratio") or 0.0) >= 1.0: - return { - "auto_execute": False, - "policy_gate_hit": True, - "policy_gate_code": "budget_hard_limit", - "approval_requirement": { - "required": True, - "reason": "budget_hard_limit", - "message": "项目预算已触达硬限制,自动执行被拦截。", - }, - } - - if float(simulation.get("projected_budget_usage_ratio") or 0.0) >= 1.0: - return { - "auto_execute": False, - "policy_gate_hit": True, - "policy_gate_code": "simulation_budget_limit", - "approval_requirement": { - "required": True, - "reason": "simulation_budget_limit", - "message": "执行前仿真显示预算将触顶,自动执行被拦截。", - }, - } - - if float(simulation.get("projected_capacity_peak_ratio") or 0.0) >= 1.0: - return { - "auto_execute": False, - "policy_gate_hit": True, - "policy_gate_code": "simulation_capacity_limit", - "approval_requirement": { - "required": True, - "reason": "simulation_capacity_limit", - "message": "执行前仿真显示产能会超载,自动执行被拦截。", - }, - } - - if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD and int(simulation.get("missing_skill_count") or 0) > 0: - return { - "auto_execute": False, - "policy_gate_hit": True, - "policy_gate_code": "simulation_skill_gap", - "approval_requirement": { - "required": True, - "reason": "simulation_skill_gap", - "message": "执行前仿真显示当前项目存在技能缺口,信号源接入先进入待确认。", - }, - } - - if intent.name in {INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_SIGNAL_CREATE} and str(simulation.get("dependency_pressure") or "") == "high": - return { - "auto_execute": False, - "policy_gate_hit": True, - "policy_gate_code": "simulation_dependency_pressure", - "approval_requirement": { - "required": True, - "reason": "simulation_dependency_pressure", - "message": "执行前仿真显示关键依赖压力过高,当前先进入待人工确认。", - }, - } - - return { - "auto_execute": True, - "policy_gate_hit": False, - "policy_gate_code": None, - "approval_requirement": {"required": False}, - } - - -class DecisionEngine: - """汇总三脑输出。""" - - def __init__(self, *, policy_gate_rules: tuple[tuple[str, tuple[str, ...]], ...]) -> None: - self.strategic_brain = StrategicBrain() - self.tactical_brain = TacticalBrain() - self.simulation_engine = SimulationEngine() - self.execution_policy_brain = ExecutionPolicyBrain(policy_gate_rules=policy_gate_rules) - - def decide(self, *, intent: CanonicalIntent, world_model: dict[str, Any], message_text: str) -> dict[str, Any]: - strategic = self.strategic_brain.evaluate(intent=intent, world_model=world_model, message_text=message_text) - strategic_plan, execution_plan = self.tactical_brain.plan(intent=intent, world_model=world_model, decision=strategic) - simulation = self.simulation_engine.simulate(intent=intent, world_model=world_model, execution_plan=execution_plan) - strategic_plan["simulation"] = simulation - execution_plan["simulation"] = simulation - policy = self.execution_policy_brain.evaluate( - message_text=message_text, - world_model=world_model, - intent=intent, - simulation=simulation, - ) - strategic["risk_score"] = max(float(strategic.get("risk_score") or 0.0), float(simulation.get("projected_risk_score") or 0.0)) - return { - "strategic": strategic, - "strategic_plan": strategic_plan, - "execution_plan": execution_plan, - "simulation": simulation, - "policy": policy, - } - - -def _safe_int(value: Any) -> int | None: - try: - if value is None or value == "": - return None - return int(value) - except (TypeError, ValueError): - return None +__all__ = [ + "StrategicBrain", + "TacticalBrain", + "SimulationEngine", + "ExecutionPolicyBrain", + "DecisionEngine", + "_normalize_workload_ratio", + "_safe_int", +] diff --git a/backend/app/services/ai_pmo_intent/__init__.py b/backend/app/services/ai_pmo_intent/__init__.py new file mode 100644 index 00000000..ef1c7c0e --- /dev/null +++ b/backend/app/services/ai_pmo_intent/__init__.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from .shared import * +from .models import CanonicalIntent, WorkflowRoute, _safe_int +from .route_resolver import RouteResolver +from .intent_resolver import IntentResolver + +__all__ = [ + "INTENT_QUERY", + "INTENT_PROJECT_CREATE", + "INTENT_SIGNAL_CREATE", + "INTENT_SIGNAL_SOURCE_ONBOARD", + "INTENT_FOLLOWUP_CONFIRM", + "INTENT_FOLLOWUP_CONTINUE", + "INTENT_IGNORE", + "FOLLOWUP_TARGET_INTENTS", + "ROUTE_FAMILY_QUERY", + "ROUTE_FAMILY_PROJECT_CREATE", + "ROUTE_FAMILY_PROJECT_SIGNAL", + "ROUTE_FAMILY_SIGNAL_SOURCE", + "ROUTE_FAMILY_FOLLOWUP", + "ROUTE_ACTION_ANSWER", + "ROUTE_ACTION_CREATE", + "ROUTE_ACTION_DISCOVER", + "ROUTE_ACTION_COMMIT", + "ROUTE_ACTION_CANCEL", + "ROUTE_ACTION_REFRESH", + "ROUTE_ACTION_ONBOARD", + "ROUTE_ACTION_CONTINUE", + "ROUTE_ACTION_CONFIRM", + "ROUTE_QUERY_ANSWER", + "ROUTE_PROJECT_CREATE", + "ROUTE_PROJECT_SIGNAL_CREATE", + "ROUTE_PROJECT_SIGNAL_DISCOVER", + "ROUTE_PROJECT_SIGNAL_COMMIT", + "ROUTE_PROJECT_SIGNAL_CANCEL", + "ROUTE_PROJECT_SIGNAL_REFRESH", + "ROUTE_SIGNAL_SOURCE_ONBOARD", + "ROUTE_FOLLOWUP_CONFIRM", + "ROUTE_FOLLOWUP_CONTINUE", + "WORKFLOW_ROUTE_KEYS", + "ROUTE_CANONICAL_INTENT_MAP", + "LEGACY_INTENT_ROUTE_MAP", + "PROJECT_SIGNAL_DISCOVER_HINTS", + "FOLLOWUP_REFRESH_HINTS", + "FOLLOWUP_CANCEL_HINTS", + "FOLLOWUP_COMMIT_HINTS", + "QUESTION_HINTS", + "SMALLTALK_EXACT", + "SMALLTALK_SHORT_HINTS", + "SMALLTALK_STRUCTURED_HINTS", + "FOLLOWUP_CONFIRM_HINTS", + "FOLLOWUP_CONTINUE_HINTS", + "FOLLOWUP_WORK_ITEM_ID_RE", + "SIGNAL_CREATE_HINTS", + "PROJECT_CREATE_HINTS", + "SESSION_SHORT_CONFIRM_WINDOW_SECONDS", + "SHORT_CONFIRM_MAX_MESSAGE_LENGTH", + "SHORT_CONFIRM_HINTS", + "INTENT_LLM_FUNCTION_NAME", + "INTENT_CANDIDATES", + "WRITE_INTENTS", + "INTENT_CONFLICT_POLICIES", + "_safe_int", + "CanonicalIntent", + "WorkflowRoute", + "RouteResolver", + "IntentResolver", +] diff --git a/backend/app/services/ai_pmo_intent/intent_resolver.py b/backend/app/services/ai_pmo_intent/intent_resolver.py new file mode 100644 index 00000000..1d369c3c --- /dev/null +++ b/backend/app/services/ai_pmo_intent/intent_resolver.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +from .models import * + +class IntentResolver: + """Resolve raw text to canonical intent.""" + + OPERATION_VERBS = ( + "新增", + "增加", + "添加", + "加", + "加一个", + "加个", + "新建", + "创建", + "建立", + "立项", + "接入", + "修复", + "优化", + "改成", + "改为", + "更新", + "分配", + "指派", + "归档", + "执行", + ) + + @staticmethod + def _looks_like_negated_write_statement(*, text: str) -> bool: + normalized = re.sub(r"\s+", "", str(text or "")).lower() + if not normalized: + return False + negation_tokens = ("别", "别再", "别乱", "不要", "不要再", "先别", "先不要", "禁止", "不能", "无需", "不用") + if not any(token in normalized for token in negation_tokens): + return False + write_tokens = ("创建", "新建", "新增", "增加", "添加", "加", "接入", "执行", "分配", "指派", "立项") + object_tokens = ("信号", "需求", "任务", "项目", "事项", "工单", "流程", "信号源") + if any(w in normalized for w in write_tokens) and any(o in normalized for o in object_tokens): + return True + return bool( + re.search( + r"(?:别|不要|先别|先不要|禁止|不能).{0,8}(?:创建|新建|新增|增加|添加|加|接入|执行|分配|指派|立项)", + normalized, + re.IGNORECASE, + ) + ) + + @staticmethod + def resolve(*, text: str, project_id: Optional[int]) -> CanonicalIntent: + normalized = re.sub(r"\s+", "", str(text or "")).lower() + pretty = re.sub(r"\s+", " ", str(text or "")).strip() + if not pretty: + return CanonicalIntent(INTENT_IGNORE, 1.0, project_id, "", "", {"reason": "empty"}) + if IntentResolver._is_smalltalk_message(pretty=pretty, normalized=normalized): + return CanonicalIntent(INTENT_IGNORE, 0.95, project_id, "", "", {"reason": "smalltalk"}) + if IntentResolver._looks_like_negated_write_statement(text=pretty): + return CanonicalIntent( + INTENT_QUERY, + 0.9, + project_id, + "", + pretty[:300], + {"reason": "negated_write_statement"}, + ) + explicit_followup_target = FOLLOWUP_WORK_ITEM_ID_RE.search(pretty) + if explicit_followup_target is not None: + return CanonicalIntent( + INTENT_FOLLOWUP_CONTINUE, + 0.92, + project_id, + "", + "", + { + "followup_by_work_item_id": True, + "target_work_item_id": _safe_int(explicit_followup_target.group(1)), + }, + ) + if any(token in pretty for token in FOLLOWUP_CONFIRM_HINTS): + return CanonicalIntent(INTENT_FOLLOWUP_CONFIRM, 0.82, project_id, "", "", {}) + if any(token in pretty for token in FOLLOWUP_CONTINUE_HINTS): + return CanonicalIntent(INTENT_FOLLOWUP_CONTINUE, 0.82, project_id, "", "", {}) + if SignalSourceOrchestrationService.looks_like_signal_source_request(pretty): + return CanonicalIntent( + INTENT_SIGNAL_SOURCE_ONBOARD, + 0.92, + project_id, + IntentResolver._build_signal_title(pretty, default="新增信号源接入请求"), + pretty[:300], + {"source_request": True}, + ) + if ( + IntentResolver._looks_like_project_create_request(text=pretty) + or extract_project_create_title(pretty) + ): + return CanonicalIntent( + INTENT_PROJECT_CREATE, + 0.9, + project_id, + IntentResolver._build_signal_title(pretty, default="新项目创建请求"), + pretty[:300], + {}, + ) + if IntentResolver._looks_like_signal_create_request(text=pretty): + return CanonicalIntent( + INTENT_SIGNAL_CREATE, + 0.78, + project_id, + IntentResolver._build_signal_title(pretty, default="新信号"), + pretty[:300], + {}, + ) + if IntentResolver._looks_like_query(text=pretty): + return CanonicalIntent( + INTENT_QUERY, + 0.88, + project_id, + "", + pretty[:300], + {}, + ) + return CanonicalIntent( + INTENT_QUERY, + 0.6, + project_id, + "", + pretty[:300], + {"fallback": True, "reason": "default_query_fallback"}, + ) + + @staticmethod + def _looks_like_signal_create_request(*, text: str) -> bool: + normalized = re.sub(r"\s+", "", str(text or "")).lower() + if not normalized: + return False + if IntentResolver._looks_like_negated_write_statement(text=normalized): + return False + candidate_discover_hints = ("候选", "换一批", "刷新", "再来一批", "先分析") + if ( + any(token in normalized for token in candidate_discover_hints) + and any(token in normalized for token in ("确认", "入库", "提交")) + and any(token in normalized for token in ("项目", "project")) + ): + return True + if re.search(r"(?:抓|找|给).{0,8}(?:\d+|[一二两三四五])(?:条|个)?候选", normalized, re.IGNORECASE): + return True + if any(token in normalized for token in SIGNAL_CREATE_HINTS): + return True + signal_object_hints = ("信号", "需求", "任务", "事项", "问题", "改造", "优化") + has_operation = any(token in normalized for token in IntentResolver.OPERATION_VERBS) + has_signal_object = any(token in normalized for token in signal_object_hints) + if has_operation and has_signal_object: + return True + return bool( + re.search( + r"(?:给|帮|请).{0,20}(?:新增|增加|添加|补充|加).{0,20}(?:信号|需求|任务|事项)", + normalized, + re.IGNORECASE, + ) + ) + + @staticmethod + def _is_smalltalk_message(*, pretty: str, normalized: str) -> bool: + compact = re.sub(r"[\\s\\??!!,,。~~、]+", "", str(pretty or "")).strip().lower() + if not compact: + return True + if any(keyword in compact for keyword in SMALLTALK_STRUCTURED_HINTS): + return False + if compact in SMALLTALK_EXACT: + return True + if len(compact) <= 8 and any(keyword in compact for keyword in SMALLTALK_SHORT_HINTS): + return True + return False + + @staticmethod + def _looks_like_query(*, text: str) -> bool: + normalized = re.sub(r"\s+", "", str(text or "")).lower() + if not normalized: + return False + if IntentResolver._looks_like_signal_create_request(text=normalized): + return False + if any(token in normalized for token in QUESTION_HINTS): + return True + has_question_structure = ( + "?" in normalized + or "?" in normalized + or any(token in normalized for token in ("吗", "呢", "吧", "是否", "是不是")) + ) + if not has_question_structure: + return False + has_operation = any(token in normalized for token in IntentResolver.OPERATION_VERBS) + return not has_operation + + @staticmethod + def _looks_like_project_create_request(*, text: str) -> bool: + normalized = re.sub(r"\s+", "", str(text or "")).lower() + if not normalized: + return False + if any(token in normalized for token in PROJECT_CREATE_HINTS): + return True + return bool( + re.search( + r"(?:新增|增加|添加|新建|创建|建立|开个|开一个|建个|建一个|做个|做一个|加个|加一个|立项)(?:一个|个)?项目", + normalized, + re.IGNORECASE, + ) + ) + + @staticmethod + def _build_signal_title(text: str, *, default: str) -> str: + raw = str(text or "").strip() + return raw or default + diff --git a/backend/app/services/ai_pmo_intent/models.py b/backend/app/services/ai_pmo_intent/models.py new file mode 100644 index 00000000..1691afe5 --- /dev/null +++ b/backend/app/services/ai_pmo_intent/models.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from .shared import * + +def _safe_int(value: Any) -> Optional[int]: + try: + if value is None or value == "": + return None + return int(value) + except (TypeError, ValueError): + return None + +class CanonicalIntent: + name: str + confidence: float + project_id: Optional[int] + signal_title: str + signal_summary: str + metadata: dict[str, Any] + +class WorkflowRoute: + family: str + action: str + project_id: Optional[int] + requested_count: int + requires_confirmation: bool + metadata: dict[str, Any] + + @property + def key(self) -> str: + return f"{self.family}.{self.action}" + diff --git a/backend/app/services/ai_pmo_intent/route_resolver.py b/backend/app/services/ai_pmo_intent/route_resolver.py new file mode 100644 index 00000000..135f32bc --- /dev/null +++ b/backend/app/services/ai_pmo_intent/route_resolver.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +from .models import * + +class RouteResolver: + """Map canonical intent + text into workflow route.""" + + @staticmethod + def _split_route_key(route_key: str) -> tuple[str, str]: + normalized = str(route_key or "").strip().lower() + if "." not in normalized: + return ROUTE_FAMILY_QUERY, ROUTE_ACTION_ANSWER + family, action = normalized.split(".", 1) + return family.strip() or ROUTE_FAMILY_QUERY, action.strip() or ROUTE_ACTION_ANSWER + + @classmethod + def normalize_route_key(cls, value: str) -> str: + normalized = str(value or "").strip().lower() + if not normalized: + return ROUTE_QUERY_ANSWER + mapped = LEGACY_INTENT_ROUTE_MAP.get(normalized) + if mapped: + return mapped + if normalized in WORKFLOW_ROUTE_KEYS: + return normalized + family, action = cls._split_route_key(normalized) + if family and action: + return f"{family}.{action}" + return ROUTE_QUERY_ANSWER + + @classmethod + def canonical_intent_for_route(cls, route_key: str) -> str: + normalized = cls.normalize_route_key(route_key) + mapped = ROUTE_CANONICAL_INTENT_MAP.get(normalized) + if mapped: + return mapped + family, _ = cls._split_route_key(normalized) + if family == ROUTE_FAMILY_PROJECT_SIGNAL: + return INTENT_SIGNAL_CREATE + if family == ROUTE_FAMILY_SIGNAL_SOURCE: + return INTENT_SIGNAL_SOURCE_ONBOARD + if family == ROUTE_FAMILY_PROJECT_CREATE: + return INTENT_PROJECT_CREATE + if family == ROUTE_FAMILY_FOLLOWUP: + return INTENT_FOLLOWUP_CONTINUE + return INTENT_QUERY + + @staticmethod + def _parse_chinese_count_token(token: str) -> Optional[int]: + cleaned = str(token or "").strip() + if not cleaned: + return None + map_digit = { + "零": 0, + "一": 1, + "二": 2, + "两": 2, + "三": 3, + "四": 4, + "五": 5, + "六": 6, + "七": 7, + "八": 8, + "九": 9, + "十": 10, + } + if cleaned in map_digit: + return map_digit[cleaned] + if len(cleaned) == 2 and cleaned[0] == "十" and cleaned[1] in map_digit: + return 10 + map_digit[cleaned[1]] + if len(cleaned) == 2 and cleaned[1] == "十" and cleaned[0] in map_digit: + return map_digit[cleaned[0]] * 10 + if len(cleaned) == 3 and cleaned[1] == "十" and cleaned[0] in map_digit and cleaned[2] in map_digit: + return map_digit[cleaned[0]] * 10 + map_digit[cleaned[2]] + return None + + @classmethod + def extract_requested_count(cls, *, message_text: str, metadata: dict[str, Any]) -> int: + metadata_count = _safe_int(metadata.get("requested_count")) if isinstance(metadata, dict) else None + if metadata_count is not None and metadata_count > 0: + return min(max(metadata_count, 1), 5) + normalized_text = str(message_text or "") + digit_match = re.search(r"(\d{1,2})\s*(?:个|条|项)?\s*(?:信号|候选)", normalized_text, flags=re.IGNORECASE) + if digit_match: + return min(max(int(digit_match.group(1)), 1), 5) + chinese_match = re.search(r"([零一二两三四五六七八九十]{1,3})\s*(?:个|条|项)?\s*(?:信号|候选)", normalized_text) + if chinese_match: + parsed = cls._parse_chinese_count_token(chinese_match.group(1)) + if parsed is not None and parsed > 0: + return min(max(parsed, 1), 5) + return 1 + + @staticmethod + def looks_like_project_signal_discover(*, message_text: str, metadata: dict[str, Any]) -> bool: + explicit_route = str(metadata.get("workflow_route") or "").strip().lower() if isinstance(metadata, dict) else "" + if explicit_route == ROUTE_PROJECT_SIGNAL_DISCOVER: + return True + normalized = re.sub(r"\s+", "", str(message_text or "")).lower() + if not normalized: + return False + has_discover_hint = any(token in normalized for token in PROJECT_SIGNAL_DISCOVER_HINTS) + has_signal_hint = any(token in normalized for token in ("信号", "候选", "分析", "拆分")) + return has_discover_hint and has_signal_hint + + @staticmethod + def parse_followup_action(*, message_text: str, canonical_intent: str) -> str: + normalized = re.sub(r"\s+", "", str(message_text or "")).lower() + if any(token in normalized for token in FOLLOWUP_REFRESH_HINTS): + return ROUTE_ACTION_REFRESH + if any(token in normalized for token in FOLLOWUP_CANCEL_HINTS): + return ROUTE_ACTION_CANCEL + if any(token in normalized for token in FOLLOWUP_COMMIT_HINTS): + return ROUTE_ACTION_COMMIT + if canonical_intent in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: + return ROUTE_ACTION_COMMIT + return ROUTE_ACTION_CONTINUE + + @classmethod + def from_canonical( + cls, + *, + canonical_intent: CanonicalIntent, + message_text: str, + ) -> WorkflowRoute: + metadata = dict(canonical_intent.metadata or {}) + route_key = LEGACY_INTENT_ROUTE_MAP.get(canonical_intent.name, ROUTE_QUERY_ANSWER) + if canonical_intent.name == INTENT_SIGNAL_CREATE: + route_key = ( + ROUTE_PROJECT_SIGNAL_DISCOVER + if cls.looks_like_project_signal_discover(message_text=message_text, metadata=metadata) + else ROUTE_PROJECT_SIGNAL_CREATE + ) + elif canonical_intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: + action = cls.parse_followup_action(message_text=message_text, canonical_intent=canonical_intent.name) + if action == ROUTE_ACTION_CONFIRM: + route_key = ROUTE_FOLLOWUP_CONFIRM + else: + route_key = ROUTE_FOLLOWUP_CONTINUE + metadata["followup_action"] = action + family, action = cls._split_route_key(route_key) + requested_count = cls.extract_requested_count(message_text=message_text, metadata=metadata) + if family != ROUTE_FAMILY_PROJECT_SIGNAL: + requested_count = 1 + requires_confirmation = bool(metadata.get("requires_confirmation")) or action in { + ROUTE_ACTION_DISCOVER, + ROUTE_ACTION_COMMIT, + ROUTE_ACTION_CANCEL, + ROUTE_ACTION_REFRESH, + } + metadata["workflow_route"] = f"{family}.{action}" + metadata["workflow_family"] = family + metadata["workflow_action"] = action + return WorkflowRoute( + family=family, + action=action, + project_id=canonical_intent.project_id, + requested_count=requested_count, + requires_confirmation=requires_confirmation, + metadata=metadata, + ) + + @classmethod + def from_structured_payload( + cls, + *, + payload: dict[str, Any], + message_text: str, + fallback_project_id: Optional[int], + ) -> Optional[WorkflowRoute]: + family = str(payload.get("family") or "").strip().lower() + action = str(payload.get("action") or "").strip().lower() + if not family or not action: + intent_name = str(payload.get("intent") or "").strip().lower() + route_key = cls.normalize_route_key(intent_name) + family, action = cls._split_route_key(route_key) + route_key = cls.normalize_route_key(f"{family}.{action}") + family, action = cls._split_route_key(route_key) + if family == ROUTE_FAMILY_PROJECT_SIGNAL and action not in { + ROUTE_ACTION_CREATE, + ROUTE_ACTION_DISCOVER, + ROUTE_ACTION_COMMIT, + ROUTE_ACTION_CANCEL, + ROUTE_ACTION_REFRESH, + }: + return None + if f"{family}.{action}" not in WORKFLOW_ROUTE_KEYS and family != ROUTE_FAMILY_PROJECT_SIGNAL: + return None + requested_count = cls.extract_requested_count( + message_text=message_text, + metadata=payload if isinstance(payload, dict) else {}, + ) + if family != ROUTE_FAMILY_PROJECT_SIGNAL: + requested_count = 1 + project_id = _safe_int(payload.get("project_id")) + if project_id is None: + project_id = fallback_project_id + if project_id is not None and int(project_id) <= 0: + project_id = None + requires_confirmation = bool(payload.get("requires_confirmation")) or action in { + ROUTE_ACTION_DISCOVER, + ROUTE_ACTION_COMMIT, + ROUTE_ACTION_CANCEL, + ROUTE_ACTION_REFRESH, + } + metadata = dict(payload) + metadata["workflow_route"] = f"{family}.{action}" + metadata["workflow_family"] = family + metadata["workflow_action"] = action + metadata["requested_count"] = requested_count + return WorkflowRoute( + family=family, + action=action, + project_id=project_id, + requested_count=requested_count, + requires_confirmation=requires_confirmation, + metadata=metadata, + ) + diff --git a/backend/app/services/ai_pmo_intent/shared.py b/backend/app/services/ai_pmo_intent/shared.py new file mode 100644 index 00000000..528864e5 --- /dev/null +++ b/backend/app/services/ai_pmo_intent/shared.py @@ -0,0 +1,212 @@ +"""AI PMO intent/route constants and resolvers.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any, Optional + +from .project_command_utils import extract_project_create_title +from .signal_source_orchestration_service import SignalSourceOrchestrationService + +INTENT_QUERY = "query.read_only" +INTENT_PROJECT_CREATE = "project.create" +INTENT_SIGNAL_CREATE = "signal.create" +INTENT_SIGNAL_SOURCE_ONBOARD = "signal_source.onboard" +INTENT_FOLLOWUP_CONFIRM = "followup.confirm" +INTENT_FOLLOWUP_CONTINUE = "followup.continue" +INTENT_IGNORE = "ignore" +FOLLOWUP_TARGET_INTENTS = { + INTENT_PROJECT_CREATE, + INTENT_SIGNAL_CREATE, + INTENT_SIGNAL_SOURCE_ONBOARD, +} + +ROUTE_FAMILY_QUERY = "query" +ROUTE_FAMILY_PROJECT_CREATE = "project_create" +ROUTE_FAMILY_PROJECT_SIGNAL = "project_signal" +ROUTE_FAMILY_SIGNAL_SOURCE = "signal_source" +ROUTE_FAMILY_FOLLOWUP = "followup" + +ROUTE_ACTION_ANSWER = "answer" +ROUTE_ACTION_CREATE = "create" +ROUTE_ACTION_DISCOVER = "discover" +ROUTE_ACTION_COMMIT = "commit" +ROUTE_ACTION_CANCEL = "cancel" +ROUTE_ACTION_REFRESH = "refresh" +ROUTE_ACTION_ONBOARD = "onboard" +ROUTE_ACTION_CONTINUE = "continue" +ROUTE_ACTION_CONFIRM = "confirm" + +ROUTE_QUERY_ANSWER = f"{ROUTE_FAMILY_QUERY}.{ROUTE_ACTION_ANSWER}" +ROUTE_PROJECT_CREATE = f"{ROUTE_FAMILY_PROJECT_CREATE}.{ROUTE_ACTION_CREATE}" +ROUTE_PROJECT_SIGNAL_CREATE = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_CREATE}" +ROUTE_PROJECT_SIGNAL_DISCOVER = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_DISCOVER}" +ROUTE_PROJECT_SIGNAL_COMMIT = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_COMMIT}" +ROUTE_PROJECT_SIGNAL_CANCEL = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_CANCEL}" +ROUTE_PROJECT_SIGNAL_REFRESH = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_REFRESH}" +ROUTE_SIGNAL_SOURCE_ONBOARD = f"{ROUTE_FAMILY_SIGNAL_SOURCE}.{ROUTE_ACTION_ONBOARD}" +ROUTE_FOLLOWUP_CONFIRM = f"{ROUTE_FAMILY_FOLLOWUP}.{ROUTE_ACTION_CONFIRM}" +ROUTE_FOLLOWUP_CONTINUE = f"{ROUTE_FAMILY_FOLLOWUP}.{ROUTE_ACTION_CONTINUE}" + +WORKFLOW_ROUTE_KEYS = { + ROUTE_QUERY_ANSWER, + ROUTE_PROJECT_CREATE, + ROUTE_PROJECT_SIGNAL_CREATE, + ROUTE_PROJECT_SIGNAL_DISCOVER, + ROUTE_PROJECT_SIGNAL_COMMIT, + ROUTE_PROJECT_SIGNAL_CANCEL, + ROUTE_PROJECT_SIGNAL_REFRESH, + ROUTE_SIGNAL_SOURCE_ONBOARD, + ROUTE_FOLLOWUP_CONFIRM, + ROUTE_FOLLOWUP_CONTINUE, +} + +ROUTE_CANONICAL_INTENT_MAP: dict[str, str] = { + ROUTE_QUERY_ANSWER: INTENT_QUERY, + ROUTE_PROJECT_CREATE: INTENT_PROJECT_CREATE, + ROUTE_PROJECT_SIGNAL_CREATE: INTENT_SIGNAL_CREATE, + ROUTE_PROJECT_SIGNAL_DISCOVER: INTENT_SIGNAL_CREATE, + ROUTE_PROJECT_SIGNAL_COMMIT: INTENT_SIGNAL_CREATE, + ROUTE_PROJECT_SIGNAL_CANCEL: INTENT_SIGNAL_CREATE, + ROUTE_PROJECT_SIGNAL_REFRESH: INTENT_SIGNAL_CREATE, + ROUTE_SIGNAL_SOURCE_ONBOARD: INTENT_SIGNAL_SOURCE_ONBOARD, + ROUTE_FOLLOWUP_CONFIRM: INTENT_FOLLOWUP_CONFIRM, + ROUTE_FOLLOWUP_CONTINUE: INTENT_FOLLOWUP_CONTINUE, +} + +LEGACY_INTENT_ROUTE_MAP: dict[str, str] = { + INTENT_QUERY: ROUTE_QUERY_ANSWER, + INTENT_PROJECT_CREATE: ROUTE_PROJECT_CREATE, + INTENT_SIGNAL_CREATE: ROUTE_PROJECT_SIGNAL_CREATE, + INTENT_SIGNAL_SOURCE_ONBOARD: ROUTE_SIGNAL_SOURCE_ONBOARD, + INTENT_FOLLOWUP_CONFIRM: ROUTE_FOLLOWUP_CONFIRM, + INTENT_FOLLOWUP_CONTINUE: ROUTE_FOLLOWUP_CONTINUE, + "query.answer": ROUTE_QUERY_ANSWER, + "project_create.create": ROUTE_PROJECT_CREATE, + "project_signal.create": ROUTE_PROJECT_SIGNAL_CREATE, + "project_signal.discover": ROUTE_PROJECT_SIGNAL_DISCOVER, + "project_signal.commit": ROUTE_PROJECT_SIGNAL_COMMIT, + "project_signal.cancel": ROUTE_PROJECT_SIGNAL_CANCEL, + "project_signal.refresh": ROUTE_PROJECT_SIGNAL_REFRESH, + "signal_source.onboard": ROUTE_SIGNAL_SOURCE_ONBOARD, +} + +PROJECT_SIGNAL_DISCOVER_HINTS = ( + "候选", + "先分析", + "先看候选", + "给我确认", + "待确认", + "再让我确认", + "确认后入库", + "先不要入库", + "先别入库", +) +FOLLOWUP_REFRESH_HINTS = ("换一批", "再来一批", "刷新候选", "重新找", "再找一组", "换一组") +FOLLOWUP_CANCEL_HINTS = ("取消", "先不做", "先别做", "不要了", "算了", "reject", "驳回") +FOLLOWUP_COMMIT_HINTS = ("确认", "继续处理", "继续执行", "同意", "批准", "approve") + +QUESTION_HINTS = ( + "?", + "?", + "怎么", + "如何", + "为何", + "为什么", + "什么", + "是什么", + "哪些", + "有哪些", + "特点", + "技术特点", + "能力", + "介绍", + "说明", + "多少", + "是否", + "帮我看", + "状态", + "风险", + "能做什么", + "可以做什么", +) +SMALLTALK_EXACT = { + "收到", + "收到啦", + "好的", + "ok", + "okk", + "yes", + "no", + "谢谢", + "thanks", + "thx", + "在吗", + "在不在", + "怎么样了", + "咋样了", + "辛苦了", +} +SMALLTALK_SHORT_HINTS = ("怎么样了", "咋样了", "在吗", "辛苦了", "收到", "谢谢", "ok") +SMALLTALK_STRUCTURED_HINTS = ("项目", "信号", "任务", "风险", "进度", "状态", "work_item", "工作项", "#") +FOLLOWUP_CONFIRM_HINTS = ("确认归属", "确认项目", "确认到项目", "确认关联", "确认这条") +FOLLOWUP_CONTINUE_HINTS = ("继续处理", "继续执行", "接着处理", "往下做", "继续推进") +FOLLOWUP_WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|队列项)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE) +SIGNAL_CREATE_HINTS = ("新增一个信号", "新增一条信号", "增加一个信号", "录入一个信号", "关注", "跟踪") +PROJECT_CREATE_HINTS = ( + "创建项目", + "新建项目", + "建立项目", + "做个项目", + "立项", + "开个项目", + "新增项目", + "新增个项目", + "新增一个项目", + "增加项目", + "增加个项目", + "增加一个项目", + "添加项目", + "添加一个项目", + "加个项目", + "加一个项目", +) +SESSION_SHORT_CONFIRM_WINDOW_SECONDS = 5 * 60 +SHORT_CONFIRM_MAX_MESSAGE_LENGTH = 10 +SHORT_CONFIRM_HINTS = ( + "是", + "好", + "对", + "确认", + "新增", + "加", + "嗯", + "yes", + "ok", + "好的", + "是的", + "行", + "可以", + "没问题", +) +INTENT_LLM_FUNCTION_NAME = "classify_intent_ai_pmo_kernel" +INTENT_CANDIDATES = ( + INTENT_SIGNAL_SOURCE_ONBOARD, + INTENT_SIGNAL_CREATE, + INTENT_QUERY, + INTENT_PROJECT_CREATE, + INTENT_FOLLOWUP_CONFIRM, + INTENT_FOLLOWUP_CONTINUE, + INTENT_IGNORE, +) +WRITE_INTENTS = { + INTENT_PROJECT_CREATE, + INTENT_SIGNAL_CREATE, + INTENT_SIGNAL_SOURCE_ONBOARD, + INTENT_FOLLOWUP_CONFIRM, + INTENT_FOLLOWUP_CONTINUE, +} +INTENT_CONFLICT_POLICIES = {"pending_manual", "prefer_rule", "prefer_llm"} + + diff --git a/backend/app/services/ai_pmo_intent_routing.py b/backend/app/services/ai_pmo_intent_routing.py index bc6fced9..f79dd324 100644 --- a/backend/app/services/ai_pmo_intent_routing.py +++ b/backend/app/services/ai_pmo_intent_routing.py @@ -1,670 +1,3 @@ -"""AI PMO intent/route constants and resolvers.""" - from __future__ import annotations -import re -from dataclasses import dataclass -from typing import Any, Optional - -from .project_command_utils import extract_project_create_title -from .signal_source_orchestration_service import SignalSourceOrchestrationService - -INTENT_QUERY = "query.read_only" -INTENT_PROJECT_CREATE = "project.create" -INTENT_SIGNAL_CREATE = "signal.create" -INTENT_SIGNAL_SOURCE_ONBOARD = "signal_source.onboard" -INTENT_FOLLOWUP_CONFIRM = "followup.confirm" -INTENT_FOLLOWUP_CONTINUE = "followup.continue" -INTENT_IGNORE = "ignore" -FOLLOWUP_TARGET_INTENTS = { - INTENT_PROJECT_CREATE, - INTENT_SIGNAL_CREATE, - INTENT_SIGNAL_SOURCE_ONBOARD, -} - -ROUTE_FAMILY_QUERY = "query" -ROUTE_FAMILY_PROJECT_CREATE = "project_create" -ROUTE_FAMILY_PROJECT_SIGNAL = "project_signal" -ROUTE_FAMILY_SIGNAL_SOURCE = "signal_source" -ROUTE_FAMILY_FOLLOWUP = "followup" - -ROUTE_ACTION_ANSWER = "answer" -ROUTE_ACTION_CREATE = "create" -ROUTE_ACTION_DISCOVER = "discover" -ROUTE_ACTION_COMMIT = "commit" -ROUTE_ACTION_CANCEL = "cancel" -ROUTE_ACTION_REFRESH = "refresh" -ROUTE_ACTION_ONBOARD = "onboard" -ROUTE_ACTION_CONTINUE = "continue" -ROUTE_ACTION_CONFIRM = "confirm" - -ROUTE_QUERY_ANSWER = f"{ROUTE_FAMILY_QUERY}.{ROUTE_ACTION_ANSWER}" -ROUTE_PROJECT_CREATE = f"{ROUTE_FAMILY_PROJECT_CREATE}.{ROUTE_ACTION_CREATE}" -ROUTE_PROJECT_SIGNAL_CREATE = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_CREATE}" -ROUTE_PROJECT_SIGNAL_DISCOVER = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_DISCOVER}" -ROUTE_PROJECT_SIGNAL_COMMIT = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_COMMIT}" -ROUTE_PROJECT_SIGNAL_CANCEL = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_CANCEL}" -ROUTE_PROJECT_SIGNAL_REFRESH = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_REFRESH}" -ROUTE_SIGNAL_SOURCE_ONBOARD = f"{ROUTE_FAMILY_SIGNAL_SOURCE}.{ROUTE_ACTION_ONBOARD}" -ROUTE_FOLLOWUP_CONFIRM = f"{ROUTE_FAMILY_FOLLOWUP}.{ROUTE_ACTION_CONFIRM}" -ROUTE_FOLLOWUP_CONTINUE = f"{ROUTE_FAMILY_FOLLOWUP}.{ROUTE_ACTION_CONTINUE}" - -WORKFLOW_ROUTE_KEYS = { - ROUTE_QUERY_ANSWER, - ROUTE_PROJECT_CREATE, - ROUTE_PROJECT_SIGNAL_CREATE, - ROUTE_PROJECT_SIGNAL_DISCOVER, - ROUTE_PROJECT_SIGNAL_COMMIT, - ROUTE_PROJECT_SIGNAL_CANCEL, - ROUTE_PROJECT_SIGNAL_REFRESH, - ROUTE_SIGNAL_SOURCE_ONBOARD, - ROUTE_FOLLOWUP_CONFIRM, - ROUTE_FOLLOWUP_CONTINUE, -} - -ROUTE_CANONICAL_INTENT_MAP: dict[str, str] = { - ROUTE_QUERY_ANSWER: INTENT_QUERY, - ROUTE_PROJECT_CREATE: INTENT_PROJECT_CREATE, - ROUTE_PROJECT_SIGNAL_CREATE: INTENT_SIGNAL_CREATE, - ROUTE_PROJECT_SIGNAL_DISCOVER: INTENT_SIGNAL_CREATE, - ROUTE_PROJECT_SIGNAL_COMMIT: INTENT_SIGNAL_CREATE, - ROUTE_PROJECT_SIGNAL_CANCEL: INTENT_SIGNAL_CREATE, - ROUTE_PROJECT_SIGNAL_REFRESH: INTENT_SIGNAL_CREATE, - ROUTE_SIGNAL_SOURCE_ONBOARD: INTENT_SIGNAL_SOURCE_ONBOARD, - ROUTE_FOLLOWUP_CONFIRM: INTENT_FOLLOWUP_CONFIRM, - ROUTE_FOLLOWUP_CONTINUE: INTENT_FOLLOWUP_CONTINUE, -} - -LEGACY_INTENT_ROUTE_MAP: dict[str, str] = { - INTENT_QUERY: ROUTE_QUERY_ANSWER, - INTENT_PROJECT_CREATE: ROUTE_PROJECT_CREATE, - INTENT_SIGNAL_CREATE: ROUTE_PROJECT_SIGNAL_CREATE, - INTENT_SIGNAL_SOURCE_ONBOARD: ROUTE_SIGNAL_SOURCE_ONBOARD, - INTENT_FOLLOWUP_CONFIRM: ROUTE_FOLLOWUP_CONFIRM, - INTENT_FOLLOWUP_CONTINUE: ROUTE_FOLLOWUP_CONTINUE, - "query.answer": ROUTE_QUERY_ANSWER, - "project_create.create": ROUTE_PROJECT_CREATE, - "project_signal.create": ROUTE_PROJECT_SIGNAL_CREATE, - "project_signal.discover": ROUTE_PROJECT_SIGNAL_DISCOVER, - "project_signal.commit": ROUTE_PROJECT_SIGNAL_COMMIT, - "project_signal.cancel": ROUTE_PROJECT_SIGNAL_CANCEL, - "project_signal.refresh": ROUTE_PROJECT_SIGNAL_REFRESH, - "signal_source.onboard": ROUTE_SIGNAL_SOURCE_ONBOARD, -} - -PROJECT_SIGNAL_DISCOVER_HINTS = ( - "候选", - "先分析", - "先看候选", - "给我确认", - "待确认", - "再让我确认", - "确认后入库", - "先不要入库", - "先别入库", -) -FOLLOWUP_REFRESH_HINTS = ("换一批", "再来一批", "刷新候选", "重新找", "再找一组", "换一组") -FOLLOWUP_CANCEL_HINTS = ("取消", "先不做", "先别做", "不要了", "算了", "reject", "驳回") -FOLLOWUP_COMMIT_HINTS = ("确认", "继续处理", "继续执行", "同意", "批准", "approve") - -QUESTION_HINTS = ( - "?", - "?", - "怎么", - "如何", - "为何", - "为什么", - "什么", - "是什么", - "哪些", - "有哪些", - "特点", - "技术特点", - "能力", - "介绍", - "说明", - "多少", - "是否", - "帮我看", - "状态", - "风险", - "能做什么", - "可以做什么", -) -SMALLTALK_EXACT = { - "收到", - "收到啦", - "好的", - "ok", - "okk", - "yes", - "no", - "谢谢", - "thanks", - "thx", - "在吗", - "在不在", - "怎么样了", - "咋样了", - "辛苦了", -} -SMALLTALK_SHORT_HINTS = ("怎么样了", "咋样了", "在吗", "辛苦了", "收到", "谢谢", "ok") -SMALLTALK_STRUCTURED_HINTS = ("项目", "信号", "任务", "风险", "进度", "状态", "work_item", "工作项", "#") -FOLLOWUP_CONFIRM_HINTS = ("确认归属", "确认项目", "确认到项目", "确认关联", "确认这条") -FOLLOWUP_CONTINUE_HINTS = ("继续处理", "继续执行", "接着处理", "往下做", "继续推进") -FOLLOWUP_WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|队列项)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE) -SIGNAL_CREATE_HINTS = ("新增一个信号", "新增一条信号", "增加一个信号", "录入一个信号", "关注", "跟踪") -PROJECT_CREATE_HINTS = ( - "创建项目", - "新建项目", - "建立项目", - "做个项目", - "立项", - "开个项目", - "新增项目", - "新增个项目", - "新增一个项目", - "增加项目", - "增加个项目", - "增加一个项目", - "添加项目", - "添加一个项目", - "加个项目", - "加一个项目", -) -SESSION_SHORT_CONFIRM_WINDOW_SECONDS = 5 * 60 -SHORT_CONFIRM_MAX_MESSAGE_LENGTH = 10 -SHORT_CONFIRM_HINTS = ( - "是", - "好", - "对", - "确认", - "新增", - "加", - "嗯", - "yes", - "ok", - "好的", - "是的", - "行", - "可以", - "没问题", -) -INTENT_LLM_FUNCTION_NAME = "classify_intent_ai_pmo_kernel" -INTENT_CANDIDATES = ( - INTENT_SIGNAL_SOURCE_ONBOARD, - INTENT_SIGNAL_CREATE, - INTENT_QUERY, - INTENT_PROJECT_CREATE, - INTENT_FOLLOWUP_CONFIRM, - INTENT_FOLLOWUP_CONTINUE, - INTENT_IGNORE, -) -WRITE_INTENTS = { - INTENT_PROJECT_CREATE, - INTENT_SIGNAL_CREATE, - INTENT_SIGNAL_SOURCE_ONBOARD, - INTENT_FOLLOWUP_CONFIRM, - INTENT_FOLLOWUP_CONTINUE, -} -INTENT_CONFLICT_POLICIES = {"pending_manual", "prefer_rule", "prefer_llm"} - - -def _safe_int(value: Any) -> Optional[int]: - try: - if value is None or value == "": - return None - return int(value) - except (TypeError, ValueError): - return None - - -@dataclass(slots=True) -class CanonicalIntent: - name: str - confidence: float - project_id: Optional[int] - signal_title: str - signal_summary: str - metadata: dict[str, Any] - - -@dataclass(slots=True) -class WorkflowRoute: - family: str - action: str - project_id: Optional[int] - requested_count: int - requires_confirmation: bool - metadata: dict[str, Any] - - @property - def key(self) -> str: - return f"{self.family}.{self.action}" - - -class RouteResolver: - """Map canonical intent + text into workflow route.""" - - @staticmethod - def _split_route_key(route_key: str) -> tuple[str, str]: - normalized = str(route_key or "").strip().lower() - if "." not in normalized: - return ROUTE_FAMILY_QUERY, ROUTE_ACTION_ANSWER - family, action = normalized.split(".", 1) - return family.strip() or ROUTE_FAMILY_QUERY, action.strip() or ROUTE_ACTION_ANSWER - - @classmethod - def normalize_route_key(cls, value: str) -> str: - normalized = str(value or "").strip().lower() - if not normalized: - return ROUTE_QUERY_ANSWER - mapped = LEGACY_INTENT_ROUTE_MAP.get(normalized) - if mapped: - return mapped - if normalized in WORKFLOW_ROUTE_KEYS: - return normalized - family, action = cls._split_route_key(normalized) - if family and action: - return f"{family}.{action}" - return ROUTE_QUERY_ANSWER - - @classmethod - def canonical_intent_for_route(cls, route_key: str) -> str: - normalized = cls.normalize_route_key(route_key) - mapped = ROUTE_CANONICAL_INTENT_MAP.get(normalized) - if mapped: - return mapped - family, _ = cls._split_route_key(normalized) - if family == ROUTE_FAMILY_PROJECT_SIGNAL: - return INTENT_SIGNAL_CREATE - if family == ROUTE_FAMILY_SIGNAL_SOURCE: - return INTENT_SIGNAL_SOURCE_ONBOARD - if family == ROUTE_FAMILY_PROJECT_CREATE: - return INTENT_PROJECT_CREATE - if family == ROUTE_FAMILY_FOLLOWUP: - return INTENT_FOLLOWUP_CONTINUE - return INTENT_QUERY - - @staticmethod - def _parse_chinese_count_token(token: str) -> Optional[int]: - cleaned = str(token or "").strip() - if not cleaned: - return None - map_digit = { - "零": 0, - "一": 1, - "二": 2, - "两": 2, - "三": 3, - "四": 4, - "五": 5, - "六": 6, - "七": 7, - "八": 8, - "九": 9, - "十": 10, - } - if cleaned in map_digit: - return map_digit[cleaned] - if len(cleaned) == 2 and cleaned[0] == "十" and cleaned[1] in map_digit: - return 10 + map_digit[cleaned[1]] - if len(cleaned) == 2 and cleaned[1] == "十" and cleaned[0] in map_digit: - return map_digit[cleaned[0]] * 10 - if len(cleaned) == 3 and cleaned[1] == "十" and cleaned[0] in map_digit and cleaned[2] in map_digit: - return map_digit[cleaned[0]] * 10 + map_digit[cleaned[2]] - return None - - @classmethod - def extract_requested_count(cls, *, message_text: str, metadata: dict[str, Any]) -> int: - metadata_count = _safe_int(metadata.get("requested_count")) if isinstance(metadata, dict) else None - if metadata_count is not None and metadata_count > 0: - return min(max(metadata_count, 1), 5) - normalized_text = str(message_text or "") - digit_match = re.search(r"(\d{1,2})\s*(?:个|条|项)?\s*(?:信号|候选)", normalized_text, flags=re.IGNORECASE) - if digit_match: - return min(max(int(digit_match.group(1)), 1), 5) - chinese_match = re.search(r"([零一二两三四五六七八九十]{1,3})\s*(?:个|条|项)?\s*(?:信号|候选)", normalized_text) - if chinese_match: - parsed = cls._parse_chinese_count_token(chinese_match.group(1)) - if parsed is not None and parsed > 0: - return min(max(parsed, 1), 5) - return 1 - - @staticmethod - def looks_like_project_signal_discover(*, message_text: str, metadata: dict[str, Any]) -> bool: - explicit_route = str(metadata.get("workflow_route") or "").strip().lower() if isinstance(metadata, dict) else "" - if explicit_route == ROUTE_PROJECT_SIGNAL_DISCOVER: - return True - normalized = re.sub(r"\s+", "", str(message_text or "")).lower() - if not normalized: - return False - has_discover_hint = any(token in normalized for token in PROJECT_SIGNAL_DISCOVER_HINTS) - has_signal_hint = any(token in normalized for token in ("信号", "候选", "分析", "拆分")) - return has_discover_hint and has_signal_hint - - @staticmethod - def parse_followup_action(*, message_text: str, canonical_intent: str) -> str: - normalized = re.sub(r"\s+", "", str(message_text or "")).lower() - if any(token in normalized for token in FOLLOWUP_REFRESH_HINTS): - return ROUTE_ACTION_REFRESH - if any(token in normalized for token in FOLLOWUP_CANCEL_HINTS): - return ROUTE_ACTION_CANCEL - if any(token in normalized for token in FOLLOWUP_COMMIT_HINTS): - return ROUTE_ACTION_COMMIT - if canonical_intent in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: - return ROUTE_ACTION_COMMIT - return ROUTE_ACTION_CONTINUE - - @classmethod - def from_canonical( - cls, - *, - canonical_intent: CanonicalIntent, - message_text: str, - ) -> WorkflowRoute: - metadata = dict(canonical_intent.metadata or {}) - route_key = LEGACY_INTENT_ROUTE_MAP.get(canonical_intent.name, ROUTE_QUERY_ANSWER) - if canonical_intent.name == INTENT_SIGNAL_CREATE: - route_key = ( - ROUTE_PROJECT_SIGNAL_DISCOVER - if cls.looks_like_project_signal_discover(message_text=message_text, metadata=metadata) - else ROUTE_PROJECT_SIGNAL_CREATE - ) - elif canonical_intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: - action = cls.parse_followup_action(message_text=message_text, canonical_intent=canonical_intent.name) - if action == ROUTE_ACTION_CONFIRM: - route_key = ROUTE_FOLLOWUP_CONFIRM - else: - route_key = ROUTE_FOLLOWUP_CONTINUE - metadata["followup_action"] = action - family, action = cls._split_route_key(route_key) - requested_count = cls.extract_requested_count(message_text=message_text, metadata=metadata) - if family != ROUTE_FAMILY_PROJECT_SIGNAL: - requested_count = 1 - requires_confirmation = bool(metadata.get("requires_confirmation")) or action in { - ROUTE_ACTION_DISCOVER, - ROUTE_ACTION_COMMIT, - ROUTE_ACTION_CANCEL, - ROUTE_ACTION_REFRESH, - } - metadata["workflow_route"] = f"{family}.{action}" - metadata["workflow_family"] = family - metadata["workflow_action"] = action - return WorkflowRoute( - family=family, - action=action, - project_id=canonical_intent.project_id, - requested_count=requested_count, - requires_confirmation=requires_confirmation, - metadata=metadata, - ) - - @classmethod - def from_structured_payload( - cls, - *, - payload: dict[str, Any], - message_text: str, - fallback_project_id: Optional[int], - ) -> Optional[WorkflowRoute]: - family = str(payload.get("family") or "").strip().lower() - action = str(payload.get("action") or "").strip().lower() - if not family or not action: - intent_name = str(payload.get("intent") or "").strip().lower() - route_key = cls.normalize_route_key(intent_name) - family, action = cls._split_route_key(route_key) - route_key = cls.normalize_route_key(f"{family}.{action}") - family, action = cls._split_route_key(route_key) - if family == ROUTE_FAMILY_PROJECT_SIGNAL and action not in { - ROUTE_ACTION_CREATE, - ROUTE_ACTION_DISCOVER, - ROUTE_ACTION_COMMIT, - ROUTE_ACTION_CANCEL, - ROUTE_ACTION_REFRESH, - }: - return None - if f"{family}.{action}" not in WORKFLOW_ROUTE_KEYS and family != ROUTE_FAMILY_PROJECT_SIGNAL: - return None - requested_count = cls.extract_requested_count( - message_text=message_text, - metadata=payload if isinstance(payload, dict) else {}, - ) - if family != ROUTE_FAMILY_PROJECT_SIGNAL: - requested_count = 1 - project_id = _safe_int(payload.get("project_id")) - if project_id is None: - project_id = fallback_project_id - if project_id is not None and int(project_id) <= 0: - project_id = None - requires_confirmation = bool(payload.get("requires_confirmation")) or action in { - ROUTE_ACTION_DISCOVER, - ROUTE_ACTION_COMMIT, - ROUTE_ACTION_CANCEL, - ROUTE_ACTION_REFRESH, - } - metadata = dict(payload) - metadata["workflow_route"] = f"{family}.{action}" - metadata["workflow_family"] = family - metadata["workflow_action"] = action - metadata["requested_count"] = requested_count - return WorkflowRoute( - family=family, - action=action, - project_id=project_id, - requested_count=requested_count, - requires_confirmation=requires_confirmation, - metadata=metadata, - ) - - -class IntentResolver: - """Resolve raw text to canonical intent.""" - - OPERATION_VERBS = ( - "新增", - "增加", - "添加", - "加", - "加一个", - "加个", - "新建", - "创建", - "建立", - "立项", - "接入", - "修复", - "优化", - "改成", - "改为", - "更新", - "分配", - "指派", - "归档", - "执行", - ) - - @staticmethod - def _looks_like_negated_write_statement(*, text: str) -> bool: - normalized = re.sub(r"\s+", "", str(text or "")).lower() - if not normalized: - return False - negation_tokens = ("别", "别再", "别乱", "不要", "不要再", "先别", "先不要", "禁止", "不能", "无需", "不用") - if not any(token in normalized for token in negation_tokens): - return False - write_tokens = ("创建", "新建", "新增", "增加", "添加", "加", "接入", "执行", "分配", "指派", "立项") - object_tokens = ("信号", "需求", "任务", "项目", "事项", "工单", "流程", "信号源") - if any(w in normalized for w in write_tokens) and any(o in normalized for o in object_tokens): - return True - return bool( - re.search( - r"(?:别|不要|先别|先不要|禁止|不能).{0,8}(?:创建|新建|新增|增加|添加|加|接入|执行|分配|指派|立项)", - normalized, - re.IGNORECASE, - ) - ) - - @staticmethod - def resolve(*, text: str, project_id: Optional[int]) -> CanonicalIntent: - normalized = re.sub(r"\s+", "", str(text or "")).lower() - pretty = re.sub(r"\s+", " ", str(text or "")).strip() - if not pretty: - return CanonicalIntent(INTENT_IGNORE, 1.0, project_id, "", "", {"reason": "empty"}) - if IntentResolver._is_smalltalk_message(pretty=pretty, normalized=normalized): - return CanonicalIntent(INTENT_IGNORE, 0.95, project_id, "", "", {"reason": "smalltalk"}) - if IntentResolver._looks_like_negated_write_statement(text=pretty): - return CanonicalIntent( - INTENT_QUERY, - 0.9, - project_id, - "", - pretty[:300], - {"reason": "negated_write_statement"}, - ) - explicit_followup_target = FOLLOWUP_WORK_ITEM_ID_RE.search(pretty) - if explicit_followup_target is not None: - return CanonicalIntent( - INTENT_FOLLOWUP_CONTINUE, - 0.92, - project_id, - "", - "", - { - "followup_by_work_item_id": True, - "target_work_item_id": _safe_int(explicit_followup_target.group(1)), - }, - ) - if any(token in pretty for token in FOLLOWUP_CONFIRM_HINTS): - return CanonicalIntent(INTENT_FOLLOWUP_CONFIRM, 0.82, project_id, "", "", {}) - if any(token in pretty for token in FOLLOWUP_CONTINUE_HINTS): - return CanonicalIntent(INTENT_FOLLOWUP_CONTINUE, 0.82, project_id, "", "", {}) - if SignalSourceOrchestrationService.looks_like_signal_source_request(pretty): - return CanonicalIntent( - INTENT_SIGNAL_SOURCE_ONBOARD, - 0.92, - project_id, - IntentResolver._build_signal_title(pretty, default="新增信号源接入请求"), - pretty[:300], - {"source_request": True}, - ) - if ( - IntentResolver._looks_like_project_create_request(text=pretty) - or extract_project_create_title(pretty) - ): - return CanonicalIntent( - INTENT_PROJECT_CREATE, - 0.9, - project_id, - IntentResolver._build_signal_title(pretty, default="新项目创建请求"), - pretty[:300], - {}, - ) - if IntentResolver._looks_like_signal_create_request(text=pretty): - return CanonicalIntent( - INTENT_SIGNAL_CREATE, - 0.78, - project_id, - IntentResolver._build_signal_title(pretty, default="新信号"), - pretty[:300], - {}, - ) - if IntentResolver._looks_like_query(text=pretty): - return CanonicalIntent( - INTENT_QUERY, - 0.88, - project_id, - "", - pretty[:300], - {}, - ) - return CanonicalIntent( - INTENT_QUERY, - 0.6, - project_id, - "", - pretty[:300], - {"fallback": True, "reason": "default_query_fallback"}, - ) - - @staticmethod - def _looks_like_signal_create_request(*, text: str) -> bool: - normalized = re.sub(r"\s+", "", str(text or "")).lower() - if not normalized: - return False - if IntentResolver._looks_like_negated_write_statement(text=normalized): - return False - candidate_discover_hints = ("候选", "换一批", "刷新", "再来一批", "先分析") - if ( - any(token in normalized for token in candidate_discover_hints) - and any(token in normalized for token in ("确认", "入库", "提交")) - and any(token in normalized for token in ("项目", "project")) - ): - return True - if re.search(r"(?:抓|找|给).{0,8}(?:\d+|[一二两三四五])(?:条|个)?候选", normalized, re.IGNORECASE): - return True - if any(token in normalized for token in SIGNAL_CREATE_HINTS): - return True - signal_object_hints = ("信号", "需求", "任务", "事项", "问题", "改造", "优化") - has_operation = any(token in normalized for token in IntentResolver.OPERATION_VERBS) - has_signal_object = any(token in normalized for token in signal_object_hints) - if has_operation and has_signal_object: - return True - return bool( - re.search( - r"(?:给|帮|请).{0,20}(?:新增|增加|添加|补充|加).{0,20}(?:信号|需求|任务|事项)", - normalized, - re.IGNORECASE, - ) - ) - - @staticmethod - def _is_smalltalk_message(*, pretty: str, normalized: str) -> bool: - compact = re.sub(r"[\\s\\??!!,,。~~、]+", "", str(pretty or "")).strip().lower() - if not compact: - return True - if any(keyword in compact for keyword in SMALLTALK_STRUCTURED_HINTS): - return False - if compact in SMALLTALK_EXACT: - return True - if len(compact) <= 8 and any(keyword in compact for keyword in SMALLTALK_SHORT_HINTS): - return True - return False - - @staticmethod - def _looks_like_query(*, text: str) -> bool: - normalized = re.sub(r"\s+", "", str(text or "")).lower() - if not normalized: - return False - if IntentResolver._looks_like_signal_create_request(text=normalized): - return False - if any(token in normalized for token in QUESTION_HINTS): - return True - has_question_structure = ( - "?" in normalized - or "?" in normalized - or any(token in normalized for token in ("吗", "呢", "吧", "是否", "是不是")) - ) - if not has_question_structure: - return False - has_operation = any(token in normalized for token in IntentResolver.OPERATION_VERBS) - return not has_operation - - @staticmethod - def _looks_like_project_create_request(*, text: str) -> bool: - normalized = re.sub(r"\s+", "", str(text or "")).lower() - if not normalized: - return False - if any(token in normalized for token in PROJECT_CREATE_HINTS): - return True - return bool( - re.search( - r"(?:新增|增加|添加|新建|创建|建立|开个|开一个|建个|建一个|做个|做一个|加个|加一个|立项)(?:一个|个)?项目", - normalized, - re.IGNORECASE, - ) - ) - - @staticmethod - def _build_signal_title(text: str, *, default: str) -> str: - raw = str(text or "").strip() - return raw or default +from .ai_pmo_intent import * diff --git a/backend/app/services/ai_pmo_kernel_common.py b/backend/app/services/ai_pmo_kernel_common.py index cab5b39b..1bdcecdf 100644 --- a/backend/app/services/ai_pmo_kernel_common.py +++ b/backend/app/services/ai_pmo_kernel_common.py @@ -2,309 +2,4 @@ from __future__ import annotations -import asyncio -import hashlib -import json -import os -import re -import time -from datetime import datetime, timedelta, timezone -from typing import Any, Optional - -from sqlalchemy import and_, func, or_, select -from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker - -from ..config.ai_prompts import ( - PROMPT_AI_PMO_INTENT_CLASSIFICATION, - PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS, -) -from ..core.logging import get_logger -from ..models import ( - AIProcessingLog, - Budget, - ConversationSession, - DecisionRecord, - Experiment, - IngestEvent, - Notification, - OperationExecutionLedger, - PlanBundle, - PortfolioSnapshot, - Project, - ProjectDomain, - ProjectSignalLink, - QARecord, - ReplyReceipt, - Signal, - SignalSourceRegistry, - Task, - TruthNode, - User, - UserProject, - UserSkillBinding, - UserWorkload, - WorkItem, -) -from ..schemas.world_model import ( - BlockedTask, - BudgetSummary, - CapacityItem, - CharterItem, - DependencySummary, - PortfolioSummary, - ProjectDomainSnapshot, - ProjectSnapshot, - RecentSignalItem, - RiskSummary, - SkillGraphItem, - TaskSummary, - WorldModel, -) -from .adaptive_rag_service import AdaptiveRAGService -from .ai_agent_kernel import ( - build_decision_trace, - gate_execution as kernel_gate_execution, - resolve_correction as kernel_resolve_correction, - select_intent_route, -) -from .ai_pmo_cadence_service import AIPMOCadenceService -from .ai_pmo_decision_runtime import DecisionEngine -from .ai_pmo_reply_renderer import ReplyRenderer -from .ai_pmo_typed_executor import TypedExecutor -from .ai_steward import AIStewardService -from .ai_pmo_review_service import AIPMOReviewService -from .ai_pmo_intent_routing import ( - FOLLOWUP_TARGET_INTENTS, - FOLLOWUP_WORK_ITEM_ID_RE, - INTENT_CANDIDATES, - INTENT_CONFLICT_POLICIES, - INTENT_FOLLOWUP_CONFIRM, - INTENT_FOLLOWUP_CONTINUE, - INTENT_IGNORE, - INTENT_LLM_FUNCTION_NAME, - INTENT_PROJECT_CREATE, - INTENT_QUERY, - INTENT_SIGNAL_CREATE, - INTENT_SIGNAL_SOURCE_ONBOARD, - LEGACY_INTENT_ROUTE_MAP, - ROUTE_ACTION_ANSWER, - ROUTE_ACTION_CANCEL, - ROUTE_ACTION_COMMIT, - ROUTE_ACTION_CONFIRM, - ROUTE_ACTION_CONTINUE, - ROUTE_ACTION_CREATE, - ROUTE_ACTION_DISCOVER, - ROUTE_ACTION_ONBOARD, - ROUTE_ACTION_REFRESH, - ROUTE_FAMILY_FOLLOWUP, - ROUTE_FAMILY_PROJECT_CREATE, - ROUTE_FAMILY_PROJECT_SIGNAL, - ROUTE_FAMILY_QUERY, - ROUTE_FAMILY_SIGNAL_SOURCE, - ROUTE_FOLLOWUP_CONFIRM, - ROUTE_FOLLOWUP_CONTINUE, - ROUTE_PROJECT_CREATE, - ROUTE_PROJECT_SIGNAL_CANCEL, - ROUTE_PROJECT_SIGNAL_COMMIT, - ROUTE_PROJECT_SIGNAL_CREATE, - ROUTE_PROJECT_SIGNAL_DISCOVER, - ROUTE_PROJECT_SIGNAL_REFRESH, - ROUTE_QUERY_ANSWER, - ROUTE_SIGNAL_SOURCE_ONBOARD, - SESSION_SHORT_CONFIRM_WINDOW_SECONDS, - SHORT_CONFIRM_HINTS, - SHORT_CONFIRM_MAX_MESSAGE_LENGTH, - WRITE_INTENTS, - CanonicalIntent, - IntentResolver, - RouteResolver, - WorkflowRoute, -) -from .ai_pmo_workflow_runtime import ContextAssembler, WorkflowRegistry -from .context_cache import ContextCacheService -from .notification_service import NotificationService -from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow -from .llm import llm_service -from .model_context_bundle_service import ModelContextBundleService -from .signal_planner_service import SignalPlannerService - -from ..utils.timezone import now_cn_naive -logger = get_logger(__name__) - -STATE_RECEIVED = "received" -STATE_MODELED = "modeled" -STATE_DECIDED = "decided" -STATE_PLANNED = "planned" -STATE_EXECUTING = "executing" -STATE_WAITING_MANUAL = "waiting_manual" -STATE_SUCCEEDED = "succeeded" -STATE_FAILED = "failed" -STATE_IGNORED = "ignored" - -RECEIPT_PHASE_ACTION = "action_applied" -RECEIPT_PHASE_PENDING = "pending_manual" -RECEIPT_PHASE_ERROR = "error" - - -def _looks_like_upstream_timeout_message(text: str) -> bool: - lowered = str(text or "").strip().lower() - if not lowered: - return False - timeout_signals = ( - "request timed out before a response was generated", - "agents.defaults.timeoutseconds", - "timeoutseconds", - ) - return any(signal in lowered for signal in timeout_signals) - -TRANSPORT_MODE_CORRECTION_HINTS = ("不是", "不再", "改为", "改成", "替换为", "取代", "现在都", "现在是") -GENERIC_SIGNAL_SOURCE_MESSAGE_RE = re.compile( - r"^(?:请)?(?:按|按照)?(?:这个|上述|这条)?(?:分步|步骤)?(?:骤)?(?:把|将)?(?:它|该需求|这条需求)?(?:加入|接入|录入).{0,24}信号源(?:中)?$", - re.IGNORECASE, -) -MODULE_KEYWORD_RULES: tuple[tuple[str, tuple[str, ...]], ...] = ( - ("用户管理与权限", ("用户管理", "角色权限", "crud", "批量导入", "成员管理")), - ("技能图谱", ("技能图谱", "技能标签", "能力图谱", "雷达图")), - ("工作量管理", ("工作量", "负载", "产能", "workload")), - ("SSTC 工作流", ("sstc", "charter", "signal", "task", "execution", "release", "review", "工作流")), - ("AI 能力编排", ("ai能力", "智能", "自动化", "推荐", "编排", "agent", "llm")), - ("信号源接入", ("信号源", "source", "onboard", "接入")), - ("飞书集成", ("飞书", "feishu", "群聊", "机器人")), - ("消息台与详情页", ("消息台", "详情页", "展开详情", "摘要", "时间戳", "按钮")), -) -POLICY_GATE_RULES: tuple[tuple[str, tuple[str, ...]], ...] = ( - ("delete_or_archive", ("删除项目", "删除信号", "归档项目", "归档", "archive", "清空项目")), - ("budget_overrun", ("超预算", "突破预算", "额外预算", "加预算")), - ("cross_project_staffing", ("跨项目抢人", "抽调", "抢占关键人力", "挪人")), - ("charter_rewrite", ("改写charter", "success criteria", "成功标准改", "范围重大变更")), - ("critical_path_change", ("关键路径", "里程碑改动", "影响多个项目", "跨项目依赖调整")), -) -BOOL_TRUE_VALUES = {"1", "true", "yes", "y", "on", "是"} -SESSION_PROJECT_INHERIT_WINDOW_SECONDS = 30 * 60 -INTENT_LLM_TIMEOUT_DEFAULT_SECONDS = 30.0 -INTENT_LLM_TIMEOUT_COMPLEX_DEFAULT_SECONDS = 30.0 -INTENT_LLM_TIMEOUT_FALLBACK_DEFAULT_SECONDS = 35.0 -INTENT_LLM_TOTAL_BUDGET_DEFAULT_SECONDS = 55.0 -INTENT_LLM_STAGE2_CONFIDENCE_DEFAULT = 0.72 -INTENT_LLM_STAGE2_MIN_REMAINING_DEFAULT_SECONDS = 12.0 - -WORLD_MODEL_STATIC_CACHE_TTL_SECONDS = 10 * 60 -WORLD_MODEL_STATIC_CACHE_KEY_PREFIX = "wm:static" - - -def _utc_now_naive() -> datetime: - return now_cn_naive() - - -def _env_bool(name: str, default: bool = False) -> bool: - raw = os.getenv(name) - if raw is None: - return default - return str(raw).strip().lower() in BOOL_TRUE_VALUES - - -def _env_float(name: str, default: float) -> float: - raw = os.getenv(name) - if raw is None: - return float(default) - try: - return float(raw) - except (TypeError, ValueError): - return float(default) - - -def _intent_llm_timeout_seconds(*, function_name: str) -> float: - normalized = str(function_name or "").strip().lower() - if normalized == "classify_intent_complex": - default_timeout = INTENT_LLM_TIMEOUT_COMPLEX_DEFAULT_SECONDS - configured = _env_float( - "AI_PMO_INTENT_LLM_TIMEOUT_COMPLEX_SECONDS", - _env_float("AI_PMO_INTENT_LLM_TIMEOUT_SECONDS", default_timeout), - ) - elif normalized == str(INTENT_LLM_FUNCTION_NAME or "").strip().lower(): - default_timeout = INTENT_LLM_TIMEOUT_FALLBACK_DEFAULT_SECONDS - configured = _env_float( - "AI_PMO_INTENT_LLM_TIMEOUT_FALLBACK_SECONDS", - _env_float("AI_PMO_INTENT_LLM_TIMEOUT_SECONDS", default_timeout), - ) - else: - default_timeout = INTENT_LLM_TIMEOUT_DEFAULT_SECONDS - configured = _env_float("AI_PMO_INTENT_LLM_TIMEOUT_SECONDS", default_timeout) - return min(max(float(configured), 12.0), 120.0) - - -def _intent_llm_total_budget_seconds() -> float: - configured = _env_float("AI_PMO_INTENT_LLM_TOTAL_BUDGET_SECONDS", INTENT_LLM_TOTAL_BUDGET_DEFAULT_SECONDS) - return min(max(float(configured), 20.0), 120.0) - - -def _intent_llm_stage2_confidence_threshold() -> float: - configured = _env_float("AI_PMO_INTENT_LLM_STAGE2_CONFIDENCE_THRESHOLD", INTENT_LLM_STAGE2_CONFIDENCE_DEFAULT) - return min(max(float(configured), 0.40), 0.95) - - -def _intent_llm_stage2_min_remaining_seconds() -> float: - configured = _env_float( - "AI_PMO_INTENT_LLM_STAGE2_MIN_REMAINING_SECONDS", - INTENT_LLM_STAGE2_MIN_REMAINING_DEFAULT_SECONDS, - ) - return min(max(float(configured), 8.0), 30.0) - - -def _should_force_low_tier_intent_route(*, message_text: str, correction_action: str) -> bool: - if str(correction_action or "").strip().lower() != "none": - return True - normalized = re.sub(r"\s+", "", str(message_text or "")).lower() - if not normalized: - return False - return any(token in normalized for token in TRANSPORT_MODE_CORRECTION_HINTS) - - -def _should_escalate_to_high_intent_route( - *, - route_is_complex: bool, - low_intent: Optional["CanonicalIntent"], - rule_intent: Optional["CanonicalIntent"], -) -> tuple[bool, list[str]]: - reasons: list[str] = [] - if route_is_complex: - reasons.append("route_complex") - if low_intent is None: - reasons.append("stage1_empty") - else: - low_confidence = float(low_intent.confidence or 0.0) - if low_confidence < _intent_llm_stage2_confidence_threshold(): - reasons.append("stage1_low_confidence") - if rule_intent is not None and low_intent is not None and str(rule_intent.name or "") != str(low_intent.name or ""): - reasons.append("stage1_rule_conflict") - return bool(reasons), reasons - - -def _safe_int(value: Any) -> Optional[int]: - try: - if value is None or value == "": - return None - return int(value) - except (TypeError, ValueError): - return None - - -def _normalize_signal_id_list(value: Any) -> list[int]: - ids: list[int] = [] - seen: set[int] = set() - if isinstance(value, list): - items = value - else: - items = [value] - for item in items: - signal_id = _safe_int(item) - if signal_id is None or signal_id <= 0: - continue - if signal_id in seen: - continue - seen.add(signal_id) - ids.append(signal_id) - return ids - - +from .ai_pmo_kernel_common_core import * # noqa: F401,F403 diff --git a/backend/app/services/ai_pmo_kernel_common_core/__init__.py b/backend/app/services/ai_pmo_kernel_common_core/__init__.py new file mode 100644 index 00000000..3da93b92 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_common_core/__init__.py @@ -0,0 +1,5 @@ +"""Public exports for AI PMO kernel common module.""" + +from .constants import * # noqa: F401,F403 +from .helpers import * # noqa: F401,F403 +from .imports import * # noqa: F401,F403 diff --git a/backend/app/services/ai_pmo_kernel_common_core/constants.py b/backend/app/services/ai_pmo_kernel_common_core/constants.py new file mode 100644 index 00000000..1ea320da --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_common_core/constants.py @@ -0,0 +1,62 @@ +"""Constants for AI PMO kernel common module.""" + +from __future__ import annotations + +from .imports import re + +STATE_RECEIVED = "received" +STATE_MODELED = "modeled" +STATE_DECIDED = "decided" +STATE_PLANNED = "planned" +STATE_EXECUTING = "executing" +STATE_WAITING_MANUAL = "waiting_manual" +STATE_SUCCEEDED = "succeeded" +STATE_FAILED = "failed" +STATE_IGNORED = "ignored" + +RECEIPT_PHASE_ACTION = "action_applied" +RECEIPT_PHASE_PENDING = "pending_manual" +RECEIPT_PHASE_ERROR = "error" + +TRANSPORT_MODE_CORRECTION_HINTS = ( + "不是", + "不再", + "改为", + "改成", + "替换为", + "取代", + "现在都", + "现在是", +) +GENERIC_SIGNAL_SOURCE_MESSAGE_RE = re.compile( + r"^(?:请)?(?:按|按照)?(?:这个|上述|这条)?(?:分步|步骤)?(?:骤)?(?:把|将)?(?:它|该需求|这条需求)?(?:加入|接入|录入).{0,24}信号源(?:中)?$", + re.IGNORECASE, +) +MODULE_KEYWORD_RULES: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("用户管理与权限", ("用户管理", "角色权限", "crud", "批量导入", "成员管理")), + ("技能图谱", ("技能图谱", "技能标签", "能力图谱", "雷达图")), + ("工作量管理", ("工作量", "负载", "产能", "workload")), + ("SSTC 工作流", ("sstc", "charter", "signal", "task", "execution", "release", "review", "工作流")), + ("AI 能力编排", ("ai能力", "智能", "自动化", "推荐", "编排", "agent", "llm")), + ("信号源接入", ("信号源", "source", "onboard", "接入")), + ("飞书集成", ("飞书", "feishu", "群聊", "机器人")), + ("消息台与详情页", ("消息台", "详情页", "展开详情", "摘要", "时间戳", "按钮")), +) +POLICY_GATE_RULES: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("delete_or_archive", ("删除项目", "删除信号", "归档项目", "归档", "archive", "清空项目")), + ("budget_overrun", ("超预算", "突破预算", "额外预算", "加预算")), + ("cross_project_staffing", ("跨项目抢人", "抽调", "抢占关键人力", "挪人")), + ("charter_rewrite", ("改写charter", "success criteria", "成功标准改", "范围重大变更")), + ("critical_path_change", ("关键路径", "里程碑改动", "影响多个项目", "跨项目依赖调整")), +) +BOOL_TRUE_VALUES = {"1", "true", "yes", "y", "on", "是"} +SESSION_PROJECT_INHERIT_WINDOW_SECONDS = 30 * 60 +INTENT_LLM_TIMEOUT_DEFAULT_SECONDS = 30.0 +INTENT_LLM_TIMEOUT_COMPLEX_DEFAULT_SECONDS = 30.0 +INTENT_LLM_TIMEOUT_FALLBACK_DEFAULT_SECONDS = 35.0 +INTENT_LLM_TOTAL_BUDGET_DEFAULT_SECONDS = 55.0 +INTENT_LLM_STAGE2_CONFIDENCE_DEFAULT = 0.72 +INTENT_LLM_STAGE2_MIN_REMAINING_DEFAULT_SECONDS = 12.0 + +WORLD_MODEL_STATIC_CACHE_TTL_SECONDS = 10 * 60 +WORLD_MODEL_STATIC_CACHE_KEY_PREFIX = "wm:static" diff --git a/backend/app/services/ai_pmo_kernel_common_core/env.py b/backend/app/services/ai_pmo_kernel_common_core/env.py new file mode 100644 index 00000000..9c87a897 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_common_core/env.py @@ -0,0 +1,75 @@ +"""Environment-derived helper functions for AI PMO kernel common.""" + +from __future__ import annotations + +from .constants import ( + BOOL_TRUE_VALUES, + INTENT_LLM_STAGE2_CONFIDENCE_DEFAULT, + INTENT_LLM_STAGE2_MIN_REMAINING_DEFAULT_SECONDS, + INTENT_LLM_TIMEOUT_COMPLEX_DEFAULT_SECONDS, + INTENT_LLM_TIMEOUT_DEFAULT_SECONDS, + INTENT_LLM_TIMEOUT_FALLBACK_DEFAULT_SECONDS, + INTENT_LLM_TOTAL_BUDGET_DEFAULT_SECONDS, +) +from .imports import INTENT_LLM_FUNCTION_NAME, os + + +def env_bool(name: str, default: bool = False) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return str(raw).strip().lower() in BOOL_TRUE_VALUES + + +def env_float(name: str, default: float) -> float: + raw = os.getenv(name) + if raw is None: + return float(default) + try: + return float(raw) + except (TypeError, ValueError): + return float(default) + + +def intent_llm_timeout_seconds(*, function_name: str) -> float: + normalized = str(function_name or "").strip().lower() + if normalized == "classify_intent_complex": + default_timeout = INTENT_LLM_TIMEOUT_COMPLEX_DEFAULT_SECONDS + configured = env_float( + "AI_PMO_INTENT_LLM_TIMEOUT_COMPLEX_SECONDS", + env_float("AI_PMO_INTENT_LLM_TIMEOUT_SECONDS", default_timeout), + ) + elif normalized == str(INTENT_LLM_FUNCTION_NAME or "").strip().lower(): + default_timeout = INTENT_LLM_TIMEOUT_FALLBACK_DEFAULT_SECONDS + configured = env_float( + "AI_PMO_INTENT_LLM_TIMEOUT_FALLBACK_SECONDS", + env_float("AI_PMO_INTENT_LLM_TIMEOUT_SECONDS", default_timeout), + ) + else: + default_timeout = INTENT_LLM_TIMEOUT_DEFAULT_SECONDS + configured = env_float("AI_PMO_INTENT_LLM_TIMEOUT_SECONDS", default_timeout) + return min(max(float(configured), 12.0), 120.0) + + +def intent_llm_total_budget_seconds() -> float: + configured = env_float( + "AI_PMO_INTENT_LLM_TOTAL_BUDGET_SECONDS", + INTENT_LLM_TOTAL_BUDGET_DEFAULT_SECONDS, + ) + return min(max(float(configured), 20.0), 120.0) + + +def intent_llm_stage2_confidence_threshold() -> float: + configured = env_float( + "AI_PMO_INTENT_LLM_STAGE2_CONFIDENCE_THRESHOLD", + INTENT_LLM_STAGE2_CONFIDENCE_DEFAULT, + ) + return min(max(float(configured), 0.40), 0.95) + + +def intent_llm_stage2_min_remaining_seconds() -> float: + configured = env_float( + "AI_PMO_INTENT_LLM_STAGE2_MIN_REMAINING_SECONDS", + INTENT_LLM_STAGE2_MIN_REMAINING_DEFAULT_SECONDS, + ) + return min(max(float(configured), 8.0), 30.0) diff --git a/backend/app/services/ai_pmo_kernel_common_core/helpers.py b/backend/app/services/ai_pmo_kernel_common_core/helpers.py new file mode 100644 index 00000000..023869fa --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_common_core/helpers.py @@ -0,0 +1,110 @@ +"""Helper functions for AI PMO kernel common module.""" + +from __future__ import annotations + +from .constants import TRANSPORT_MODE_CORRECTION_HINTS +from .env import ( + env_bool, + env_float, + intent_llm_stage2_confidence_threshold, + intent_llm_stage2_min_remaining_seconds, + intent_llm_timeout_seconds, + intent_llm_total_budget_seconds, +) +from .imports import Any, CanonicalIntent, Optional, now_cn_naive, re + + +def _utc_now_naive(): + return now_cn_naive() + + +def _env_bool(name: str, default: bool = False) -> bool: + return env_bool(name, default) + + +def _env_float(name: str, default: float) -> float: + return env_float(name, default) + + +def _intent_llm_timeout_seconds(*, function_name: str) -> float: + return intent_llm_timeout_seconds(function_name=function_name) + + +def _intent_llm_total_budget_seconds() -> float: + return intent_llm_total_budget_seconds() + + +def _intent_llm_stage2_confidence_threshold() -> float: + return intent_llm_stage2_confidence_threshold() + + +def _intent_llm_stage2_min_remaining_seconds() -> float: + return intent_llm_stage2_min_remaining_seconds() + + +def _looks_like_upstream_timeout_message(text: str) -> bool: + lowered = str(text or "").strip().lower() + if not lowered: + return False + timeout_signals = ( + "request timed out before a response was generated", + "agents.defaults.timeoutseconds", + "timeoutseconds", + ) + return any(signal in lowered for signal in timeout_signals) + + +def _should_force_low_tier_intent_route(*, message_text: str, correction_action: str) -> bool: + if str(correction_action or "").strip().lower() != "none": + return True + normalized = re.sub(r"\s+", "", str(message_text or "")).lower() + if not normalized: + return False + return any(token in normalized for token in TRANSPORT_MODE_CORRECTION_HINTS) + + +def _should_escalate_to_high_intent_route( + *, + route_is_complex: bool, + low_intent: Optional[CanonicalIntent], + rule_intent: Optional[CanonicalIntent], +) -> tuple[bool, list[str]]: + reasons: list[str] = [] + if route_is_complex: + reasons.append("route_complex") + if low_intent is None: + reasons.append("stage1_empty") + else: + low_confidence = float(low_intent.confidence or 0.0) + if low_confidence < _intent_llm_stage2_confidence_threshold(): + reasons.append("stage1_low_confidence") + if rule_intent is not None and low_intent is not None and str(rule_intent.name or "") != str(low_intent.name or ""): + reasons.append("stage1_rule_conflict") + return bool(reasons), reasons + + +def _safe_int(value: Any) -> Optional[int]: + try: + if value is None or value == "": + return None + return int(value) + except (TypeError, ValueError): + return None + + +def _normalize_signal_id_list(value: Any) -> list[int]: + ids: list[int] = [] + seen: set[int] = set() + if isinstance(value, list): + items = value + else: + items = [value] + for item in items: + signal_id = _safe_int(item) + if signal_id is None or signal_id <= 0: + continue + if signal_id in seen: + continue + seen.add(signal_id) + ids.append(signal_id) + return ids diff --git a/backend/app/services/ai_pmo_kernel_common_core/imports.py b/backend/app/services/ai_pmo_kernel_common_core/imports.py new file mode 100644 index 00000000..8f759ce6 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_common_core/imports.py @@ -0,0 +1,133 @@ +"""Shared imports for AI PMO kernel common module.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +import re +import time +from datetime import datetime, timedelta, timezone +from typing import Any, Optional + +from sqlalchemy import and_, func, or_, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from ...config.ai_prompts import ( + PROMPT_AI_PMO_INTENT_CLASSIFICATION, + PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS, +) +from ...core.logging import get_logger +from ...models import ( + AIProcessingLog, + Budget, + ConversationSession, + DecisionRecord, + Experiment, + IngestEvent, + Notification, + OperationExecutionLedger, + PlanBundle, + PortfolioSnapshot, + Project, + ProjectDomain, + ProjectSignalLink, + QARecord, + ReplyReceipt, + Signal, + SignalSourceRegistry, + Task, + TruthNode, + User, + UserProject, + UserSkillBinding, + UserWorkload, + WorkItem, +) +from ...schemas.world_model import ( + BlockedTask, + BudgetSummary, + CapacityItem, + CharterItem, + DependencySummary, + PortfolioSummary, + ProjectDomainSnapshot, + ProjectSnapshot, + RecentSignalItem, + RiskSummary, + SkillGraphItem, + TaskSummary, + WorldModel, +) +from ...utils.timezone import now_cn_naive +from ..adaptive_rag_service import AdaptiveRAGService +from ..ai_agent_kernel import ( + build_decision_trace, + gate_execution as kernel_gate_execution, + resolve_correction as kernel_resolve_correction, + select_intent_route, +) +from ..ai_pmo_cadence_service import AIPMOCadenceService +from ..ai_pmo_decision_runtime import DecisionEngine +from ..ai_pmo_domain_workflow import SignalSourceDomainWorkflow +from ..ai_pmo_intent_routing import ( + FOLLOWUP_TARGET_INTENTS, + FOLLOWUP_WORK_ITEM_ID_RE, + INTENT_CANDIDATES, + INTENT_CONFLICT_POLICIES, + INTENT_FOLLOWUP_CONFIRM, + INTENT_FOLLOWUP_CONTINUE, + INTENT_IGNORE, + INTENT_LLM_FUNCTION_NAME, + INTENT_PROJECT_CREATE, + INTENT_QUERY, + INTENT_SIGNAL_CREATE, + INTENT_SIGNAL_SOURCE_ONBOARD, + LEGACY_INTENT_ROUTE_MAP, + ROUTE_ACTION_ANSWER, + ROUTE_ACTION_CANCEL, + ROUTE_ACTION_COMMIT, + ROUTE_ACTION_CONFIRM, + ROUTE_ACTION_CONTINUE, + ROUTE_ACTION_CREATE, + ROUTE_ACTION_DISCOVER, + ROUTE_ACTION_ONBOARD, + ROUTE_ACTION_REFRESH, + ROUTE_FAMILY_FOLLOWUP, + ROUTE_FAMILY_PROJECT_CREATE, + ROUTE_FAMILY_PROJECT_SIGNAL, + ROUTE_FAMILY_QUERY, + ROUTE_FAMILY_SIGNAL_SOURCE, + ROUTE_FOLLOWUP_CONFIRM, + ROUTE_FOLLOWUP_CONTINUE, + ROUTE_PROJECT_CREATE, + ROUTE_PROJECT_SIGNAL_CANCEL, + ROUTE_PROJECT_SIGNAL_COMMIT, + ROUTE_PROJECT_SIGNAL_CREATE, + ROUTE_PROJECT_SIGNAL_DISCOVER, + ROUTE_PROJECT_SIGNAL_REFRESH, + ROUTE_QUERY_ANSWER, + ROUTE_SIGNAL_SOURCE_ONBOARD, + SESSION_SHORT_CONFIRM_WINDOW_SECONDS, + SHORT_CONFIRM_HINTS, + SHORT_CONFIRM_MAX_MESSAGE_LENGTH, + WRITE_INTENTS, + CanonicalIntent, + IntentResolver, + RouteResolver, + WorkflowRoute, +) +from ..ai_pmo_reply_renderer import ReplyRenderer +from ..ai_pmo_review_service import AIPMOReviewService +from ..ai_pmo_typed_executor import TypedExecutor +from ..ai_pmo_workflow_runtime import ContextAssembler, WorkflowRegistry +from ..ai_steward import AIStewardService +from ..context_cache import ContextCacheService +from ..llm import llm_service +from ..model_context_bundle_service import ModelContextBundleService +from ..notification_service import NotificationService +from ..signal_planner_service import SignalPlannerService + +logger = get_logger(__name__) diff --git a/backend/app/services/ai_pmo_kernel_ingress_flow/__init__.py b/backend/app/services/ai_pmo_kernel_ingress_flow/__init__.py new file mode 100644 index 00000000..cd1d685f --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_ingress_flow/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .service import KernelIngressFlowMixin + +__all__ = ["KernelIngressFlowMixin"] diff --git a/backend/app/services/ai_pmo_kernel_ingress_flow/mixin_auto.py b/backend/app/services/ai_pmo_kernel_ingress_flow/mixin_auto.py new file mode 100644 index 00000000..e29fb2b6 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_ingress_flow/mixin_auto.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +from .shared import * + +class KernelIngressFlowAutoMixin: + async def _execute_auto_ingress_flow( + self, + *, + work_item: Any, + intent: CanonicalIntent, + world_model: dict[str, Any], + decision_payload: dict[str, Any], + decision_trace: dict[str, Any], + decision_row: Any, + workflow_route: Any, + operator_user_id: Optional[int], + source_system: str, + source_message_id: str, + message_text: str, + sender: str, + channel_ref: Optional[str], + source_sender_open_id: Optional[str], + ingest_event: Any, + started: float, + fallback_intent: CanonicalIntent, + resolved_project_for_session: Optional[int], + log_perf_fn: Any, + ) -> dict[str, Any]: + work_item.state = STATE_PLANNED + work_item.action_bundle = decision_payload["execution_plan"] + work_item.state = STATE_EXECUTING + if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: + stage_started_at = time.perf_counter() + execution_result, verification = await self._execute_followup_intent( + work_item=work_item, + intent=intent, + message_text=message_text, + sender=sender, + operator_user_id=operator_user_id, + source_system=source_system, + source_message_id=source_message_id, + channel_ref=channel_ref, + source_sender_open_id=source_sender_open_id, + world_model=world_model, + workflow_route=workflow_route, + ) + log_perf_fn("TypedExecutor.execute", stage_started_at) + else: + stage_started_at = time.perf_counter() + handler = self.workflow_registry.get_handler(route=workflow_route) + execution_result = await handler.start( + route=workflow_route, + intent=intent, + message_text=message_text, + sender=sender, + operator_user_id=operator_user_id, + source_system=source_system, + source_message_id=source_message_id, + channel_ref=channel_ref, + ) + log_perf_fn("TypedExecutor.execute", stage_started_at) + verification = await self._verify_execution(intent=intent, execution_result=execution_result, world_model=world_model) + if isinstance(execution_result, dict): + execution_result.setdefault("decision_trace", decision_trace) + execution_result.setdefault("workflow_route", workflow_route.key) + execution_result.setdefault("workflow_action", workflow_route.action) + resolved_route = str(execution_result.get("resolved_route") or "").strip().lower() + if resolved_route: + work_item.intent = self.route_resolver.normalize_route_key(resolved_route) + work_item.execution_result = execution_result + if _safe_int(execution_result.get("project_id")) is not None: + work_item.project_id = _safe_int(execution_result.get("project_id")) + if _safe_int(execution_result.get("signal_id")) is not None: + work_item.signal_id = _safe_int(execution_result.get("signal_id")) + work_item.review_summary = await self.review_service.review_work_item( + work_item=work_item, + intent_name=intent.name, + execution_result=execution_result, + world_model=world_model, + verification=verification, + simulation=decision_payload["simulation"], + ) + await self._maybe_emit_risk_cadence_snapshot( + project_id=_safe_int(work_item.project_id), + review_summary=work_item.review_summary, + ) + decision_row.execution_result = execution_result + issues = verification.get("issues") if isinstance(verification.get("issues"), list) else [] + if verification["verified"]: + work_item.state = STATE_SUCCEEDED + await self._project_success(intent=intent, execution_result=execution_result) + elif any( + issue in {"waiting_manual", "followup_ambiguous", "signal_source_pending_manual"} + for issue in issues + ) or str(execution_result.get("status") or "").strip().lower() in {"pending_manual", "pending_confirmation"}: + work_item.state = STATE_WAITING_MANUAL + else: + work_item.state = STATE_FAILED + decision_row.final_outcome = work_item.state + + render = self.reply_renderer.render( + work_state=work_item.state, + intent=intent, + decision_record={ + "approval_requirement": decision_payload["policy"].get("approval_requirement"), + }, + execution_result=execution_result, + ) + work_item.reply_template_key = str(render["template_key"]) + direct_reply_required = await self._claim_reply_receipt( + work_item_id=int(work_item.id), + source_system=source_system, + source_message_id=source_message_id, + receipt_phase=str(render["receipt_phase"]), + template_key=str(render["template_key"]), + payload=render["payload"], + ) + ingest_event.status = "done" + ingest_event.processed_at = _utc_now_naive() + await self._write_processing_log( + source_message_id=source_message_id, + work_item=work_item, + message_text=message_text, + route=f"ai_pmo.{work_item.intent or intent.name}", + operation_code=self._primary_operation_code(str(work_item.intent or intent.name)), + operation_result=execution_result, + processing_status="success" if work_item.state in {STATE_SUCCEEDED, STATE_WAITING_MANUAL} else "failed", + duration_ms=int((time.perf_counter() - started) * 1000), + ) + final_project_id = _safe_int(work_item.project_id) or resolved_project_for_session + final_intent_name = intent.name or fallback_intent.name + await self._update_session( + tenant_id=self.tenant_id, + sender=sender, + current_project_id=final_project_id, + last_intent=final_intent_name, + last_message_text=message_text[:200], + ) + await self.db.commit() + execution_route_key = RouteResolver.normalize_route_key( + str((execution_result or {}).get("workflow_route") or "") + ) if isinstance(execution_result, dict) else "" + should_run_signal_planner = ( + work_item.state == STATE_SUCCEEDED + and ( + intent.name == INTENT_SIGNAL_CREATE + or execution_route_key == ROUTE_PROJECT_SIGNAL_COMMIT + ) + ) + if should_run_signal_planner: + signal_ids = _normalize_signal_id_list(execution_result.get("signal_ids")) + signal_id = _safe_int(execution_result.get("signal_id")) + if signal_id is not None and signal_id not in signal_ids: + signal_ids = [signal_id, *signal_ids] + for planner_signal_id in signal_ids: + asyncio.create_task( + SignalPlannerService.analyze( + signal_id=int(planner_signal_id), + tenant_id=int(self.tenant_id), + source_message_id=source_message_id, + source_chat_id=channel_ref, + dedupe_seed=f"signal-planner-failed:{int(planner_signal_id)}", + session_factory=self._background_session_factory, + ) + ) + stage_started_at = time.perf_counter() + capture_result = self._build_capture_result( + work_item=work_item, + intent=intent, + execution_result=execution_result, + render=render, + direct_reply_required=direct_reply_required, + status=( + "answered" + if intent.name in {INTENT_QUERY, INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE} + and work_item.state == STATE_SUCCEEDED + else "captured" + ), + classification=( + "question" + if intent.name == INTENT_QUERY + else ("signal" if intent.name == INTENT_SIGNAL_CREATE else "command") + ), + ) + log_perf_fn("_build_capture_result", stage_started_at) + return capture_result + diff --git a/backend/app/services/ai_pmo_kernel_ingress_flow/mixin_entry.py b/backend/app/services/ai_pmo_kernel_ingress_flow/mixin_entry.py new file mode 100644 index 00000000..309a03f8 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_ingress_flow/mixin_entry.py @@ -0,0 +1,276 @@ +from __future__ import annotations + +from .shared import * + +class KernelIngressFlowEntryMixin: + async def process_feishu_message( + self, + *, + group_chat_id: str, + message_content: str, + sender: str, + source_message_id: Optional[str], + source_sender_open_id: Optional[str] = None, + captured_at: Optional[datetime] = None, + chat_type: Optional[str] = None, + ) -> dict[str, Any]: + return await self.process_ingress( + source_system="feishu", + source_message_id=source_message_id, + message_text=message_content, + sender=sender, + channel_ref=group_chat_id, + source_sender_open_id=source_sender_open_id, + captured_at=captured_at, + chat_type=chat_type, + ) + + async def process_ingress( + self, + *, + source_system: str, + source_message_id: Optional[str], + message_text: str, + sender: str, + channel_ref: Optional[str] = None, + source_sender_open_id: Optional[str] = None, + captured_at: Optional[datetime] = None, + chat_type: Optional[str] = None, + project_hint_id: Optional[int] = None, + ) -> dict[str, Any]: + started = time.perf_counter() + perf_work_item_id: Optional[int] = None + perf_intent_name = "" + def _log_perf(stage_name: str, stage_started_at: float) -> None: + elapsed_seconds = time.perf_counter() - stage_started_at + perf_line = ( + f"[PERF] {stage_name} took {elapsed_seconds:.2f}s " + f"work_item={perf_work_item_id if perf_work_item_id is not None else 'N/A'} " + f"intent={perf_intent_name or 'N/A'}" + ) + logger.info(perf_line) + print(perf_line, flush=True) + ingress_prepare = await self._prepare_ingress_envelope( + source_system=source_system, + source_message_id=source_message_id, + message_text=message_text, + sender=sender, + channel_ref=channel_ref, + source_sender_open_id=source_sender_open_id, + captured_at=captured_at, + chat_type=chat_type, + project_hint_id=project_hint_id, + ) + if ingress_prepare.get("early_result") is not None: + return ingress_prepare["early_result"] + clean_text = str(ingress_prepare["clean_text"]) + synthetic_message_id = str(ingress_prepare["synthetic_message_id"]) + closure_payload = ingress_prepare["closure_payload"] + ingest_event = ingress_prepare["ingest_event"] + operator = ingress_prepare["operator"] + work_item = ingress_prepare["work_item"] + session = ingress_prepare["session"] + perf_work_item_id = int(work_item.id) + try: + stage_started_at = time.perf_counter() + project, world_model = await self.context_assembler.assemble_world_model( + sender=sender, + message_text=clean_text, + project_hint_id=project_hint_id, + session=session, + ) + _log_perf("_build_world_model", stage_started_at) + self._schedule_portfolio_snapshot_persist(world_model=world_model) + work_item.state = STATE_MODELED + stage_started_at = time.perf_counter() + intent, intent_context_source = await self._resolve_intent( + message_text=clean_text, + session=session, + world_model=world_model, + ) + intent = self._enrich_signal_source_intent_from_closure( + intent=intent, + message_text=clean_text, + closure_payload=closure_payload, + ) + if intent.name in WRITE_INTENTS and IntentResolver._looks_like_negated_write_statement(text=clean_text): + metadata = dict(intent.metadata or {}) + metadata["guard_reason"] = "negated_write_statement" + metadata["guarded_from_intent"] = intent.name + intent = CanonicalIntent( + INTENT_QUERY, + max(float(intent.confidence or 0.0), 0.9), + intent.project_id, + "", + clean_text[:300], + metadata, + ) + workflow_route = self.route_resolver.from_canonical( + canonical_intent=intent, + message_text=clean_text, + ) + route_metadata = dict(intent.metadata or {}) + route_metadata.update( + { + "workflow_route": workflow_route.key, + "workflow_family": workflow_route.family, + "workflow_action": workflow_route.action, + "requested_count": workflow_route.requested_count, + "requires_confirmation": workflow_route.requires_confirmation, + "world_model_capacity": world_model.get("capacity") if isinstance(world_model.get("capacity"), list) else [], + "world_model_skill_graph": world_model.get("skill_graph") if isinstance(world_model.get("skill_graph"), list) else [], + } + ) + intent.metadata = route_metadata + workflow_route.metadata = route_metadata + perf_intent_name = workflow_route.key + _log_perf("_resolve_intent", stage_started_at) + if intent_context_source: + world_model["context_source"] = intent_context_source + work_item.context_snapshot = world_model + resolved_project_for_session = _safe_int(workflow_route.project_id) or _safe_int(world_model.get("resolved_project_id")) + fallback_intent = self.intent_resolver.resolve( + text=clean_text, + project_id=_safe_int(world_model.get("resolved_project_id")), + ) + if intent.name == INTENT_IGNORE: + return await self._finalize_ignored_ingress( + work_item=work_item, + ingest_event=ingest_event, + synthetic_message_id=synthetic_message_id, + clean_text=clean_text, + intent=intent, + started=started, + sender=sender, + resolved_project_for_session=resolved_project_for_session, + ) + + work_item.intent = workflow_route.key + work_item.project_id = workflow_route.project_id + stage_started_at = time.perf_counter() + decision_payload = self.decision_engine.decide( + intent=intent, + world_model=world_model, + message_text=clean_text, + ) + _log_perf("DecisionEngine.decide", stage_started_at) + correction_action = str((intent.metadata or {}).get("correction_action") or "none").strip() or "none" + requires_manual_review = bool((intent.metadata or {}).get("requires_manual_review")) + manual_review_code = str((intent.metadata or {}).get("manual_review_code") or "manual_review_required").strip() + manual_review_reason = str((intent.metadata or {}).get("manual_review_reason") or "").strip() + llm_function = str((intent.metadata or {}).get("llm_function") or INTENT_LLM_FUNCTION_NAME).strip() + route_tier = str((intent.metadata or {}).get("route_tier") or "rule").strip() + route_reasons = (intent.metadata or {}).get("route_reasons") + gate = kernel_gate_execution( + confidence=float(intent.confidence or 0.0), + risk_level=self._risk_level_for_intent(intent.name), + correction_action=correction_action, + force_manual=requires_manual_review, + force_manual_code=manual_review_code, + force_manual_reason=manual_review_reason, + ) + decision_trace = build_decision_trace( + intent=intent.name, + confidence=float(intent.confidence or 0.0), + route_tier=route_tier, + route_function=llm_function, + gate_action=gate.action, + gate_code=gate.gate_code, + correction_action=correction_action, + correction_keywords=(intent.metadata or {}).get("correction_keywords") if isinstance(intent.metadata, dict) else [], + complexity_reasons=route_reasons if isinstance(route_reasons, list) else [], + ) + work_item.state = STATE_DECIDED + work_item.decision_level = str((decision_payload["strategic"] or {}).get("decision_level") or "portfolio") + + decision_row = await self._create_decision_record( + work_item=work_item, + world_model=world_model, + decision_payload=decision_payload, + ) + await self._create_plan_bundle( + work_item_id=int(work_item.id), + plan_type="strategic", + summary="Strategic Brain 产出的项目群/项目策略。", + payload=decision_payload["strategic_plan"], + ) + await self._create_plan_bundle( + work_item_id=int(work_item.id), + plan_type="execution", + summary="Execution Plan typed action bundle。", + payload=decision_payload["execution_plan"], + ) + + policy = decision_payload["policy"] + if self._should_bypass_policy_gate( + intent=intent, + workflow_route=workflow_route, + ): + policy["auto_execute"] = True + policy["policy_gate_hit"] = False + policy["policy_gate_code"] = None + policy["approval_requirement"] = { + "required": False, + "reason": "workflow_policy_bypass", + "message": "工作流恢复路径按快照执行,不触发仿真门禁拦截。", + } + if not gate.auto_execute: + policy["auto_execute"] = False + policy["policy_gate_hit"] = True + policy["policy_gate_code"] = gate.gate_code + policy["approval_requirement"] = { + "required": True, + "reason": gate.gate_code, + "message": gate.reason, + } + if not bool(policy.get("auto_execute")): + stage_started_at = time.perf_counter() + capture_result = await self._finalize_waiting_manual_ingress( + work_item=work_item, + intent=intent, + world_model=world_model, + decision_payload=decision_payload, + workflow_route=workflow_route, + decision_trace=decision_trace, + source_system=source_system, + source_message_id=synthetic_message_id, + message_text=clean_text, + sender=sender, + ingest_event=ingest_event, + started=started, + ) + _log_perf("_build_capture_result", stage_started_at) + return capture_result + + return await self._execute_auto_ingress_flow( + work_item=work_item, + intent=intent, + world_model=world_model, + decision_payload=decision_payload, + decision_trace=decision_trace, + decision_row=decision_row, + workflow_route=workflow_route, + operator_user_id=_safe_int(getattr(operator, "id", None)), + source_system=source_system, + source_message_id=synthetic_message_id, + message_text=clean_text, + sender=sender, + channel_ref=channel_ref, + source_sender_open_id=source_sender_open_id, + ingest_event=ingest_event, + started=started, + fallback_intent=fallback_intent, + resolved_project_for_session=resolved_project_for_session, + log_perf_fn=_log_perf, + ) + except Exception as exc: + await self.db.rollback() + logger.warning( + "ai_pmo_kernel_failed", + tenant_id=self.tenant_id, + source_system=source_system, + source_message_id=synthetic_message_id, + error=str(exc), + ) + return {"handled": False, "reason": "kernel_exception", "error": str(exc)} + diff --git a/backend/app/services/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py b/backend/app/services/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py new file mode 100644 index 00000000..75bc264e --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +from .shared import * + +class KernelIngressFlowPrepareManualMixin: + async def _prepare_ingress_envelope( + self, + *, + source_system: str, + source_message_id: Optional[str], + message_text: str, + sender: str, + channel_ref: Optional[str], + source_sender_open_id: Optional[str], + captured_at: Optional[datetime], + chat_type: Optional[str], + project_hint_id: Optional[int], + ) -> dict[str, Any]: + clean_text = self._normalize_text(message_text) + if not clean_text: + return { + "early_result": { + "handled": True, + "status": "ignored", + "classification": "empty", + "direct_reply_required": False, + } + } + + transport_mode_reply = self._build_transport_mode_reply(clean_text) + if transport_mode_reply: + return { + "early_result": { + "handled": True, + "status": "answered", + "classification": "question", + "linked_project_ids": [int(project_hint_id)] if project_hint_id is not None else [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": None, + "results": [], + "intent": INTENT_QUERY, + "direct_reply_required": True, + "direct_reply_text": transport_mode_reply, + "reply_template_key": "transport_mode_ack", + "work_item_id": None, + "decision_level": None, + "review_summary": None, + } + } + + synthetic_message_id = self._build_source_message_id( + source_system=source_system, + source_message_id=source_message_id, + channel_ref=channel_ref, + sender=sender, + message_text=clean_text, + ) + closure_payload = await self._build_message_closure_payload( + source_system=source_system, + channel_ref=channel_ref, + sender=sender, + source_sender_open_id=source_sender_open_id, + message_text=clean_text, + source_message_id=synthetic_message_id, + captured_at=captured_at, + project_id=project_hint_id, + ) + + ingest_event, created = await self._claim_ingress_event( + source_system=source_system, + source_message_id=synthetic_message_id, + payload={ + "sender": sender, + "source_sender_open_id": source_sender_open_id, + "channel_ref": channel_ref, + "chat_type": chat_type, + "captured_at": captured_at.isoformat() if isinstance(captured_at, datetime) else None, + "message_text": clean_text, + "closure_dialogue": closure_payload.get("closure_dialogue"), + "closure_summary": closure_payload.get("closure_summary"), + "closure_modules": closure_payload.get("closure_modules"), + "closure_turns": closure_payload.get("closure_turns"), + }, + ) + if not created: + return { + "early_result": { + "handled": True, + "status": "ignored", + "reason": "duplicate_filtered", + "classification": "duplicate", + "direct_reply_required": False, + "source_message_id": synthetic_message_id, + } + } + + operator = await self._resolve_operator_user(sender=sender, sender_open_id=source_sender_open_id) + work_item = await self._create_work_item( + ingest_event=ingest_event, + source_system=source_system, + source_message_id=synthetic_message_id, + owner_user_id=_safe_int(getattr(operator, "id", None)), + ) + session = await self._get_or_create_session(tenant_id=self.tenant_id, sender=sender) + return { + "early_result": None, + "clean_text": clean_text, + "synthetic_message_id": synthetic_message_id, + "closure_payload": closure_payload, + "ingest_event": ingest_event, + "operator": operator, + "work_item": work_item, + "session": session, + } + + async def _finalize_ignored_ingress( + self, + *, + work_item: Any, + ingest_event: Any, + synthetic_message_id: str, + clean_text: str, + intent: CanonicalIntent, + started: float, + sender: str, + resolved_project_for_session: Optional[int], + ) -> dict[str, Any]: + work_item.state = STATE_IGNORED + work_item.intent = INTENT_IGNORE + ingest_event.status = "done" + ingest_event.processed_at = _utc_now_naive() + await self._write_processing_log( + source_message_id=synthetic_message_id, + work_item=work_item, + message_text=clean_text, + route="ai_pmo.ignore", + operation_code=None, + operation_result={"reason": (intent.metadata or {}).get("reason")}, + processing_status="success", + duration_ms=int((time.perf_counter() - started) * 1000), + ) + await self._update_session( + tenant_id=self.tenant_id, + sender=sender, + current_project_id=resolved_project_for_session, + last_intent=intent.name, + last_message_text=clean_text[:200], + ) + await self.db.commit() + return { + "handled": True, + "status": "ignored", + "classification": "ignored", + "direct_reply_required": False, + "work_item_id": int(work_item.id), + "ai_trace_id": f"ai-pmo-{int(work_item.id)}", + } + + async def _finalize_waiting_manual_ingress( + self, + *, + work_item: Any, + intent: CanonicalIntent, + world_model: dict[str, Any], + decision_payload: dict[str, Any], + workflow_route: Any, + decision_trace: dict[str, Any], + source_system: str, + source_message_id: str, + message_text: str, + sender: str, + ingest_event: Any, + started: float, + ) -> dict[str, Any]: + policy = decision_payload["policy"] + work_item.state = STATE_WAITING_MANUAL + work_item.action_bundle = decision_payload["execution_plan"] + work_item.execution_result = { + "status": "waiting_manual", + "reason": ((policy.get("approval_requirement") or {}).get("message") or "").strip(), + "workflow_route": workflow_route.key, + "workflow_action": workflow_route.action, + "decision_trace": decision_trace, + } + manual_issues = ["waiting_manual"] + policy_gate_code = str(policy.get("policy_gate_code") or "").strip() + if policy_gate_code: + manual_issues.append(policy_gate_code) + work_item.review_summary = await self.review_service.review_work_item( + work_item=work_item, + intent_name=intent.name, + execution_result=work_item.execution_result, + world_model=world_model, + verification={"verified": False, "issues": manual_issues}, + simulation=decision_payload["simulation"], + ) + render = self.reply_renderer.render( + work_state=STATE_WAITING_MANUAL, + intent=intent, + decision_record={"approval_requirement": policy.get("approval_requirement")}, + execution_result={}, + ) + work_item.reply_template_key = str(render["template_key"]) + await self._project_waiting_manual( + work_item=work_item, + message_text=message_text, + render=render, + intent=intent, + ) + await self._maybe_emit_risk_cadence_snapshot( + project_id=_safe_int(work_item.project_id), + review_summary=work_item.review_summary, + ) + direct_reply_required = await self._claim_reply_receipt( + work_item_id=int(work_item.id), + source_system=source_system, + source_message_id=source_message_id, + receipt_phase=str(render["receipt_phase"]), + template_key=str(render["template_key"]), + payload=render["payload"], + ) + ingest_event.status = "done" + ingest_event.processed_at = _utc_now_naive() + await self._write_processing_log( + source_message_id=source_message_id, + work_item=work_item, + message_text=message_text, + route="ai_pmo.waiting_manual", + operation_code=None, + operation_result=work_item.execution_result, + processing_status="success", + duration_ms=int((time.perf_counter() - started) * 1000), + ) + await self._update_session( + tenant_id=self.tenant_id, + sender=sender, + current_project_id=_safe_int(work_item.project_id), + last_intent=intent.name, + last_message_text=message_text[:200], + ) + await self.db.commit() + return self._build_capture_result( + work_item=work_item, + intent=intent, + execution_result=work_item.execution_result or {}, + render=render, + direct_reply_required=direct_reply_required, + status="captured", + classification="command", + ) + diff --git a/backend/app/services/ai_pmo_kernel_ingress_flow/service.py b/backend/app/services/ai_pmo_kernel_ingress_flow/service.py new file mode 100644 index 00000000..a8b7aeeb --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_ingress_flow/service.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from .mixin_auto import KernelIngressFlowAutoMixin +from .mixin_entry import KernelIngressFlowEntryMixin +from .mixin_prepare_manual import KernelIngressFlowPrepareManualMixin + + +class KernelIngressFlowMixin( + KernelIngressFlowEntryMixin, + KernelIngressFlowPrepareManualMixin, + KernelIngressFlowAutoMixin, +): + pass + + +__all__ = ["KernelIngressFlowMixin"] diff --git a/backend/app/services/ai_pmo_kernel_ingress_flow/shared.py b/backend/app/services/ai_pmo_kernel_ingress_flow/shared.py new file mode 100644 index 00000000..97800d2f --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_ingress_flow/shared.py @@ -0,0 +1,11 @@ +"""AI PMO kernel ingress flow mixin.""" + +from __future__ import annotations + +from .. import ai_pmo_kernel_common as _common + +for _name, _value in vars(_common).items(): + if not _name.startswith("__"): + globals()[_name] = _value + + diff --git a/backend/app/services/ai_pmo_kernel_mixins_ingress_flow.py b/backend/app/services/ai_pmo_kernel_mixins_ingress_flow.py index 5c5f9718..d0b0f0e1 100644 --- a/backend/app/services/ai_pmo_kernel_mixins_ingress_flow.py +++ b/backend/app/services/ai_pmo_kernel_mixins_ingress_flow.py @@ -1,557 +1,5 @@ -"""AI PMO kernel ingress flow mixin.""" - from __future__ import annotations -from . import ai_pmo_kernel_common as _common - -for _name, _value in vars(_common).items(): - if not _name.startswith("__"): - globals()[_name] = _value - - -class KernelIngressFlowMixin: - async def process_feishu_message( - self, - *, - group_chat_id: str, - message_content: str, - sender: str, - source_message_id: Optional[str], - source_sender_open_id: Optional[str] = None, - captured_at: Optional[datetime] = None, - chat_type: Optional[str] = None, - ) -> dict[str, Any]: - return await self.process_ingress( - source_system="feishu", - source_message_id=source_message_id, - message_text=message_content, - sender=sender, - channel_ref=group_chat_id, - source_sender_open_id=source_sender_open_id, - captured_at=captured_at, - chat_type=chat_type, - ) - - async def process_ingress( - self, - *, - source_system: str, - source_message_id: Optional[str], - message_text: str, - sender: str, - channel_ref: Optional[str] = None, - source_sender_open_id: Optional[str] = None, - captured_at: Optional[datetime] = None, - chat_type: Optional[str] = None, - project_hint_id: Optional[int] = None, - ) -> dict[str, Any]: - started = time.perf_counter() - perf_work_item_id: Optional[int] = None - perf_intent_name = "" - - def _log_perf(stage_name: str, stage_started_at: float) -> None: - elapsed_seconds = time.perf_counter() - stage_started_at - perf_line = ( - f"[PERF] {stage_name} took {elapsed_seconds:.2f}s " - f"work_item={perf_work_item_id if perf_work_item_id is not None else 'N/A'} " - f"intent={perf_intent_name or 'N/A'}" - ) - logger.info(perf_line) - print(perf_line, flush=True) - - clean_text = self._normalize_text(message_text) - if not clean_text: - return {"handled": True, "status": "ignored", "classification": "empty", "direct_reply_required": False} - transport_mode_reply = self._build_transport_mode_reply(clean_text) - if transport_mode_reply: - return { - "handled": True, - "status": "answered", - "classification": "question", - "linked_project_ids": [int(project_hint_id)] if project_hint_id is not None else [], - "signal_id": None, - "created_project_id": None, - "auto_tasks": [], - "ai_trace_id": None, - "results": [], - "intent": INTENT_QUERY, - "direct_reply_required": True, - "direct_reply_text": transport_mode_reply, - "reply_template_key": "transport_mode_ack", - "work_item_id": None, - "decision_level": None, - "review_summary": None, - } - - synthetic_message_id = self._build_source_message_id( - source_system=source_system, - source_message_id=source_message_id, - channel_ref=channel_ref, - sender=sender, - message_text=clean_text, - ) - closure_payload = await self._build_message_closure_payload( - source_system=source_system, - channel_ref=channel_ref, - sender=sender, - source_sender_open_id=source_sender_open_id, - message_text=clean_text, - source_message_id=synthetic_message_id, - captured_at=captured_at, - project_id=project_hint_id, - ) - - ingest_event, created = await self._claim_ingress_event( - source_system=source_system, - source_message_id=synthetic_message_id, - payload={ - "sender": sender, - "source_sender_open_id": source_sender_open_id, - "channel_ref": channel_ref, - "chat_type": chat_type, - "captured_at": captured_at.isoformat() if isinstance(captured_at, datetime) else None, - "message_text": clean_text, - "closure_dialogue": closure_payload.get("closure_dialogue"), - "closure_summary": closure_payload.get("closure_summary"), - "closure_modules": closure_payload.get("closure_modules"), - "closure_turns": closure_payload.get("closure_turns"), - }, - ) - if not created: - return { - "handled": True, - "status": "ignored", - "reason": "duplicate_filtered", - "classification": "duplicate", - "direct_reply_required": False, - "source_message_id": synthetic_message_id, - } - - operator = await self._resolve_operator_user(sender=sender, sender_open_id=source_sender_open_id) - work_item = await self._create_work_item( - ingest_event=ingest_event, - source_system=source_system, - source_message_id=synthetic_message_id, - owner_user_id=_safe_int(getattr(operator, "id", None)), - ) - perf_work_item_id = int(work_item.id) - session = await self._get_or_create_session(tenant_id=self.tenant_id, sender=sender) - - try: - stage_started_at = time.perf_counter() - project, world_model = await self.context_assembler.assemble_world_model( - sender=sender, - message_text=clean_text, - project_hint_id=project_hint_id, - session=session, - ) - _log_perf("_build_world_model", stage_started_at) - self._schedule_portfolio_snapshot_persist(world_model=world_model) - work_item.state = STATE_MODELED - stage_started_at = time.perf_counter() - intent, intent_context_source = await self._resolve_intent( - message_text=clean_text, - session=session, - world_model=world_model, - ) - intent = self._enrich_signal_source_intent_from_closure( - intent=intent, - message_text=clean_text, - closure_payload=closure_payload, - ) - if intent.name in WRITE_INTENTS and IntentResolver._looks_like_negated_write_statement(text=clean_text): - metadata = dict(intent.metadata or {}) - metadata["guard_reason"] = "negated_write_statement" - metadata["guarded_from_intent"] = intent.name - intent = CanonicalIntent( - INTENT_QUERY, - max(float(intent.confidence or 0.0), 0.9), - intent.project_id, - "", - clean_text[:300], - metadata, - ) - workflow_route = self.route_resolver.from_canonical( - canonical_intent=intent, - message_text=clean_text, - ) - route_metadata = dict(intent.metadata or {}) - route_metadata.update( - { - "workflow_route": workflow_route.key, - "workflow_family": workflow_route.family, - "workflow_action": workflow_route.action, - "requested_count": workflow_route.requested_count, - "requires_confirmation": workflow_route.requires_confirmation, - "world_model_capacity": world_model.get("capacity") if isinstance(world_model.get("capacity"), list) else [], - "world_model_skill_graph": world_model.get("skill_graph") if isinstance(world_model.get("skill_graph"), list) else [], - } - ) - intent.metadata = route_metadata - workflow_route.metadata = route_metadata - perf_intent_name = workflow_route.key - _log_perf("_resolve_intent", stage_started_at) - if intent_context_source: - world_model["context_source"] = intent_context_source - work_item.context_snapshot = world_model - resolved_project_for_session = _safe_int(workflow_route.project_id) or _safe_int(world_model.get("resolved_project_id")) - fallback_intent = self.intent_resolver.resolve( - text=clean_text, - project_id=_safe_int(world_model.get("resolved_project_id")), - ) - if intent.name == INTENT_IGNORE: - work_item.state = STATE_IGNORED - work_item.intent = INTENT_IGNORE - ingest_event.status = "done" - ingest_event.processed_at = _utc_now_naive() - await self._write_processing_log( - source_message_id=synthetic_message_id, - work_item=work_item, - message_text=clean_text, - route="ai_pmo.ignore", - operation_code=None, - operation_result={"reason": intent.metadata.get("reason")}, - processing_status="success", - duration_ms=int((time.perf_counter() - started) * 1000), - ) - await self._update_session( - tenant_id=self.tenant_id, - sender=sender, - current_project_id=resolved_project_for_session, - last_intent=intent.name, - last_message_text=clean_text[:200], - ) - await self.db.commit() - return { - "handled": True, - "status": "ignored", - "classification": "ignored", - "direct_reply_required": False, - "work_item_id": int(work_item.id), - "ai_trace_id": f"ai-pmo-{int(work_item.id)}", - } - - work_item.intent = workflow_route.key - work_item.project_id = workflow_route.project_id - stage_started_at = time.perf_counter() - decision_payload = self.decision_engine.decide( - intent=intent, - world_model=world_model, - message_text=clean_text, - ) - _log_perf("DecisionEngine.decide", stage_started_at) - correction_action = str((intent.metadata or {}).get("correction_action") or "none").strip() or "none" - requires_manual_review = bool((intent.metadata or {}).get("requires_manual_review")) - manual_review_code = str((intent.metadata or {}).get("manual_review_code") or "manual_review_required").strip() - manual_review_reason = str((intent.metadata or {}).get("manual_review_reason") or "").strip() - llm_function = str((intent.metadata or {}).get("llm_function") or INTENT_LLM_FUNCTION_NAME).strip() - route_tier = str((intent.metadata or {}).get("route_tier") or "rule").strip() - route_reasons = (intent.metadata or {}).get("route_reasons") - gate = kernel_gate_execution( - confidence=float(intent.confidence or 0.0), - risk_level=self._risk_level_for_intent(intent.name), - correction_action=correction_action, - force_manual=requires_manual_review, - force_manual_code=manual_review_code, - force_manual_reason=manual_review_reason, - ) - decision_trace = build_decision_trace( - intent=intent.name, - confidence=float(intent.confidence or 0.0), - route_tier=route_tier, - route_function=llm_function, - gate_action=gate.action, - gate_code=gate.gate_code, - correction_action=correction_action, - correction_keywords=(intent.metadata or {}).get("correction_keywords") if isinstance(intent.metadata, dict) else [], - complexity_reasons=route_reasons if isinstance(route_reasons, list) else [], - ) - work_item.state = STATE_DECIDED - work_item.decision_level = str((decision_payload["strategic"] or {}).get("decision_level") or "portfolio") - - decision_row = await self._create_decision_record( - work_item=work_item, - world_model=world_model, - decision_payload=decision_payload, - ) - await self._create_plan_bundle( - work_item_id=int(work_item.id), - plan_type="strategic", - summary="Strategic Brain 产出的项目群/项目策略。", - payload=decision_payload["strategic_plan"], - ) - await self._create_plan_bundle( - work_item_id=int(work_item.id), - plan_type="execution", - summary="Execution Plan typed action bundle。", - payload=decision_payload["execution_plan"], - ) - - policy = decision_payload["policy"] - if self._should_bypass_policy_gate( - intent=intent, - workflow_route=workflow_route, - ): - policy["auto_execute"] = True - policy["policy_gate_hit"] = False - policy["policy_gate_code"] = None - policy["approval_requirement"] = { - "required": False, - "reason": "workflow_policy_bypass", - "message": "工作流恢复路径按快照执行,不触发仿真门禁拦截。", - } - if not gate.auto_execute: - policy["auto_execute"] = False - policy["policy_gate_hit"] = True - policy["policy_gate_code"] = gate.gate_code - policy["approval_requirement"] = { - "required": True, - "reason": gate.gate_code, - "message": gate.reason, - } - if not bool(policy.get("auto_execute")): - work_item.state = STATE_WAITING_MANUAL - work_item.action_bundle = decision_payload["execution_plan"] - work_item.execution_result = { - "status": "waiting_manual", - "reason": ((policy.get("approval_requirement") or {}).get("message") or "").strip(), - "workflow_route": workflow_route.key, - "workflow_action": workflow_route.action, - "decision_trace": decision_trace, - } - manual_issues = ["waiting_manual"] - policy_gate_code = str(policy.get("policy_gate_code") or "").strip() - if policy_gate_code: - manual_issues.append(policy_gate_code) - work_item.review_summary = await self.review_service.review_work_item( - work_item=work_item, - intent_name=intent.name, - execution_result=work_item.execution_result, - world_model=world_model, - verification={"verified": False, "issues": manual_issues}, - simulation=decision_payload["simulation"], - ) - render = self.reply_renderer.render( - work_state=STATE_WAITING_MANUAL, - intent=intent, - decision_record={ - "approval_requirement": policy.get("approval_requirement"), - }, - execution_result={}, - ) - work_item.reply_template_key = str(render["template_key"]) - await self._project_waiting_manual( - work_item=work_item, - message_text=clean_text, - render=render, - intent=intent, - ) - await self._maybe_emit_risk_cadence_snapshot( - project_id=_safe_int(work_item.project_id), - review_summary=work_item.review_summary, - ) - direct_reply_required = await self._claim_reply_receipt( - work_item_id=int(work_item.id), - source_system=source_system, - source_message_id=synthetic_message_id, - receipt_phase=str(render["receipt_phase"]), - template_key=str(render["template_key"]), - payload=render["payload"], - ) - ingest_event.status = "done" - ingest_event.processed_at = _utc_now_naive() - await self._write_processing_log( - source_message_id=synthetic_message_id, - work_item=work_item, - message_text=clean_text, - route="ai_pmo.waiting_manual", - operation_code=None, - operation_result=work_item.execution_result, - processing_status="success", - duration_ms=int((time.perf_counter() - started) * 1000), - ) - await self._update_session( - tenant_id=self.tenant_id, - sender=sender, - current_project_id=_safe_int(work_item.project_id), - last_intent=intent.name, - last_message_text=clean_text[:200], - ) - await self.db.commit() - stage_started_at = time.perf_counter() - capture_result = self._build_capture_result( - work_item=work_item, - intent=intent, - execution_result=work_item.execution_result or {}, - render=render, - direct_reply_required=direct_reply_required, - status="captured", - classification="command", - ) - _log_perf("_build_capture_result", stage_started_at) - return capture_result - - work_item.state = STATE_PLANNED - work_item.action_bundle = decision_payload["execution_plan"] - work_item.state = STATE_EXECUTING - if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: - stage_started_at = time.perf_counter() - execution_result, verification = await self._execute_followup_intent( - work_item=work_item, - intent=intent, - message_text=clean_text, - sender=sender, - operator_user_id=_safe_int(getattr(operator, "id", None)), - source_system=source_system, - source_message_id=synthetic_message_id, - channel_ref=channel_ref, - source_sender_open_id=source_sender_open_id, - world_model=world_model, - workflow_route=workflow_route, - ) - _log_perf("TypedExecutor.execute", stage_started_at) - else: - stage_started_at = time.perf_counter() - handler = self.workflow_registry.get_handler(route=workflow_route) - execution_result = await handler.start( - route=workflow_route, - intent=intent, - message_text=clean_text, - sender=sender, - operator_user_id=_safe_int(getattr(operator, "id", None)), - source_system=source_system, - source_message_id=synthetic_message_id, - channel_ref=channel_ref, - ) - _log_perf("TypedExecutor.execute", stage_started_at) - verification = await self._verify_execution(intent=intent, execution_result=execution_result, world_model=world_model) - if isinstance(execution_result, dict): - execution_result.setdefault("decision_trace", decision_trace) - execution_result.setdefault("workflow_route", workflow_route.key) - execution_result.setdefault("workflow_action", workflow_route.action) - resolved_route = str(execution_result.get("resolved_route") or "").strip().lower() - if resolved_route: - work_item.intent = self.route_resolver.normalize_route_key(resolved_route) - work_item.execution_result = execution_result - if _safe_int(execution_result.get("project_id")) is not None: - work_item.project_id = _safe_int(execution_result.get("project_id")) - if _safe_int(execution_result.get("signal_id")) is not None: - work_item.signal_id = _safe_int(execution_result.get("signal_id")) - work_item.review_summary = await self.review_service.review_work_item( - work_item=work_item, - intent_name=intent.name, - execution_result=execution_result, - world_model=world_model, - verification=verification, - simulation=decision_payload["simulation"], - ) - await self._maybe_emit_risk_cadence_snapshot( - project_id=_safe_int(work_item.project_id), - review_summary=work_item.review_summary, - ) - decision_row.execution_result = execution_result - issues = verification.get("issues") if isinstance(verification.get("issues"), list) else [] - if verification["verified"]: - work_item.state = STATE_SUCCEEDED - await self._project_success(intent=intent, execution_result=execution_result) - elif any( - issue in {"waiting_manual", "followup_ambiguous", "signal_source_pending_manual"} - for issue in issues - ) or str(execution_result.get("status") or "").strip().lower() in {"pending_manual", "pending_confirmation"}: - work_item.state = STATE_WAITING_MANUAL - else: - work_item.state = STATE_FAILED - decision_row.final_outcome = work_item.state - - render = self.reply_renderer.render( - work_state=work_item.state, - intent=intent, - decision_record={ - "approval_requirement": decision_payload["policy"].get("approval_requirement"), - }, - execution_result=execution_result, - ) - work_item.reply_template_key = str(render["template_key"]) - direct_reply_required = await self._claim_reply_receipt( - work_item_id=int(work_item.id), - source_system=source_system, - source_message_id=synthetic_message_id, - receipt_phase=str(render["receipt_phase"]), - template_key=str(render["template_key"]), - payload=render["payload"], - ) - ingest_event.status = "done" - ingest_event.processed_at = _utc_now_naive() - await self._write_processing_log( - source_message_id=synthetic_message_id, - work_item=work_item, - message_text=clean_text, - route=f"ai_pmo.{work_item.intent or intent.name}", - operation_code=self._primary_operation_code(str(work_item.intent or intent.name)), - operation_result=execution_result, - processing_status="success" if work_item.state in {STATE_SUCCEEDED, STATE_WAITING_MANUAL} else "failed", - duration_ms=int((time.perf_counter() - started) * 1000), - ) - final_project_id = _safe_int(work_item.project_id) or resolved_project_for_session - final_intent_name = intent.name or fallback_intent.name - await self._update_session( - tenant_id=self.tenant_id, - sender=sender, - current_project_id=final_project_id, - last_intent=final_intent_name, - last_message_text=clean_text[:200], - ) - await self.db.commit() - execution_route_key = RouteResolver.normalize_route_key( - str((execution_result or {}).get("workflow_route") or "") - ) if isinstance(execution_result, dict) else "" - should_run_signal_planner = ( - work_item.state == STATE_SUCCEEDED - and ( - intent.name == INTENT_SIGNAL_CREATE - or execution_route_key == ROUTE_PROJECT_SIGNAL_COMMIT - ) - ) - if should_run_signal_planner: - signal_ids = _normalize_signal_id_list(execution_result.get("signal_ids")) - signal_id = _safe_int(execution_result.get("signal_id")) - if signal_id is not None and signal_id not in signal_ids: - signal_ids = [signal_id, *signal_ids] - for planner_signal_id in signal_ids: - asyncio.create_task( - SignalPlannerService.analyze( - signal_id=int(planner_signal_id), - tenant_id=int(self.tenant_id), - source_message_id=synthetic_message_id, - source_chat_id=channel_ref, - dedupe_seed=f"signal-planner-failed:{int(planner_signal_id)}", - session_factory=self._background_session_factory, - ) - ) - stage_started_at = time.perf_counter() - capture_result = self._build_capture_result( - work_item=work_item, - intent=intent, - execution_result=execution_result, - render=render, - direct_reply_required=direct_reply_required, - status="answered" if intent.name in {INTENT_QUERY, INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE} and work_item.state == STATE_SUCCEEDED else "captured", - classification=( - "question" - if intent.name == INTENT_QUERY - else ("signal" if intent.name == INTENT_SIGNAL_CREATE else "command") - ), - ) - _log_perf("_build_capture_result", stage_started_at) - return capture_result - except Exception as exc: - await self.db.rollback() - logger.warning( - "ai_pmo_kernel_failed", - tenant_id=self.tenant_id, - source_system=source_system, - source_message_id=synthetic_message_id, - error=str(exc), - ) - return {"handled": False, "reason": "kernel_exception", "error": str(exc)} +from .ai_pmo_kernel_ingress_flow import KernelIngressFlowMixin +__all__ = ["KernelIngressFlowMixin"] diff --git a/backend/app/services/ai_pmo_kernel_mixins_world_model_aggregates.py b/backend/app/services/ai_pmo_kernel_mixins_world_model_aggregates.py index 674873cb..5a01ffe0 100644 --- a/backend/app/services/ai_pmo_kernel_mixins_world_model_aggregates.py +++ b/backend/app/services/ai_pmo_kernel_mixins_world_model_aggregates.py @@ -2,338 +2,6 @@ from __future__ import annotations -from . import ai_pmo_kernel_common as _common - -for _name, _value in vars(_common).items(): - if not _name.startswith("__"): - globals()[_name] = _value - - -class KernelWorldModelAggregatesMixin: - async def _build_portfolio_summary( - self, - *, - project_id: Optional[int], - query_tables: Optional[set[str]] = None, - ) -> PortfolioSummary: - self._record_world_model_query_table(query_tables, "projects") - active_project_count = ( - await self.db.execute( - select(func.count(Project.id)).where( - and_( - Project.tenant_id == self.tenant_id, - Project.status.in_(["active", "planning", "todo", "doing"]), - ) - ) - ) - ).scalar_one() - return { - "active_project_count": int(active_project_count or 0), - "focus_project_id": project_id, - } - - async def _build_budget_summary( - self, - *, - project_id: Optional[int], - query_tables: Optional[set[str]] = None, - ) -> BudgetSummary: - self._record_world_model_query_table(query_tables, "budgets") - stmt = select(Budget).where(Budget.tenant_id == self.tenant_id) - if project_id is not None: - stmt = stmt.where(or_(Budget.project_id == int(project_id), Budget.project_id.is_(None))) - row = (await self.db.execute(stmt.order_by(Budget.project_id.desc().nulls_last(), Budget.id.desc()).limit(1))).scalars().first() - if row is None: - return {"budget_id": None, "usage_ratio": 0.0, "hard_limit_enabled": False} - amount = float(row.budget_amount_usd or 0.0) - usage = float(row.current_usage_usd or 0.0) - ratio = (usage / amount) if amount > 0 else 0.0 - return { - "budget_id": int(row.id), - "budget_type": str(row.budget_type or ""), - "period": str(row.period or ""), - "budget_amount_usd": amount, - "current_usage_usd": usage, - "usage_ratio": round(ratio, 4), - "hard_limit_enabled": bool(row.hard_limit_enabled), - } - - async def _build_capacity_summary( - self, - *, - project_id: Optional[int], - query_tables: Optional[set[str]] = None, - ) -> list[CapacityItem]: - user_ids: list[int] = [] - if project_id is not None: - self._record_world_model_query_table(query_tables, "user_projects") - rows = ( - await self.db.execute( - select(UserProject.user_id).where( - and_( - UserProject.tenant_id == self.tenant_id, - UserProject.project_id == int(project_id), - ) - ) - ) - ).scalars().all() - user_ids = [int(uid) for uid in rows] - if not user_ids and project_id is not None: - self._record_world_model_query_table(query_tables, "projects") - owner_id = ( - await self.db.execute( - select(Project.owner_id).where( - and_( - Project.tenant_id == self.tenant_id, - Project.id == int(project_id), - ) - ) - ) - ).scalar_one_or_none() - if owner_id is not None: - user_ids = [int(owner_id)] - if not user_ids: - return [] - self._record_world_model_query_table(query_tables, "user_workloads") - rows = ( - await self.db.execute( - select(UserWorkload).where( - and_( - UserWorkload.tenant_id == self.tenant_id, - UserWorkload.user_id.in_(user_ids), - ) - ) - ) - ).scalars().all() - return [ - { - "user_id": int(row.user_id), - "current_assigned_hours": float(row.current_assigned_hours or 0.0), - "max_available_hours_per_week": float(row.max_available_hours_per_week or 0.0), - "workload_ratio": float(row.workload_ratio or 0.0), - } - for row in rows - ] - - async def _build_skill_graph( - self, - *, - project_id: Optional[int], - query_tables: Optional[set[str]] = None, - ) -> list[SkillGraphItem]: - user_ids: list[int] = [] - if project_id is not None: - self._record_world_model_query_table(query_tables, "user_projects") - rows = ( - await self.db.execute( - select(UserProject.user_id).where( - and_( - UserProject.tenant_id == self.tenant_id, - UserProject.project_id == int(project_id), - ) - ) - ) - ).scalars().all() - user_ids = [int(uid) for uid in rows] - if not user_ids: - return [] - self._record_world_model_query_table(query_tables, "user_skill_bindings") - rows = ( - await self.db.execute( - select(UserSkillBinding).where( - and_( - UserSkillBinding.tenant_id == self.tenant_id, - UserSkillBinding.user_id.in_(user_ids), - ) - ).limit(50) - ) - ).scalars().all() - return [ - { - "user_id": int(row.user_id), - "skill_tag_id": int(row.skill_tag_id), - "proficiency_level": str(row.proficiency_level or ""), - "quality_score": float(row.quality_score or 0.0), - "efficiency_score": float(row.efficiency_score or 0.0), - } - for row in rows - ] - - async def _build_project_domain_snapshot( - self, - *, - project_id: Optional[int], - query_tables: Optional[set[str]] = None, - ) -> Optional[ProjectDomainSnapshot]: - if project_id is None: - return None - self._record_world_model_query_table(query_tables, "projects") - self._record_world_model_query_table(query_tables, "project_domains") - domain_row = ( - await self.db.execute( - select(ProjectDomain) - .join(Project, Project.domain_id == ProjectDomain.id) - .where( - and_( - Project.tenant_id == self.tenant_id, - Project.id == int(project_id), - ProjectDomain.tenant_id == self.tenant_id, - ) - ) - .limit(1) - ) - ).scalar_one_or_none() - if domain_row is None: - return None - return { - "id": int(domain_row.id), - "name": str(domain_row.name or ""), - "slug": str(domain_row.slug or ""), - "description": str(domain_row.description or ""), - "owner_id": domain_row.owner_id, - } - - async def _build_charter_snapshot( - self, - *, - project_id: Optional[int], - query_tables: Optional[set[str]] = None, - ) -> list[CharterItem]: - if project_id is None: - return [] - self._record_world_model_query_table(query_tables, "truth_nodes") - rows = ( - await self.db.execute( - select(TruthNode).where( - and_( - TruthNode.tenant_id == self.tenant_id, - TruthNode.project_id == int(project_id), - TruthNode.node_type == "charter", - ) - ).order_by(TruthNode.updated_at.desc(), TruthNode.id.desc()).limit(3) - ) - ).scalars().all() - return [ - { - "id": int(row.id), - "title": str(row.title or ""), - "status": str(row.status or ""), - "content": row.content if isinstance(row.content, dict) else {}, - } - for row in rows - ] - - async def _build_task_summary( - self, - *, - project_id: Optional[int], - skill_graph: list[SkillGraphItem], - query_tables: Optional[set[str]] = None, - ) -> TaskSummary: - if project_id is None: - return {"total": 0, "by_status": {}, "overdue_count": 0, "uncovered_skill_tag_ids": [], "blocked_tasks": []} - self._record_world_model_query_table(query_tables, "tasks") - tasks = ( - await self.db.execute( - select(Task).where(and_(Task.tenant_id == self.tenant_id, Task.project_id == int(project_id))) - ) - ).scalars().all() - by_status: dict[str, int] = {} - required_skill_tag_ids: set[int] = set() - blocked_tasks: list[BlockedTask] = [] - overdue_count = 0 - now = _utc_now_naive() - for row in tasks: - status = str(row.status or "unknown") - by_status[status] = by_status.get(status, 0) + 1 - for skill_id in row.required_skill_tag_ids or []: - if skill_id is not None: - required_skill_tag_ids.add(int(skill_id)) - if status == "blocked": - blocked_tasks.append({"id": int(row.id), "title": str(row.title or "")[:120]}) - if row.due_date and str(row.status or "").lower() not in {"done", "completed", "closed"}: - due_date = row.due_date.replace(tzinfo=None) if row.due_date.tzinfo else row.due_date - if due_date < now: - overdue_count += 1 - available_skill_ids = {int(item["skill_tag_id"]) for item in skill_graph if item.get("skill_tag_id") is not None} - uncovered_skill_ids = sorted(skill_id for skill_id in required_skill_tag_ids if skill_id not in available_skill_ids) - return { - "total": sum(by_status.values()), - "by_status": by_status, - "overdue_count": overdue_count, - "required_skill_tag_ids": sorted(required_skill_tag_ids), - "uncovered_skill_tag_ids": uncovered_skill_ids, - "blocked_tasks": blocked_tasks[:8], - } - - async def _build_recent_signal_summary( - self, - *, - project_id: Optional[int], - query_tables: Optional[set[str]] = None, - ) -> list[RecentSignalItem]: - if project_id is None: - return [] - self._record_world_model_query_table(query_tables, "signals") - self._record_world_model_query_table(query_tables, "project_signal_links") - rows = ( - await self.db.execute( - select(Signal).where( - and_( - Signal.tenant_id == self.tenant_id, - or_( - Signal.matched_project_id == int(project_id), - Signal.id.in_( - select(ProjectSignalLink.signal_id).where( - and_( - ProjectSignalLink.tenant_id == self.tenant_id, - ProjectSignalLink.project_id == int(project_id), - ) - ) - ), - ), - ) - ).order_by(Signal.updated_at.desc(), Signal.id.desc()).limit(5) - ) - ).scalars().all() - return [ - { - "id": int(row.id), - "title": str(row.title or ""), - "status": str(row.status or ""), - "signal_type": str(row.signal_type or ""), - } - for row in rows - ] - - async def _build_risk_summary( - self, - *, - project_id: Optional[int], - query_tables: Optional[set[str]] = None, - ) -> RiskSummary: - self._record_world_model_query_table(query_tables, "notifications") - stmt = select(func.count(Notification.id)).where( - and_( - Notification.tenant_id == self.tenant_id, - Notification.is_read.is_(False), - Notification.kind.in_(["risk", "alert", "action_required"]), - ) - ) - if project_id is not None: - stmt = stmt.where(or_(Notification.entity_id == int(project_id), Notification.payload.is_(None), Notification.entity_type == "project")) - open_risk_count = (await self.db.execute(stmt)).scalar_one() - return {"open_risk_count": int(open_risk_count or 0)} - - @staticmethod - def _build_dependency_summary(*, charter: list[CharterItem], task_summary: TaskSummary) -> DependencySummary: - blocked_tasks = task_summary.get("blocked_tasks") if isinstance(task_summary.get("blocked_tasks"), list) else [] - overdue_count = int(task_summary.get("overdue_count") or 0) - pressure = "high" if blocked_tasks or overdue_count > 0 else ("medium" if not charter else "low") - return { - "pressure": pressure, - "blocked_tasks": blocked_tasks[:5], - "overdue_count": overdue_count, - "charter_ready": bool(charter), - } +from .ai_pmo_kernel_world_model_aggregates import KernelWorldModelAggregatesMixin +__all__ = ["KernelWorldModelAggregatesMixin"] diff --git a/backend/app/services/ai_pmo_kernel_mixins_world_model_core.py b/backend/app/services/ai_pmo_kernel_mixins_world_model_core.py index 4952f716..a59f43c1 100644 --- a/backend/app/services/ai_pmo_kernel_mixins_world_model_core.py +++ b/backend/app/services/ai_pmo_kernel_mixins_world_model_core.py @@ -2,330 +2,6 @@ from __future__ import annotations -from . import ai_pmo_kernel_common as _common +from .ai_pmo_kernel_world_model_core import KernelWorldModelCoreMixin -for _name, _value in vars(_common).items(): - if not _name.startswith("__"): - globals()[_name] = _value - - -class KernelWorldModelCoreMixin: - async def build_world_model( - self, - *, - tenant_id: int, - project_id: Optional[int], - sender: str = "", - project: Optional[Project] = None, - pre_queried_tables: Optional[set[str]] = None, - ) -> WorldModel: - if int(tenant_id) != int(self.tenant_id): - raise ValueError(f"tenant mismatch for world model build: input={tenant_id} service={self.tenant_id}") - - started = time.perf_counter() - queried_tables: set[str] = set(pre_queried_tables or set()) - resolved_project_id: Optional[int] = project_id - status = "failed" - try: - if project is None and project_id is not None: - self._record_world_model_query_table(queried_tables, "projects") - project = ( - await self.db.execute( - select(Project).where( - and_( - Project.tenant_id == self.tenant_id, - Project.id == int(project_id), - ) - ).limit(1) - ) - ).scalar_one_or_none() - - resolved_project_id = _safe_int(getattr(project, "id", None)) if project is not None else project_id - project_snapshot = self._serialize_project(project) - domain: Optional[ProjectDomainSnapshot] = None - skill_graph: list[SkillGraphItem] = [] - charter: list[CharterItem] = [] - - cached_static = await self._get_static_world_model_cache(tenant_id=self.tenant_id, project_id=resolved_project_id) - if cached_static is not None: - cached_project = cached_static.get("project") - if isinstance(cached_project, dict): - project_snapshot = cached_project - cached_domain = cached_static.get("project_domain") - if isinstance(cached_domain, dict): - domain = cached_domain - cached_skill_graph = cached_static.get("skill_graph") - if isinstance(cached_skill_graph, list): - skill_graph = [item for item in cached_skill_graph if isinstance(item, dict)] - cached_charter = cached_static.get("charter") - if isinstance(cached_charter, list): - charter = [item for item in cached_charter if isinstance(item, dict)] - else: - skill_graph = await self._build_skill_graph(project_id=resolved_project_id, query_tables=queried_tables) - charter = await self._build_charter_snapshot(project_id=resolved_project_id, query_tables=queried_tables) - domain = await self._build_project_domain_snapshot(project_id=resolved_project_id, query_tables=queried_tables) - await self._set_static_world_model_cache( - tenant_id=self.tenant_id, - project_id=resolved_project_id, - data={ - "project": project_snapshot, - "project_domain": domain, - "skill_graph": skill_graph, - "charter": charter, - }, - ) - - portfolio_summary = await self._build_portfolio_summary(project_id=resolved_project_id, query_tables=queried_tables) - budget = await self._build_budget_summary(project_id=resolved_project_id, query_tables=queried_tables) - capacity = await self._build_capacity_summary(project_id=resolved_project_id, query_tables=queried_tables) - task_summary = await self._build_task_summary( - project_id=resolved_project_id, - skill_graph=skill_graph, - query_tables=queried_tables, - ) - risk_summary = await self._build_risk_summary(project_id=resolved_project_id, query_tables=queried_tables) - dependencies = self._build_dependency_summary(charter=charter, task_summary=task_summary) - recent_signals = await self._build_recent_signal_summary(project_id=resolved_project_id, query_tables=queried_tables) - - world_model: WorldModel = { - "sender": sender, - "resolved_project_id": resolved_project_id, - "project": project_snapshot, - "project_domain": domain, - "portfolio": portfolio_summary, - "budget": budget, - "capacity": capacity, - "member_count": len(capacity), - "skill_graph": skill_graph, - "charter": charter, - "task_summary": task_summary, - "risk_summary": risk_summary, - "dependencies": dependencies, - "recent_signals": recent_signals, - "decision_context_snapshot": { - "captured_at": _utc_now_naive().isoformat(), - "project_id": resolved_project_id, - "budget": budget, - "capacity": capacity, - "skill_graph_size": len(skill_graph), - "dependency_pressure": dependencies.get("pressure"), - }, - } - status = "success" - return world_model - finally: - duration_ms = int((time.perf_counter() - started) * 1000) - logger.info( - "ai_pmo_world_model_build", - tenant_id=self.tenant_id, - project_id=resolved_project_id, - status=status, - duration_ms=duration_ms, - queried_tables=sorted(queried_tables), - query_table_count=len(queried_tables), - ) - - def _schedule_portfolio_snapshot_persist(self, *, world_model: WorldModel) -> None: - portfolio_summary = world_model.get("portfolio") - if not isinstance(portfolio_summary, dict): - return - project_id = _safe_int(world_model.get("resolved_project_id")) - asyncio.create_task( - self._persist_portfolio_snapshot(world_model=world_model, portfolio_summary=portfolio_summary), - name=f"ai-pmo-portfolio-snapshot:{self.tenant_id}:{project_id or 'none'}", - ) - - async def _persist_portfolio_snapshot( - self, - *, - world_model: WorldModel, - portfolio_summary: PortfolioSummary, - ) -> None: - if self._background_session_factory is None: - logger.warning( - "ai_pmo_portfolio_snapshot_persist_skipped", - tenant_id=self.tenant_id, - project_id=_safe_int(world_model.get("resolved_project_id")), - reason="background_session_factory_unavailable", - ) - return - try: - async with self._background_session_factory() as bg_db: - bg_db.add( - PortfolioSnapshot( - tenant_id=self.tenant_id, - scope_type="tenant", - scope_ref=f"tenant:{self.tenant_id}", - summary=portfolio_summary, - payload=world_model, - ) - ) - await bg_db.commit() - except Exception as exc: - logger.warning( - "ai_pmo_portfolio_snapshot_persist_failed", - tenant_id=self.tenant_id, - project_id=_safe_int(world_model.get("resolved_project_id")), - error=str(exc), - ) - - @staticmethod - def _world_model_static_cache_key(*, tenant_id: int, project_id: int) -> str: - return f"{WORLD_MODEL_STATIC_CACHE_KEY_PREFIX}:{int(tenant_id)}:{int(project_id)}" - - async def _get_static_world_model_cache( - self, - *, - tenant_id: int, - project_id: Optional[int], - ) -> Optional[dict[str, Any]]: - if project_id is None: - return None - key = self._world_model_static_cache_key(tenant_id=tenant_id, project_id=int(project_id)) - cached = await self.context_cache.get_json_key(key) - if not isinstance(cached, dict): - logger.debug(f"[WM_CACHE] MISS {tenant_id}:{project_id}") - return None - required_keys = {"project", "project_domain", "skill_graph", "charter"} - if not required_keys.issubset(set(cached.keys())): - logger.debug(f"[WM_CACHE] MISS {tenant_id}:{project_id}") - return None - logger.debug(f"[WM_CACHE] HIT {tenant_id}:{project_id}") - return cached - - async def _set_static_world_model_cache( - self, - *, - tenant_id: int, - project_id: Optional[int], - data: dict[str, Any], - ) -> None: - if project_id is None: - return - key = self._world_model_static_cache_key(tenant_id=tenant_id, project_id=int(project_id)) - await self.context_cache.set_json_key(key, data, WORLD_MODEL_STATIC_CACHE_TTL_SECONDS) - - async def _invalidate_world_model_cache(self, *, tenant_id: int, project_id: Optional[int]) -> None: - await self.invalidate_world_model_cache(tenant_id=tenant_id, project_id=project_id) - - @classmethod - async def invalidate_world_model_cache(cls, *, tenant_id: int, project_id: Optional[int]) -> None: - if project_id is None: - return - cache = ContextCacheService() - key = cls._world_model_static_cache_key(tenant_id=int(tenant_id), project_id=int(project_id)) - await cache.delete_keys(key) - - @classmethod - async def invalidate_world_model_cache_for_user( - cls, - *, - db: AsyncSession, - tenant_id: int, - user_id: int, - ) -> list[int]: - rows = ( - await db.execute( - select(UserProject.project_id).where( - and_( - UserProject.tenant_id == int(tenant_id), - UserProject.user_id == int(user_id), - ) - ) - ) - ).scalars().all() - project_ids = sorted({int(pid) for pid in rows if pid is not None}) - for pid in project_ids: - await cls.invalidate_world_model_cache(tenant_id=tenant_id, project_id=pid) - return project_ids - - async def _build_world_model( - self, - *, - message_text: str, - sender: str, - project_hint_id: Optional[int], - session: Optional[ConversationSession] = None, - ) -> WorldModel: - query_tables: set[str] = set() - project = await self._resolve_project( - message_text=message_text, - project_hint_id=project_hint_id, - query_tables=query_tables, - session=session, - ) - return await self.build_world_model( - tenant_id=self.tenant_id, - project_id=_safe_int(getattr(project, "id", None)), - sender=sender, - project=project, - pre_queried_tables=query_tables, - ) - - @staticmethod - def _record_world_model_query_table(query_tables: Optional[set[str]], table_name: str) -> None: - if query_tables is not None: - query_tables.add(table_name) - - async def _resolve_project( - self, - *, - message_text: str, - project_hint_id: Optional[int], - query_tables: Optional[set[str]] = None, - session: Optional[ConversationSession] = None, - ) -> Optional[Project]: - if project_hint_id is not None: - self._record_world_model_query_table(query_tables, "projects") - return ( - await self.db.execute( - select(Project).where( - and_( - Project.tenant_id == self.tenant_id, - Project.id == int(project_hint_id), - ) - ).limit(1) - ) - ).scalar_one_or_none() - - id_match = re.search(r"(?:项目(?:id|ID)?|project(?:id)?)\s*[:#:]?\s*(\d+)", str(message_text or "")) - if id_match: - project_id = int(id_match.group(1)) - self._record_world_model_query_table(query_tables, "projects") - return ( - await self.db.execute( - select(Project).where( - and_( - Project.tenant_id == self.tenant_id, - Project.id == project_id, - ) - ).limit(1) - ) - ).scalar_one_or_none() - - self._record_world_model_query_table(query_tables, "projects") - projects = ( - await self.db.execute( - select(Project).where(Project.tenant_id == self.tenant_id).order_by(Project.updated_at.desc(), Project.id.desc()).limit(50) - ) - ).scalars().all() - normalized_message = re.sub(r"\s+", "", str(message_text or "")).lower() - for project in projects: - for candidate in (project.title, project.name): - normalized_candidate = re.sub(r"\s+", "", str(candidate or "")).lower() - if normalized_candidate and normalized_candidate in normalized_message: - return project - inherited_project_id = self._extract_inherited_project_id(session=session) - if inherited_project_id is not None: - self._record_world_model_query_table(query_tables, "projects") - return ( - await self.db.execute( - select(Project).where( - and_( - Project.tenant_id == self.tenant_id, - Project.id == int(inherited_project_id), - ) - ).limit(1) - ) - ).scalar_one_or_none() - return None +__all__ = ["KernelWorldModelCoreMixin"] diff --git a/backend/app/services/ai_pmo_kernel_world_model_aggregates/__init__.py b/backend/app/services/ai_pmo_kernel_world_model_aggregates/__init__.py new file mode 100644 index 00000000..5d5fc414 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_world_model_aggregates/__init__.py @@ -0,0 +1,5 @@ +"""Exports for world-model aggregates mixin package.""" + +from .service import KernelWorldModelAggregatesMixin + +__all__ = ["KernelWorldModelAggregatesMixin"] diff --git a/backend/app/services/ai_pmo_kernel_world_model_aggregates/mixin_basics.py b/backend/app/services/ai_pmo_kernel_world_model_aggregates/mixin_basics.py new file mode 100644 index 00000000..c7ec0bdf --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_world_model_aggregates/mixin_basics.py @@ -0,0 +1,192 @@ +"""Portfolio/budget/capacity and skill graph aggregate builders.""" + +from __future__ import annotations + +from .shared import * # noqa: F401,F403 + + +class KernelWorldModelAggregatesBasicsMixin: + async def _build_portfolio_summary( + self, + *, + project_id: Optional[int], + query_tables: Optional[set[str]] = None, + ) -> PortfolioSummary: + self._record_world_model_query_table(query_tables, "projects") + active_project_count = ( + await self.db.execute( + select(func.count(Project.id)).where( + and_( + Project.tenant_id == self.tenant_id, + Project.status.in_(["active", "planning", "todo", "doing"]), + ) + ) + ) + ).scalar_one() + return { + "active_project_count": int(active_project_count or 0), + "focus_project_id": project_id, + } + + async def _build_budget_summary( + self, + *, + project_id: Optional[int], + query_tables: Optional[set[str]] = None, + ) -> BudgetSummary: + self._record_world_model_query_table(query_tables, "budgets") + stmt = select(Budget).where(Budget.tenant_id == self.tenant_id) + if project_id is not None: + stmt = stmt.where(or_(Budget.project_id == int(project_id), Budget.project_id.is_(None))) + row = ( + await self.db.execute(stmt.order_by(Budget.project_id.desc().nulls_last(), Budget.id.desc()).limit(1)) + ).scalars().first() + if row is None: + return {"budget_id": None, "usage_ratio": 0.0, "hard_limit_enabled": False} + amount = float(row.budget_amount_usd or 0.0) + usage = float(row.current_usage_usd or 0.0) + ratio = (usage / amount) if amount > 0 else 0.0 + return { + "budget_id": int(row.id), + "budget_type": str(row.budget_type or ""), + "period": str(row.period or ""), + "budget_amount_usd": amount, + "current_usage_usd": usage, + "usage_ratio": round(ratio, 4), + "hard_limit_enabled": bool(row.hard_limit_enabled), + } + + async def _build_capacity_summary( + self, + *, + project_id: Optional[int], + query_tables: Optional[set[str]] = None, + ) -> list[CapacityItem]: + user_ids: list[int] = [] + if project_id is not None: + self._record_world_model_query_table(query_tables, "user_projects") + rows = ( + await self.db.execute( + select(UserProject.user_id).where( + and_( + UserProject.tenant_id == self.tenant_id, + UserProject.project_id == int(project_id), + ) + ) + ) + ).scalars().all() + user_ids = [int(uid) for uid in rows] + if not user_ids and project_id is not None: + self._record_world_model_query_table(query_tables, "projects") + owner_id = ( + await self.db.execute( + select(Project.owner_id).where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == int(project_id), + ) + ) + ) + ).scalar_one_or_none() + if owner_id is not None: + user_ids = [int(owner_id)] + if not user_ids: + return [] + self._record_world_model_query_table(query_tables, "user_workloads") + rows = ( + await self.db.execute( + select(UserWorkload).where( + and_( + UserWorkload.tenant_id == self.tenant_id, + UserWorkload.user_id.in_(user_ids), + ) + ) + ) + ).scalars().all() + return [ + { + "user_id": int(row.user_id), + "current_assigned_hours": float(row.current_assigned_hours or 0.0), + "max_available_hours_per_week": float(row.max_available_hours_per_week or 0.0), + "workload_ratio": float(row.workload_ratio or 0.0), + } + for row in rows + ] + + async def _build_skill_graph( + self, + *, + project_id: Optional[int], + query_tables: Optional[set[str]] = None, + ) -> list[SkillGraphItem]: + user_ids: list[int] = [] + if project_id is not None: + self._record_world_model_query_table(query_tables, "user_projects") + rows = ( + await self.db.execute( + select(UserProject.user_id).where( + and_( + UserProject.tenant_id == self.tenant_id, + UserProject.project_id == int(project_id), + ) + ) + ) + ).scalars().all() + user_ids = [int(uid) for uid in rows] + if not user_ids: + return [] + self._record_world_model_query_table(query_tables, "user_skill_bindings") + rows = ( + await self.db.execute( + select(UserSkillBinding).where( + and_( + UserSkillBinding.tenant_id == self.tenant_id, + UserSkillBinding.user_id.in_(user_ids), + ) + ).limit(50) + ) + ).scalars().all() + return [ + { + "user_id": int(row.user_id), + "skill_tag_id": int(row.skill_tag_id), + "proficiency_level": str(row.proficiency_level or ""), + "quality_score": float(row.quality_score or 0.0), + "efficiency_score": float(row.efficiency_score or 0.0), + } + for row in rows + ] + + async def _build_project_domain_snapshot( + self, + *, + project_id: Optional[int], + query_tables: Optional[set[str]] = None, + ) -> Optional[ProjectDomainSnapshot]: + if project_id is None: + return None + self._record_world_model_query_table(query_tables, "projects") + self._record_world_model_query_table(query_tables, "project_domains") + domain_row = ( + await self.db.execute( + select(ProjectDomain) + .join(Project, Project.domain_id == ProjectDomain.id) + .where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == int(project_id), + ProjectDomain.tenant_id == self.tenant_id, + ) + ) + .limit(1) + ) + ).scalar_one_or_none() + if domain_row is None: + return None + return { + "id": int(domain_row.id), + "name": str(domain_row.name or ""), + "slug": str(domain_row.slug or ""), + "description": str(domain_row.description or ""), + "owner_id": domain_row.owner_id, + } diff --git a/backend/app/services/ai_pmo_kernel_world_model_aggregates/mixin_project.py b/backend/app/services/ai_pmo_kernel_world_model_aggregates/mixin_project.py new file mode 100644 index 00000000..06ca3d2f --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_world_model_aggregates/mixin_project.py @@ -0,0 +1,167 @@ +"""Charter/task/signal/risk and dependency aggregate builders.""" + +from __future__ import annotations + +from .shared import * # noqa: F401,F403 + + +class KernelWorldModelAggregatesProjectMixin: + async def _build_charter_snapshot( + self, + *, + project_id: Optional[int], + query_tables: Optional[set[str]] = None, + ) -> list[CharterItem]: + if project_id is None: + return [] + self._record_world_model_query_table(query_tables, "truth_nodes") + rows = ( + await self.db.execute( + select(TruthNode) + .where( + and_( + TruthNode.tenant_id == self.tenant_id, + TruthNode.project_id == int(project_id), + TruthNode.node_type == "charter", + ) + ) + .order_by(TruthNode.updated_at.desc(), TruthNode.id.desc()) + .limit(3) + ) + ).scalars().all() + return [ + { + "id": int(row.id), + "title": str(row.title or ""), + "status": str(row.status or ""), + "content": row.content if isinstance(row.content, dict) else {}, + } + for row in rows + ] + + async def _build_task_summary( + self, + *, + project_id: Optional[int], + skill_graph: list[SkillGraphItem], + query_tables: Optional[set[str]] = None, + ) -> TaskSummary: + if project_id is None: + return { + "total": 0, + "by_status": {}, + "overdue_count": 0, + "uncovered_skill_tag_ids": [], + "blocked_tasks": [], + } + self._record_world_model_query_table(query_tables, "tasks") + tasks = ( + await self.db.execute(select(Task).where(and_(Task.tenant_id == self.tenant_id, Task.project_id == int(project_id)))) + ).scalars().all() + by_status: dict[str, int] = {} + required_skill_tag_ids: set[int] = set() + blocked_tasks: list[BlockedTask] = [] + overdue_count = 0 + now = _utc_now_naive() + for row in tasks: + status = str(row.status or "unknown") + by_status[status] = by_status.get(status, 0) + 1 + for skill_id in row.required_skill_tag_ids or []: + if skill_id is not None: + required_skill_tag_ids.add(int(skill_id)) + if status == "blocked": + blocked_tasks.append({"id": int(row.id), "title": str(row.title or "")[:120]}) + if row.due_date and str(row.status or "").lower() not in {"done", "completed", "closed"}: + due_date = row.due_date.replace(tzinfo=None) if row.due_date.tzinfo else row.due_date + if due_date < now: + overdue_count += 1 + available_skill_ids = {int(item["skill_tag_id"]) for item in skill_graph if item.get("skill_tag_id") is not None} + uncovered_skill_ids = sorted(skill_id for skill_id in required_skill_tag_ids if skill_id not in available_skill_ids) + return { + "total": sum(by_status.values()), + "by_status": by_status, + "overdue_count": overdue_count, + "required_skill_tag_ids": sorted(required_skill_tag_ids), + "uncovered_skill_tag_ids": uncovered_skill_ids, + "blocked_tasks": blocked_tasks[:8], + } + + async def _build_recent_signal_summary( + self, + *, + project_id: Optional[int], + query_tables: Optional[set[str]] = None, + ) -> list[RecentSignalItem]: + if project_id is None: + return [] + self._record_world_model_query_table(query_tables, "signals") + self._record_world_model_query_table(query_tables, "project_signal_links") + rows = ( + await self.db.execute( + select(Signal) + .where( + and_( + Signal.tenant_id == self.tenant_id, + or_( + Signal.matched_project_id == int(project_id), + Signal.id.in_( + select(ProjectSignalLink.signal_id).where( + and_( + ProjectSignalLink.tenant_id == self.tenant_id, + ProjectSignalLink.project_id == int(project_id), + ) + ) + ), + ), + ) + ) + .order_by(Signal.updated_at.desc(), Signal.id.desc()) + .limit(5) + ) + ).scalars().all() + return [ + { + "id": int(row.id), + "title": str(row.title or ""), + "status": str(row.status or ""), + "signal_type": str(row.signal_type or ""), + } + for row in rows + ] + + async def _build_risk_summary( + self, + *, + project_id: Optional[int], + query_tables: Optional[set[str]] = None, + ) -> RiskSummary: + self._record_world_model_query_table(query_tables, "notifications") + stmt = select(func.count(Notification.id)).where( + and_( + Notification.tenant_id == self.tenant_id, + Notification.is_read.is_(False), + Notification.kind.in_(["risk", "alert", "action_required"]), + ) + ) + if project_id is not None: + stmt = stmt.where( + or_( + Notification.entity_id == int(project_id), + Notification.payload.is_(None), + Notification.entity_type == "project", + ) + ) + open_risk_count = (await self.db.execute(stmt)).scalar_one() + return {"open_risk_count": int(open_risk_count or 0)} + + @staticmethod + def _build_dependency_summary(*, charter: list[CharterItem], task_summary: TaskSummary) -> DependencySummary: + blocked_tasks = task_summary.get("blocked_tasks") if isinstance(task_summary.get("blocked_tasks"), list) else [] + overdue_count = int(task_summary.get("overdue_count") or 0) + pressure = "high" if blocked_tasks or overdue_count > 0 else ("medium" if not charter else "low") + return { + "pressure": pressure, + "blocked_tasks": blocked_tasks[:5], + "overdue_count": overdue_count, + "charter_ready": bool(charter), + } diff --git a/backend/app/services/ai_pmo_kernel_world_model_aggregates/service.py b/backend/app/services/ai_pmo_kernel_world_model_aggregates/service.py new file mode 100644 index 00000000..2897069d --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_world_model_aggregates/service.py @@ -0,0 +1,13 @@ +"""Combined world-model aggregates mixin.""" + +from __future__ import annotations + +from .mixin_basics import KernelWorldModelAggregatesBasicsMixin +from .mixin_project import KernelWorldModelAggregatesProjectMixin + + +class KernelWorldModelAggregatesMixin( + KernelWorldModelAggregatesBasicsMixin, + KernelWorldModelAggregatesProjectMixin, +): + pass diff --git a/backend/app/services/ai_pmo_kernel_world_model_aggregates/shared.py b/backend/app/services/ai_pmo_kernel_world_model_aggregates/shared.py new file mode 100644 index 00000000..e64a4d4b --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_world_model_aggregates/shared.py @@ -0,0 +1,9 @@ +"""Shared symbols for AI PMO kernel world-model aggregates.""" + +from __future__ import annotations + +from .. import ai_pmo_kernel_common as _common + +for _name, _value in vars(_common).items(): + if not _name.startswith("__"): + globals()[_name] = _value diff --git a/backend/app/services/ai_pmo_kernel_world_model_core/__init__.py b/backend/app/services/ai_pmo_kernel_world_model_core/__init__.py new file mode 100644 index 00000000..27cf792c --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_world_model_core/__init__.py @@ -0,0 +1,5 @@ +"""Exports for world-model core mixin package.""" + +from .service import KernelWorldModelCoreMixin + +__all__ = ["KernelWorldModelCoreMixin"] diff --git a/backend/app/services/ai_pmo_kernel_world_model_core/mixin_builder.py b/backend/app/services/ai_pmo_kernel_world_model_core/mixin_builder.py new file mode 100644 index 00000000..000f8f19 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_world_model_core/mixin_builder.py @@ -0,0 +1,145 @@ +"""World-model build flow mixin.""" + +from __future__ import annotations + +from .shared import * # noqa: F401,F403 + + +class KernelWorldModelCoreBuilderMixin: + async def build_world_model( + self, + *, + tenant_id: int, + project_id: Optional[int], + sender: str = "", + project: Optional[Project] = None, + pre_queried_tables: Optional[set[str]] = None, + ) -> WorldModel: + if int(tenant_id) != int(self.tenant_id): + raise ValueError(f"tenant mismatch for world model build: input={tenant_id} service={self.tenant_id}") + + started = time.perf_counter() + queried_tables: set[str] = set(pre_queried_tables or set()) + resolved_project_id: Optional[int] = project_id + status = "failed" + try: + if project is None and project_id is not None: + self._record_world_model_query_table(queried_tables, "projects") + project = ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == int(project_id), + ) + ).limit(1) + ) + ).scalar_one_or_none() + + resolved_project_id = _safe_int(getattr(project, "id", None)) if project is not None else project_id + project_snapshot = self._serialize_project(project) + domain: Optional[ProjectDomainSnapshot] = None + skill_graph: list[SkillGraphItem] = [] + charter: list[CharterItem] = [] + + cached_static = await self._get_static_world_model_cache(tenant_id=self.tenant_id, project_id=resolved_project_id) + if cached_static is not None: + cached_project = cached_static.get("project") + if isinstance(cached_project, dict): + project_snapshot = cached_project + cached_domain = cached_static.get("project_domain") + if isinstance(cached_domain, dict): + domain = cached_domain + cached_skill_graph = cached_static.get("skill_graph") + if isinstance(cached_skill_graph, list): + skill_graph = [item for item in cached_skill_graph if isinstance(item, dict)] + cached_charter = cached_static.get("charter") + if isinstance(cached_charter, list): + charter = [item for item in cached_charter if isinstance(item, dict)] + else: + skill_graph = await self._build_skill_graph(project_id=resolved_project_id, query_tables=queried_tables) + charter = await self._build_charter_snapshot(project_id=resolved_project_id, query_tables=queried_tables) + domain = await self._build_project_domain_snapshot(project_id=resolved_project_id, query_tables=queried_tables) + await self._set_static_world_model_cache( + tenant_id=self.tenant_id, + project_id=resolved_project_id, + data={ + "project": project_snapshot, + "project_domain": domain, + "skill_graph": skill_graph, + "charter": charter, + }, + ) + + portfolio_summary = await self._build_portfolio_summary(project_id=resolved_project_id, query_tables=queried_tables) + budget = await self._build_budget_summary(project_id=resolved_project_id, query_tables=queried_tables) + capacity = await self._build_capacity_summary(project_id=resolved_project_id, query_tables=queried_tables) + task_summary = await self._build_task_summary( + project_id=resolved_project_id, + skill_graph=skill_graph, + query_tables=queried_tables, + ) + risk_summary = await self._build_risk_summary(project_id=resolved_project_id, query_tables=queried_tables) + dependencies = self._build_dependency_summary(charter=charter, task_summary=task_summary) + recent_signals = await self._build_recent_signal_summary(project_id=resolved_project_id, query_tables=queried_tables) + + world_model: WorldModel = { + "sender": sender, + "resolved_project_id": resolved_project_id, + "project": project_snapshot, + "project_domain": domain, + "portfolio": portfolio_summary, + "budget": budget, + "capacity": capacity, + "member_count": len(capacity), + "skill_graph": skill_graph, + "charter": charter, + "task_summary": task_summary, + "risk_summary": risk_summary, + "dependencies": dependencies, + "recent_signals": recent_signals, + "decision_context_snapshot": { + "captured_at": _utc_now_naive().isoformat(), + "project_id": resolved_project_id, + "budget": budget, + "capacity": capacity, + "skill_graph_size": len(skill_graph), + "dependency_pressure": dependencies.get("pressure"), + }, + } + status = "success" + return world_model + finally: + duration_ms = int((time.perf_counter() - started) * 1000) + logger.info( + "ai_pmo_world_model_build", + tenant_id=self.tenant_id, + project_id=resolved_project_id, + status=status, + duration_ms=duration_ms, + queried_tables=sorted(queried_tables), + query_table_count=len(queried_tables), + ) + + async def _build_world_model( + self, + *, + message_text: str, + sender: str, + project_hint_id: Optional[int], + session: Optional[ConversationSession] = None, + ) -> WorldModel: + query_tables: set[str] = set() + project = await self._resolve_project( + message_text=message_text, + project_hint_id=project_hint_id, + query_tables=query_tables, + session=session, + ) + return await self.build_world_model( + tenant_id=self.tenant_id, + project_id=_safe_int(getattr(project, "id", None)), + sender=sender, + project=project, + pre_queried_tables=query_tables, + ) diff --git a/backend/app/services/ai_pmo_kernel_world_model_core/mixin_cache.py b/backend/app/services/ai_pmo_kernel_world_model_core/mixin_cache.py new file mode 100644 index 00000000..5ab5bef7 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_world_model_core/mixin_cache.py @@ -0,0 +1,121 @@ +"""World-model cache/snapshot mixin.""" + +from __future__ import annotations + +from .shared import * # noqa: F401,F403 + + +class KernelWorldModelCoreCacheMixin: + def _schedule_portfolio_snapshot_persist(self, *, world_model: WorldModel) -> None: + portfolio_summary = world_model.get("portfolio") + if not isinstance(portfolio_summary, dict): + return + project_id = _safe_int(world_model.get("resolved_project_id")) + asyncio.create_task( + self._persist_portfolio_snapshot(world_model=world_model, portfolio_summary=portfolio_summary), + name=f"ai-pmo-portfolio-snapshot:{self.tenant_id}:{project_id or 'none'}", + ) + + async def _persist_portfolio_snapshot( + self, + *, + world_model: WorldModel, + portfolio_summary: PortfolioSummary, + ) -> None: + if self._background_session_factory is None: + logger.warning( + "ai_pmo_portfolio_snapshot_persist_skipped", + tenant_id=self.tenant_id, + project_id=_safe_int(world_model.get("resolved_project_id")), + reason="background_session_factory_unavailable", + ) + return + try: + async with self._background_session_factory() as bg_db: + bg_db.add( + PortfolioSnapshot( + tenant_id=self.tenant_id, + scope_type="tenant", + scope_ref=f"tenant:{self.tenant_id}", + summary=portfolio_summary, + payload=world_model, + ) + ) + await bg_db.commit() + except Exception as exc: + logger.warning( + "ai_pmo_portfolio_snapshot_persist_failed", + tenant_id=self.tenant_id, + project_id=_safe_int(world_model.get("resolved_project_id")), + error=str(exc), + ) + + @staticmethod + def _world_model_static_cache_key(*, tenant_id: int, project_id: int) -> str: + return f"{WORLD_MODEL_STATIC_CACHE_KEY_PREFIX}:{int(tenant_id)}:{int(project_id)}" + + async def _get_static_world_model_cache( + self, + *, + tenant_id: int, + project_id: Optional[int], + ) -> Optional[dict[str, Any]]: + if project_id is None: + return None + key = self._world_model_static_cache_key(tenant_id=tenant_id, project_id=int(project_id)) + cached = await self.context_cache.get_json_key(key) + if not isinstance(cached, dict): + logger.debug(f"[WM_CACHE] MISS {tenant_id}:{project_id}") + return None + required_keys = {"project", "project_domain", "skill_graph", "charter"} + if not required_keys.issubset(set(cached.keys())): + logger.debug(f"[WM_CACHE] MISS {tenant_id}:{project_id}") + return None + logger.debug(f"[WM_CACHE] HIT {tenant_id}:{project_id}") + return cached + + async def _set_static_world_model_cache( + self, + *, + tenant_id: int, + project_id: Optional[int], + data: dict[str, Any], + ) -> None: + if project_id is None: + return + key = self._world_model_static_cache_key(tenant_id=tenant_id, project_id=int(project_id)) + await self.context_cache.set_json_key(key, data, WORLD_MODEL_STATIC_CACHE_TTL_SECONDS) + + async def _invalidate_world_model_cache(self, *, tenant_id: int, project_id: Optional[int]) -> None: + await self.invalidate_world_model_cache(tenant_id=tenant_id, project_id=project_id) + + @classmethod + async def invalidate_world_model_cache(cls, *, tenant_id: int, project_id: Optional[int]) -> None: + if project_id is None: + return + cache = ContextCacheService() + key = cls._world_model_static_cache_key(tenant_id=int(tenant_id), project_id=int(project_id)) + await cache.delete_keys(key) + + @classmethod + async def invalidate_world_model_cache_for_user( + cls, + *, + db: AsyncSession, + tenant_id: int, + user_id: int, + ) -> list[int]: + rows = ( + await db.execute( + select(UserProject.project_id).where( + and_( + UserProject.tenant_id == int(tenant_id), + UserProject.user_id == int(user_id), + ) + ) + ) + ).scalars().all() + project_ids = sorted({int(pid) for pid in rows if pid is not None}) + for pid in project_ids: + await cls.invalidate_world_model_cache(tenant_id=tenant_id, project_id=pid) + return project_ids diff --git a/backend/app/services/ai_pmo_kernel_world_model_core/mixin_project.py b/backend/app/services/ai_pmo_kernel_world_model_core/mixin_project.py new file mode 100644 index 00000000..94ec2207 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_world_model_core/mixin_project.py @@ -0,0 +1,78 @@ +"""Project resolution helpers for world-model build.""" + +from __future__ import annotations + +from .shared import * # noqa: F401,F403 + + +class KernelWorldModelCoreProjectMixin: + @staticmethod + def _record_world_model_query_table(query_tables: Optional[set[str]], table_name: str) -> None: + if query_tables is not None: + query_tables.add(table_name) + + async def _resolve_project( + self, + *, + message_text: str, + project_hint_id: Optional[int], + query_tables: Optional[set[str]] = None, + session: Optional[ConversationSession] = None, + ) -> Optional[Project]: + if project_hint_id is not None: + self._record_world_model_query_table(query_tables, "projects") + return ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == int(project_hint_id), + ) + ).limit(1) + ) + ).scalar_one_or_none() + + id_match = re.search(r"(?:项目(?:id|ID)?|project(?:id)?)\s*[:#:]?\s*(\d+)", str(message_text or "")) + if id_match: + project_id = int(id_match.group(1)) + self._record_world_model_query_table(query_tables, "projects") + return ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == project_id, + ) + ).limit(1) + ) + ).scalar_one_or_none() + + self._record_world_model_query_table(query_tables, "projects") + projects = ( + await self.db.execute( + select(Project) + .where(Project.tenant_id == self.tenant_id) + .order_by(Project.updated_at.desc(), Project.id.desc()) + .limit(50) + ) + ).scalars().all() + normalized_message = re.sub(r"\s+", "", str(message_text or "")).lower() + for project in projects: + for candidate in (project.title, project.name): + normalized_candidate = re.sub(r"\s+", "", str(candidate or "")).lower() + if normalized_candidate and normalized_candidate in normalized_message: + return project + inherited_project_id = self._extract_inherited_project_id(session=session) + if inherited_project_id is not None: + self._record_world_model_query_table(query_tables, "projects") + return ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == int(inherited_project_id), + ) + ).limit(1) + ) + ).scalar_one_or_none() + return None diff --git a/backend/app/services/ai_pmo_kernel_world_model_core/service.py b/backend/app/services/ai_pmo_kernel_world_model_core/service.py new file mode 100644 index 00000000..98171fd1 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_world_model_core/service.py @@ -0,0 +1,15 @@ +"""Combined world-model core mixin.""" + +from __future__ import annotations + +from .mixin_builder import KernelWorldModelCoreBuilderMixin +from .mixin_cache import KernelWorldModelCoreCacheMixin +from .mixin_project import KernelWorldModelCoreProjectMixin + + +class KernelWorldModelCoreMixin( + KernelWorldModelCoreBuilderMixin, + KernelWorldModelCoreCacheMixin, + KernelWorldModelCoreProjectMixin, +): + pass diff --git a/backend/app/services/ai_pmo_kernel_world_model_core/shared.py b/backend/app/services/ai_pmo_kernel_world_model_core/shared.py new file mode 100644 index 00000000..c2a3f782 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_world_model_core/shared.py @@ -0,0 +1,9 @@ +"""Shared symbols for AI PMO kernel world-model core.""" + +from __future__ import annotations + +from .. import ai_pmo_kernel_common as _common + +for _name, _value in vars(_common).items(): + if not _name.startswith("__"): + globals()[_name] = _value diff --git a/backend/app/services/ai_pmo_typed_executor_discovery/__init__.py b/backend/app/services/ai_pmo_typed_executor_discovery/__init__.py new file mode 100644 index 00000000..74ba8479 --- /dev/null +++ b/backend/app/services/ai_pmo_typed_executor_discovery/__init__.py @@ -0,0 +1,5 @@ +"""Typed executor discovery exports.""" + +from .service import TypedExecutorDiscoveryMixin + +__all__ = ["TypedExecutorDiscoveryMixin"] diff --git a/backend/app/services/ai_pmo_typed_executor_discovery/mixin_analysis.py b/backend/app/services/ai_pmo_typed_executor_discovery/mixin_analysis.py new file mode 100644 index 00000000..1163d363 --- /dev/null +++ b/backend/app/services/ai_pmo_typed_executor_discovery/mixin_analysis.py @@ -0,0 +1,81 @@ +"""Signal batch analysis helper for typed executor discovery.""" + +from __future__ import annotations + +from .shared import * # noqa: F401,F403 + + +class DiscoveryAnalysisMixin: + """Analyze multiple candidate signals via high-tier model.""" + + async def _analyze_signal_batch_with_high_tier_model( + self, + *, + intent: CanonicalIntent, + message_text: str, + requested_count: int, + ) -> dict[str, Any]: + strict_analysis_required = self._requires_strict_signal_analysis( + message_text=message_text, + requested_count=requested_count, + ) + prompt = PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS.format( + message_text=str(message_text or "")[:1600], + project_id=intent.project_id if intent.project_id is not None else "null", + requested_count=max(requested_count, 1), + ).strip() + raw_text = "" + try: + raw_text = await llm_service.generate( + prompt, + db=self.db, + tenant_id=self.tenant_id, + function_name="adaptive_rag_answer_deep_signal_batch_analysis", + use_prompt_profile=False, + temperature=0.1, + max_tokens=min(2800, 520 + max(requested_count, 1) * 420), + response_mode="complete", + ) + except Exception as exc: + logger.warning( + "ai_pmo_signal_batch_analysis_failed", + tenant_id=self.tenant_id, + project_id=intent.project_id, + requested_count=requested_count, + error=str(exc), + ) + + payload = self._parse_signal_batch_payload(raw_text) + raw_items = payload.get("signals") if isinstance(payload.get("signals"), list) else [] + if not raw_items: + logger.warning( + "ai_pmo_signal_batch_analysis_invalid_payload", + tenant_id=self.tenant_id, + project_id=intent.project_id, + requested_count=requested_count, + raw_preview=str(raw_text or "")[:260], + ) + error_message = "你要求先分析再入库,但当前信号分析结果不可解析,本次未自动入库。" + logger.warning( + "ai_pmo_signal_batch_analysis_strict_blocked", + tenant_id=self.tenant_id, + project_id=intent.project_id, + requested_count=requested_count, + message_preview=str(message_text or "")[:200], + ) + return { + "items": [], + "used_fallback": False, + "analysis_error": error_message, + "strict_analysis_required": strict_analysis_required, + } + return { + "items": self._normalize_signal_batch_items( + message_text=message_text, + requested_count=requested_count, + raw_items=raw_items, + ), + "used_fallback": False, + "analysis_error": "", + "strict_analysis_required": strict_analysis_required, + } diff --git a/backend/app/services/ai_pmo_typed_executor_discovery/mixin_context.py b/backend/app/services/ai_pmo_typed_executor_discovery/mixin_context.py new file mode 100644 index 00000000..6caed7c3 --- /dev/null +++ b/backend/app/services/ai_pmo_typed_executor_discovery/mixin_context.py @@ -0,0 +1,198 @@ +"""Discovery context builders for typed executor.""" + +from __future__ import annotations + +from .shared import * # noqa: F401,F403 + + +class DiscoveryContextMixin: + """Collect project context and build dedupe keys for candidates.""" + + @staticmethod + def _build_candidate_dedupe_key(*, project_id: int, candidate: dict[str, Any]) -> str: + title = str(candidate.get("title") or "").strip().lower() + summary = str(candidate.get("summary") or "").strip().lower() + signal_type = str(candidate.get("signal_type") or "新需求").strip().lower() + payload = f"{int(project_id)}|{title}|{summary}|{signal_type}" + return hashlib.sha1(payload.encode("utf-8"), usedforsecurity=False).hexdigest()[:40] + + async def _collect_project_signal_discovery_context( + self, + *, + project_id: int, + message_text: str, + source_chat_id: Optional[str], + ) -> dict[str, Any]: + rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) + retrieval_result: dict[str, Any] = {} + entity_result: dict[str, Any] = {} + retrieval_error: Optional[str] = None + entity_error: Optional[str] = None + try: + retrieval_result = await rag_service.multi_step_search( + question=message_text, + project_id=int(project_id), + source_chat_id=source_chat_id, + ) + except Exception as exc: + retrieval_error = str(exc) + logger.warning( + "project_signal_discover_multi_step_search_failed", + tenant_id=self.tenant_id, + project_id=project_id, + error=str(exc), + ) + try: + entity_result = await rag_service.entity_resolution( + question=message_text, + project_id=int(project_id), + limit=8, + ) + except Exception as exc: + entity_error = str(exc) + logger.warning( + "project_signal_discover_entity_resolution_failed", + tenant_id=self.tenant_id, + project_id=project_id, + error=str(exc), + ) + if retrieval_error or entity_error: + raise RuntimeError( + "project_signal_discovery_retrieval_failed:" + f"multi_step={retrieval_error or 'ok'};" + f"entity={entity_error or 'ok'}" + ) + + project = ( + await self.db.execute( + select(Project) + .where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == int(project_id), + ) + ) + .limit(1) + ) + ).scalar_one_or_none() + if project is None: + raise ValueError(f"project_not_found:{project_id}") + + truth_nodes = ( + await self.db.execute( + select(TruthNode) + .where( + and_( + TruthNode.tenant_id == self.tenant_id, + TruthNode.project_id == int(project_id), + ) + ) + .order_by(TruthNode.updated_at.desc(), TruthNode.id.desc()) + .limit(6) + ) + ).scalars().all() + experiments = ( + await self.db.execute( + select(Experiment) + .where( + and_( + Experiment.tenant_id == self.tenant_id, + Experiment.project_id == int(project_id), + ) + ) + .order_by(Experiment.updated_at.desc(), Experiment.id.desc()) + .limit(5) + ) + ).scalars().all() + recent_signals = ( + await self.db.execute( + select(Signal) + .where( + and_( + Signal.tenant_id == self.tenant_id, + or_( + Signal.matched_project_id == int(project_id), + Signal.id.in_( + select(ProjectSignalLink.signal_id).where( + and_( + ProjectSignalLink.tenant_id == self.tenant_id, + ProjectSignalLink.project_id == int(project_id), + ) + ) + ), + ), + ) + ) + .order_by(Signal.updated_at.desc(), Signal.id.desc()) + .limit(8) + ) + ).scalars().all() + source_registries = ( + await self.db.execute( + select(SignalSourceRegistry) + .where( + and_( + SignalSourceRegistry.tenant_id == self.tenant_id, + SignalSourceRegistry.project_id == int(project_id), + ) + ) + .order_by(SignalSourceRegistry.updated_at.desc(), SignalSourceRegistry.id.desc()) + .limit(8) + ) + ).scalars().all() + + capability_bundle = await ModelContextBundleService.build_context_bundle( + self.db, + tenant_id=self.tenant_id, + function_name="classify_intent_complex", + ) + + return { + "project": { + "id": int(project.id), + "title": str(project.title or project.name or ""), + "status": str(project.status or ""), + }, + "truth_nodes": [ + { + "id": int(node.id), + "node_type": str(node.node_type or ""), + "title": str(node.title or "")[:120], + "status": str(node.status or ""), + } + for node in truth_nodes + ], + "experiments": [ + { + "id": int(exp.id), + "title": str(exp.title or "")[:120], + "status": str(exp.status or ""), + "target_metric": str(exp.target_metric or "")[:80], + } + for exp in experiments + ], + "recent_signals": [ + { + "id": int(sig.id), + "title": str(sig.title or "")[:120], + "status": str(sig.status or ""), + "signal_type": str(sig.signal_type or "")[:30], + } + for sig in recent_signals + ], + "source_registries": [ + { + "id": int(src.id), + "source_kind": str(src.source_kind or ""), + "source_name": str(src.source_name or "")[:120], + "status": str(src.status or ""), + "onboarding_status": str(src.onboarding_status or ""), + } + for src in source_registries + ], + "retrieval_items": retrieval_result.get("items") if isinstance(retrieval_result.get("items"), list) else [], + "retrieval_metrics": retrieval_result.get("retrieval_metrics") if isinstance(retrieval_result.get("retrieval_metrics"), dict) else {}, + "entity_matches": entity_result.get("matched_entities") if isinstance(entity_result.get("matched_entities"), list) else [], + "tool_trace": retrieval_result.get("tool_trace") if isinstance(retrieval_result.get("tool_trace"), list) else [], + "capability_bundle": capability_bundle if isinstance(capability_bundle, dict) else {}, + } diff --git a/backend/app/services/ai_pmo_typed_executor_discovery/mixin_discover.py b/backend/app/services/ai_pmo_typed_executor_discovery/mixin_discover.py new file mode 100644 index 00000000..0ac65bee --- /dev/null +++ b/backend/app/services/ai_pmo_typed_executor_discovery/mixin_discover.py @@ -0,0 +1,153 @@ +"""Project signal discovery flow for typed executor.""" + +from __future__ import annotations + +from .shared import * # noqa: F401,F403 + + +class DiscoveryFlowMixin: + """Discover candidate signals for project confirmation.""" + + async def discover_project_signal_candidates( + self, + *, + intent: CanonicalIntent, + message_text: str, + source_system: str, + source_message_id: str, + channel_ref: Optional[str], + ) -> dict[str, Any]: + _ = source_system + _ = source_message_id + project_id = _safe_int(intent.project_id) + if project_id is None: + return { + "status": "failed_safe", + "workflow_action": ROUTE_ACTION_DISCOVER, + "workflow_route": ROUTE_PROJECT_SIGNAL_DISCOVER, + "error_message": "项目不存在或未提供项目ID,无法执行候选发现。", + "candidate_signals": [], + } + metadata = intent.metadata if isinstance(intent.metadata, dict) else {} + requested_count = self._extract_requested_signal_count( + message_text=message_text, + metadata=metadata, + ) + + analysis_result = await self._analyze_signal_batch_with_high_tier_model( + intent=intent, + message_text=message_text, + requested_count=requested_count, + ) + analyzed_signals = analysis_result.get("items") if isinstance(analysis_result.get("items"), list) else [] + if not analyzed_signals: + return { + "status": "failed_safe", + "workflow_action": ROUTE_ACTION_DISCOVER, + "workflow_route": ROUTE_PROJECT_SIGNAL_DISCOVER, + "error_message": str(analysis_result.get("analysis_error") or "候选发现失败,请稍后重试。"), + "candidate_signals": [], + "requested_count": requested_count, + } + try: + context = await self._collect_project_signal_discovery_context( + project_id=int(project_id), + message_text=message_text, + source_chat_id=channel_ref, + ) + except Exception as exc: + logger.warning( + "project_signal_discover_context_failed", + tenant_id=self.tenant_id, + project_id=project_id, + error=str(exc), + ) + return { + "status": "failed_safe", + "workflow_action": ROUTE_ACTION_DISCOVER, + "workflow_route": ROUTE_PROJECT_SIGNAL_DISCOVER, + "project_id": int(project_id), + "requested_count": requested_count, + "error_message": "项目候选检索失败,请稍后重试。", + "candidate_signals": [], + } + retrieval_items = context.get("retrieval_items") if isinstance(context.get("retrieval_items"), list) else [] + candidate_signals: list[dict[str, Any]] = [] + for index, row in enumerate(analyzed_signals[:requested_count]): + if not isinstance(row, dict): + continue + try: + refined_title = await self._refine_signal_title_with_ai( + default_title=f"候选信号 {index + 1}", + title_seed=str(row.get("title") or intent.signal_title or ""), + message_text=message_text, + summary=str(row.get("summary") or ""), + content=str(row.get("content") or ""), + intent_name=ROUTE_PROJECT_SIGNAL_DISCOVER, + ) + except Exception as exc: + return { + "status": "failed_safe", + "workflow_action": ROUTE_ACTION_DISCOVER, + "workflow_route": ROUTE_PROJECT_SIGNAL_DISCOVER, + "project_id": int(project_id), + "requested_count": requested_count, + "error_message": self._build_signal_title_refine_error_message(exc), + "candidate_signals": [], + } + evidence_excerpt = "" + if index < len(retrieval_items) and isinstance(retrieval_items[index], dict): + evidence_excerpt = str(retrieval_items[index].get("content") or "")[:240] + candidate_for_dedupe = dict(row) + candidate_for_dedupe["title"] = refined_title + dedupe_key = self._build_candidate_dedupe_key(project_id=int(project_id), candidate=candidate_for_dedupe) + candidate_signals.append( + { + "title": refined_title, + "summary": str(row.get("summary") or "")[:300], + "content": str(row.get("content") or "")[:1800], + "signal_type": str(row.get("signal_type") or "新需求"), + "priority": str(row.get("priority") or "medium"), + "reasoning_bullets": row.get("reasoning_bullets") if isinstance(row.get("reasoning_bullets"), list) else [], + "dedupe_key": dedupe_key, + "evidence_excerpt": evidence_excerpt, + } + ) + + retrieval_metrics = context.get("retrieval_metrics") if isinstance(context.get("retrieval_metrics"), dict) else {} + search_summary = ( + f"项目#{int(project_id)} 候选发现完成:候选{len(candidate_signals)}条," + f"entity命中{len(context.get('entity_matches') if isinstance(context.get('entity_matches'), list) else [])}," + f"检索耗时db={int(retrieval_metrics.get('db_query_ms') or 0)}ms/" + f"vector={int(retrieval_metrics.get('vector_search_ms') or 0)}ms/" + f"graph={int(retrieval_metrics.get('graph_query_ms') or 0)}ms。" + ) + expires_at = (_utc_now_naive() + timedelta(hours=24)).isoformat() + return { + "status": "pending_confirmation", + "workflow_action": ROUTE_ACTION_DISCOVER, + "workflow_route": ROUTE_PROJECT_SIGNAL_DISCOVER, + "project_id": int(project_id), + "requested_count": requested_count, + "candidate_signals": candidate_signals, + "search_summary": search_summary, + "discovery_sources": { + "tool_trace": context.get("tool_trace") if isinstance(context.get("tool_trace"), list) else [], + "entity_matches": context.get("entity_matches") if isinstance(context.get("entity_matches"), list) else [], + "source_registries": context.get("source_registries") if isinstance(context.get("source_registries"), list) else [], + }, + "capability_context": { + "truth_nodes": context.get("truth_nodes") if isinstance(context.get("truth_nodes"), list) else [], + "experiments": context.get("experiments") if isinstance(context.get("experiments"), list) else [], + "recent_signals": context.get("recent_signals") if isinstance(context.get("recent_signals"), list) else [], + "model_context_bundle": context.get("capability_bundle") if isinstance(context.get("capability_bundle"), dict) else {}, + "workload": metadata.get("world_model_capacity") if isinstance(metadata.get("world_model_capacity"), list) else [], + "skill_graph": metadata.get("world_model_skill_graph") if isinstance(metadata.get("world_model_skill_graph"), list) else [], + }, + "confirmation_scope": "project_signal_commit", + "confirmation_expires_at": expires_at, + "followup_reply": ( + f"已为项目#{int(project_id)} 找到 {len(candidate_signals)} 条候选信号," + "请回复“确认”提交,回复“换一批”刷新,或回复“取消”结束本次候选。" + ), + } diff --git a/backend/app/services/ai_pmo_typed_executor_discovery/service.py b/backend/app/services/ai_pmo_typed_executor_discovery/service.py new file mode 100644 index 00000000..50a538d0 --- /dev/null +++ b/backend/app/services/ai_pmo_typed_executor_discovery/service.py @@ -0,0 +1,17 @@ +"""Typed executor discovery mixin composition.""" + +from __future__ import annotations + +from .mixin_analysis import DiscoveryAnalysisMixin +from .mixin_context import DiscoveryContextMixin +from .mixin_discover import DiscoveryFlowMixin + + +class TypedExecutorDiscoveryMixin( + DiscoveryAnalysisMixin, + DiscoveryContextMixin, + DiscoveryFlowMixin, +): + """AI PMO typed executor discovery mixin.""" + + pass diff --git a/backend/app/services/ai_pmo_typed_executor_discovery/shared.py b/backend/app/services/ai_pmo_typed_executor_discovery/shared.py new file mode 100644 index 00000000..f5944df2 --- /dev/null +++ b/backend/app/services/ai_pmo_typed_executor_discovery/shared.py @@ -0,0 +1,9 @@ +"""Shared symbol export for typed executor discovery modules.""" + +from __future__ import annotations + +from .. import ai_pmo_typed_executor_common as _common + +for _name, _value in vars(_common).items(): + if not _name.startswith("__"): + globals()[_name] = _value diff --git a/backend/app/services/ai_pmo_typed_executor_execute/__init__.py b/backend/app/services/ai_pmo_typed_executor_execute/__init__.py new file mode 100644 index 00000000..318ec28c --- /dev/null +++ b/backend/app/services/ai_pmo_typed_executor_execute/__init__.py @@ -0,0 +1,5 @@ +"""Typed executor execute mixin exports.""" + +from .service import TypedExecutorExecuteMixin + +__all__ = ["TypedExecutorExecuteMixin"] diff --git a/backend/app/services/ai_pmo_typed_executor_execute/mixin_execute_query_project.py b/backend/app/services/ai_pmo_typed_executor_execute/mixin_execute_query_project.py new file mode 100644 index 00000000..08c2996d --- /dev/null +++ b/backend/app/services/ai_pmo_typed_executor_execute/mixin_execute_query_project.py @@ -0,0 +1,72 @@ +"""Query/project-create intent execution for typed executor.""" + +from __future__ import annotations + +from .shared import * # noqa: F401,F403 + + +class ExecuteQueryProjectMixin: + """Handle query and project-create intents.""" + + async def _execute_query_intent( + self, + *, + intent, + message_text: str, + operator_user_id, + source_system: str, + source_message_id: str, + ) -> dict[str, Any]: + rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) + try: + rag_result = await rag_service.answer_question( + question=message_text, + asked_by=operator_user_id, + asked_via=source_system, + project_id=intent.project_id, + source_chat_id=source_message_id, + conversation_key=source_message_id, + action_mode="read", + sync_requirement=False, + ) + except Exception as exc: + return { + "status": "failed_safe", + "error_message": self._build_query_error_message(exc), + "project_id": intent.project_id, + } + return { + "status": "succeeded", + "answer": str(rag_result.get("answer") or "").strip(), + "rag_result": rag_result, + "project_id": intent.project_id, + } + + async def _execute_project_create_intent( + self, + *, + message_text: str, + operator_user_id, + ) -> dict[str, Any]: + if operator_user_id is None: + return { + "status": "failed_safe", + "error_message": "未找到可用项目创建人,当前无法自动创建项目。", + } + steward = AIStewardService(self.db) + payload = await steward.create_project_from_natural_language( + input_text=message_text, + creator_id=int(operator_user_id), + tenant_id=self.tenant_id, + ) + project = payload.get("project") if isinstance(payload.get("project"), dict) else {} + signal = payload.get("signal") if isinstance(payload.get("signal"), dict) else {} + return { + "status": "succeeded", + "project": project, + "signal": signal, + "project_id": _safe_int(project.get("id")), + "signal_id": _safe_int(signal.get("id")), + "tasks": payload.get("tasks") if isinstance(payload.get("tasks"), list) else [], + "raw_payload": payload, + } diff --git a/backend/app/services/ai_pmo_typed_executor_execute/mixin_execute_signal_create.py b/backend/app/services/ai_pmo_typed_executor_execute/mixin_execute_signal_create.py new file mode 100644 index 00000000..08dddca0 --- /dev/null +++ b/backend/app/services/ai_pmo_typed_executor_execute/mixin_execute_signal_create.py @@ -0,0 +1,153 @@ +"""Signal-create intent execution for typed executor.""" + +from __future__ import annotations + +from .shared import * # noqa: F401,F403 + + +class ExecuteSignalCreateMixin: + """Handle signal create intent with AI analysis and persistence.""" + + async def _execute_signal_create_intent( + self, + *, + intent, + message_text: str, + source_system: str, + source_message_id: str, + channel_ref, + ) -> dict[str, Any]: + metadata = intent.metadata if isinstance(intent.metadata, dict) else {} + workflow_route = RouteResolver.normalize_route_key( + str( + metadata.get("workflow_route") + or LEGACY_INTENT_ROUTE_MAP.get(INTENT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_CREATE) + ) + ) + if workflow_route == ROUTE_PROJECT_SIGNAL_DISCOVER: + return await self.discover_project_signal_candidates( + intent=intent, + message_text=message_text, + source_system=source_system, + source_message_id=source_message_id, + channel_ref=channel_ref, + ) + + requested_count = self._extract_requested_signal_count( + message_text=message_text, + metadata=metadata, + ) + analysis_result = await self._analyze_signal_batch_with_high_tier_model( + intent=intent, + message_text=message_text, + requested_count=requested_count, + ) + analyzed_signals = ( + analysis_result.get("items") + if isinstance(analysis_result.get("items"), list) + else [] + ) + analysis_error_message = str(analysis_result.get("analysis_error") or "").strip() + analysis_fallback_used = bool(analysis_result.get("used_fallback")) + strict_analysis_required = bool(analysis_result.get("strict_analysis_required")) + signal_ids: list[int] = [] + signal_summaries: list[str] = [] + + for signal_row in analyzed_signals: + summary_text = str(signal_row.get("summary") or "")[:300] + signal_content = str(signal_row.get("content") or message_text) + try: + refined_signal_title = await self._refine_signal_title_with_ai( + default_title="新信号", + title_seed=str(signal_row.get("title") or intent.signal_title or ""), + message_text=message_text, + summary=summary_text or str(intent.signal_summary or message_text), + content=signal_content, + intent_name=intent.name, + ) + except Exception as exc: + return { + "status": "failed_safe", + "workflow_action": ROUTE_ACTION_CREATE, + "workflow_route": ROUTE_PROJECT_SIGNAL_CREATE, + "error_message": self._build_signal_title_refine_error_message(exc), + "project_id": intent.project_id, + "requested_signal_count": requested_count, + "analyzed_signal_count": len(signal_ids), + } + + strategic_analysis = { + "source_context": { + "source_message_id": source_message_id, + "channel_ref": channel_ref, + }, + "ai_analysis": { + "summary": summary_text, + "intent": INTENT_SIGNAL_CREATE, + "requirement_project_scope": ( + "existing_project" + if intent.project_id is not None + else "pending_classification" + ), + "requested_count": requested_count, + "analysis_method": ( + "high_tier_batch_model_fallback" + if analysis_fallback_used + else "high_tier_batch_model" + ), + "fallback_used": analysis_fallback_used, + }, + } + signal = await self._create_signal( + title=refined_signal_title, + summary=summary_text or str(intent.signal_summary or message_text), + content=signal_content, + project_id=intent.project_id, + source="PMFlow", + signal_type=str(signal_row.get("signal_type") or "新需求"), + recommended_action="enter_only", + reasoning_bullets=( + signal_row.get("reasoning_bullets") + if isinstance(signal_row.get("reasoning_bullets"), list) + else None + ), + strategic_analysis=strategic_analysis, + priority=str(signal_row.get("priority") or "medium"), + ) + signal_ids.append(int(signal.id)) + signal_summaries.append(summary_text) + + if not signal_ids: + return { + "status": "failed_safe", + "workflow_action": ROUTE_ACTION_CREATE, + "workflow_route": ROUTE_PROJECT_SIGNAL_CREATE, + "error_message": ( + analysis_error_message + or ( + "你要求先分析再入库,但当前分析未成功,本次未自动入库。" + if strict_analysis_required + else "信号分析结果为空,本次未执行入库。" + ) + ), + "project_id": intent.project_id, + "requested_signal_count": requested_count, + "analyzed_signal_count": 0, + } + + primary_signal_id = signal_ids[0] + merged_summary = ";".join([item for item in signal_summaries if item][:2])[:300] + return { + "status": "succeeded", + "workflow_action": ROUTE_ACTION_CREATE, + "workflow_route": ROUTE_PROJECT_SIGNAL_CREATE, + "signal_id": int(primary_signal_id), + "signal_ids": signal_ids, + "project_id": intent.project_id, + "summary": merged_summary or str(intent.signal_summary or message_text)[:300], + "summaries": signal_summaries, + "auto_tasks": [], + "requested_signal_count": requested_count, + "analyzed_signal_count": len(signal_ids), + "analysis_fallback_used": analysis_fallback_used, + } diff --git a/backend/app/services/ai_pmo_typed_executor_execute/mixin_execute_signal_source.py b/backend/app/services/ai_pmo_typed_executor_execute/mixin_execute_signal_source.py new file mode 100644 index 00000000..c4988b56 --- /dev/null +++ b/backend/app/services/ai_pmo_typed_executor_execute/mixin_execute_signal_source.py @@ -0,0 +1,106 @@ +"""Signal-source-onboard intent execution for typed executor.""" + +from __future__ import annotations + +from .shared import * # noqa: F401,F403 + + +class ExecuteSignalSourceMixin: + """Handle signal source onboard intent.""" + + async def _execute_signal_source_onboard_intent( + self, + *, + intent, + message_text: str, + operator_user_id, + source_system: str, + source_message_id: str, + channel_ref, + ) -> dict[str, Any]: + semantic_payload = self._build_signal_source_semantic_payload( + intent=intent, + message_text=message_text, + ) + signal_summary = str(semantic_payload.get("summary") or intent.signal_summary or message_text) + signal_content = str(semantic_payload.get("content") or message_text) + initial_title_seed = self._normalize_title_candidate( + str(semantic_payload.get("title") or intent.signal_title or "") + ) + initial_signal_title = ( + initial_title_seed + if self._is_acceptable_title(initial_title_seed) + else "信号源接入请求" + ) + strategic_payload = ( + semantic_payload.get("strategic_analysis") + if isinstance(semantic_payload.get("strategic_analysis"), dict) + else {} + ) + source_context = ( + strategic_payload.get("source_context") + if isinstance(strategic_payload.get("source_context"), dict) + else {} + ) + source_context["source_message_id"] = source_message_id + source_context["channel_ref"] = channel_ref + strategic_payload["source_context"] = source_context + signal = await self._create_signal( + title=initial_signal_title, + summary=signal_summary, + content=signal_content, + project_id=intent.project_id, + source="PMFlow", + signal_type="新需求", + recommended_action="enter_only", + reasoning_bullets=( + semantic_payload.get("reasoning_bullets") + if isinstance(semantic_payload.get("reasoning_bullets"), list) + else None + ), + strategic_analysis=( + strategic_payload if isinstance(strategic_payload, dict) else None + ), + ) + domain_workflow = await self.signal_source_workflow.run( + tenant_id=self.tenant_id, + project_id=int(intent.project_id or 0), + signal_id=int(signal.id), + initiating_text=message_text, + requested_via=source_system, + operator_user_id=operator_user_id, + ) + orchestration = ( + domain_workflow.get("raw_result") + if isinstance(domain_workflow.get("raw_result"), dict) + else {} + ) + signal_source_payload = ( + orchestration.get("signal_source") + if isinstance(orchestration.get("signal_source"), dict) + else {} + ) + ai_source_title = self._normalize_title_candidate(signal_source_payload.get("source_name")) + if self._is_acceptable_title(ai_source_title): + signal.title = ai_source_title + await self.db.flush() + return { + "status": ( + "succeeded" + if str(domain_workflow.get("status") or "").lower() == "completed" + else ( + "pending_manual" + if str(domain_workflow.get("status") or "").lower() == "pending_manual" + else "failed_safe" + ) + ), + "signal_id": int(signal.id), + "project_id": intent.project_id, + "signal_source_workflow": domain_workflow, + "signal_source_orchestration": orchestration, + "auto_tasks": self._extract_auto_tasks( + orchestration.get("execution") + if isinstance(orchestration.get("execution"), dict) + else orchestration.get("openclaw") + ), + } diff --git a/backend/app/services/ai_pmo_typed_executor_execute/mixin_helpers.py b/backend/app/services/ai_pmo_typed_executor_execute/mixin_helpers.py new file mode 100644 index 00000000..ddd7b8ef --- /dev/null +++ b/backend/app/services/ai_pmo_typed_executor_execute/mixin_helpers.py @@ -0,0 +1,249 @@ +"""Helper utilities for typed executor execute flow.""" + +from __future__ import annotations + +from .shared import * # noqa: F401,F403 + + +class ExecuteHelpersMixin: + """Shared helper methods for execute intent handlers.""" + + @staticmethod + def _build_query_error_message(error: Exception) -> str: + reason = re.sub(r"\s+", " ", str(error or "")).strip().strip("'\"") + reason = re.sub( + r"^(runtimeerror|valueerror|exception)\s*:\s*", + "", + reason, + flags=re.IGNORECASE, + ) + if len(reason) > 120: + reason = f"{reason[:120]}..." + marker = "模型调用失败:" + while reason.startswith(marker): + reason = reason[len(marker) :].strip() + if not reason: + reason = "unknown_error" + message = f"{marker}{reason}" + return message if "请稍后重试" in message else f"{message}。请稍后重试。" + + @staticmethod + def _build_signal_title_refine_error_message(error: Exception) -> str: + reason = " ".join(str(error or "").split()).strip() + if not reason: + reason = "unknown_error" + return f"信号标题 AI 提炼失败:{reason}。" + + @staticmethod + def _normalize_title_candidate(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + blocked_chars = {"\n", "\r", "\t", "{", "}", "[", "]"} + if any(ch in text for ch in blocked_chars): + return "" + return text + + @staticmethod + def _is_acceptable_title(text: str) -> bool: + if not text: + return False + if len(text) < 2: + return False + if len(text) > 200: + return False + return True + + @staticmethod + def _extract_title_from_ai_response(raw_text: str) -> str: + raw = str(raw_text or "").strip() + if not raw: + return "" + if raw.startswith("```"): + chunks = [chunk.strip() for chunk in raw.split("```") if chunk.strip()] + if chunks: + first = chunks[0] + if first.lower().startswith("json"): + first = first[4:].strip() + raw = first.strip() + if raw.startswith("{") and raw.endswith("}"): + try: + payload = json.loads(raw) + except Exception: + payload = {} + if isinstance(payload, dict): + title = payload.get("title") + if isinstance(title, str): + return title.strip() + if "{" in raw and "}" in raw: + start = raw.find("{") + end = raw.rfind("}") + if start >= 0 and end > start: + try: + payload = json.loads(raw[start : end + 1]) + except Exception: + payload = {} + if isinstance(payload, dict): + title = payload.get("title") + if isinstance(title, str): + return title.strip() + first_line = raw.splitlines()[0].strip() + return first_line.strip("'\" ") + + async def _refine_signal_title_with_ai( + self, + *, + default_title: str, + title_seed: str, + message_text: str, + summary: str, + content: str, + intent_name: str, + ) -> str: + payload = { + "intent": str(intent_name or ""), + "default_title": str(default_title or ""), + "title_seed": str(title_seed or ""), + "message_text": str(message_text or ""), + "summary": str(summary or ""), + "content": str(content or ""), + } + prompt = ( + "你是 PMFlow 的信号标题提炼器。请根据输入语义提炼一个可入库标题。\n" + "要求:\n" + "1) 必须是语义主题短语,不要命令语气,不要'请/帮我/新增/增加'等动作词。\n" + "2) 不要项目ID、@提及、括号说明。\n" + "3) 使用中文,8-24字。\n" + "4) 仅输出标题文本,不要前后缀说明。\n\n" + f"输入数据:{json.dumps(payload, ensure_ascii=False)}" + ) + try: + raw_response = await llm_service.generate( + prompt, + db=self.db, + tenant_id=self.tenant_id, + function_name="project_draft_analysis_glm47_fallback", + use_prompt_profile=False, + temperature=0.0, + max_tokens=240, + response_mode="complete", + ) + ai_title = self._normalize_title_candidate( + self._extract_title_from_ai_response(raw_response) + ) + if self._is_acceptable_title(ai_title): + return ai_title + raise ValueError("ai_pmo_signal_title_refine_invalid_payload") + except Exception as exc: + logger.warning("ai_pmo_signal_title_refine_failed error=%s", exc) + raise RuntimeError( + f"ai_pmo_signal_title_refine_failed:{type(exc).__name__}" + ) from exc + + async def _create_signal( + self, + *, + title: str, + summary: str, + content: str, + project_id: Optional[int], + source: str, + signal_type: str, + recommended_action: str, + priority: str = "medium", + reasoning_bullets: Optional[list[str]] = None, + strategic_analysis: Optional[dict[str, Any]] = None, + ) -> Signal: + normalized_priority = str(priority or "medium").strip().lower() + if normalized_priority not in {"high", "medium", "low"}: + normalized_priority = "medium" + normalized_title = self._normalize_title_candidate(title) + if not self._is_acceptable_title(normalized_title): + raise ValueError("signal_title_invalid") + signal = Signal( + tenant_id=self.tenant_id, + source=source, + title=normalized_title, + summary=(summary or content or "")[:300], + content=(content or summary or "")[:2000], + signal_type=signal_type, + reasoning=json.dumps( + reasoning_bullets or ["由 AI PMO kernel 统一入库。"], + ensure_ascii=False, + ), + strategic_analysis=( + json.dumps(strategic_analysis, ensure_ascii=False) + if isinstance(strategic_analysis, dict) + else None + ), + confidence=0.78, + recommended_action=recommended_action, + matched_project_id=int(project_id) if project_id is not None else None, + impact_score=0.7, + priority=normalized_priority, + processed=False, + tasks_generated=json.dumps([], ensure_ascii=False), + status="待处理", + ) + self.db.add(signal) + await self.db.flush() + if project_id is not None: + exists = ( + await self.db.execute( + select(ProjectSignalLink.id) + .where( + and_( + ProjectSignalLink.tenant_id == self.tenant_id, + ProjectSignalLink.project_id == int(project_id), + ProjectSignalLink.signal_id == int(signal.id), + ) + ) + .limit(1) + ) + ).scalar_one_or_none() + if exists is None: + self.db.add( + ProjectSignalLink( + tenant_id=self.tenant_id, + project_id=int(project_id), + signal_id=int(signal.id), + link_type="related", + relevance_score=1.0, + ) + ) + await self.db.flush() + return signal + + @staticmethod + def _extract_auto_tasks(openclaw_payload: Any) -> list[dict[str, Any]]: + if not isinstance(openclaw_payload, dict): + return [] + result = ( + openclaw_payload.get("result") + if isinstance(openclaw_payload.get("result"), dict) + else openclaw_payload + ) + execution = ( + result.get("execution") + if isinstance(result, dict) and isinstance(result.get("execution"), dict) + else {} + ) + rows = execution.get("results") if isinstance(execution.get("results"), list) else [] + items: list[dict[str, Any]] = [] + for row in rows: + if not isinstance(row, dict): + continue + operation_code = str(row.get("operation_code") or "").strip() + if operation_code != "task.create": + continue + payload = row.get("payload") if isinstance(row.get("payload"), dict) else {} + items.append( + { + "operation_code": operation_code, + "status": str(row.get("status") or "").strip(), + "entity_id": row.get("entity_id"), + "title": payload.get("title"), + "task_id": payload.get("task_id"), + } + ) + return items diff --git a/backend/app/services/ai_pmo_typed_executor_execute/service.py b/backend/app/services/ai_pmo_typed_executor_execute/service.py new file mode 100644 index 00000000..0877babc --- /dev/null +++ b/backend/app/services/ai_pmo_typed_executor_execute/service.py @@ -0,0 +1,66 @@ +"""Execute entry mixin composition for typed executor.""" + +from __future__ import annotations + +from .mixin_execute_query_project import ExecuteQueryProjectMixin +from .mixin_execute_signal_create import ExecuteSignalCreateMixin +from .mixin_execute_signal_source import ExecuteSignalSourceMixin +from .mixin_helpers import ExecuteHelpersMixin +from .shared import * # noqa: F401,F403 + + +class TypedExecutorExecuteMixin( + ExecuteHelpersMixin, + ExecuteQueryProjectMixin, + ExecuteSignalSourceMixin, + ExecuteSignalCreateMixin, +): + async def execute( + self, + *, + intent: CanonicalIntent, + message_text: str, + sender: str, + operator_user_id: Optional[int], + source_system: str, + source_message_id: str, + channel_ref: Optional[str] = None, + ) -> dict[str, Any]: + if intent.name == INTENT_QUERY: + return await self._execute_query_intent( + intent=intent, + message_text=message_text, + operator_user_id=operator_user_id, + source_system=source_system, + source_message_id=source_message_id, + ) + + if intent.name == INTENT_PROJECT_CREATE: + return await self._execute_project_create_intent( + message_text=message_text, + operator_user_id=operator_user_id, + ) + + if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: + return await self._execute_signal_source_onboard_intent( + intent=intent, + message_text=message_text, + operator_user_id=operator_user_id, + source_system=source_system, + source_message_id=source_message_id, + channel_ref=channel_ref, + ) + + if intent.name == INTENT_SIGNAL_CREATE: + return await self._execute_signal_create_intent( + intent=intent, + message_text=message_text, + source_system=source_system, + source_message_id=source_message_id, + channel_ref=channel_ref, + ) + + return { + "status": "failed_safe", + "error_message": "当前意图尚未迁移到 AI PMO kernel,已保留给兼容链路处理。", + } diff --git a/backend/app/services/ai_pmo_typed_executor_execute/shared.py b/backend/app/services/ai_pmo_typed_executor_execute/shared.py new file mode 100644 index 00000000..6d287d2c --- /dev/null +++ b/backend/app/services/ai_pmo_typed_executor_execute/shared.py @@ -0,0 +1,9 @@ +"""Shared symbol export for typed executor execute modules.""" + +from __future__ import annotations + +from .. import ai_pmo_typed_executor_common as _common + +for _name, _value in vars(_common).items(): + if not _name.startswith("__"): + globals()[_name] = _value diff --git a/backend/app/services/ai_pmo_typed_executor_mixins_discovery.py b/backend/app/services/ai_pmo_typed_executor_mixins_discovery.py index 26ae352b..8f62e0bd 100644 --- a/backend/app/services/ai_pmo_typed_executor_mixins_discovery.py +++ b/backend/app/services/ai_pmo_typed_executor_mixins_discovery.py @@ -1,402 +1,5 @@ -"""AI PMO typed executor mixin.""" +"""Compatibility exports for typed executor discovery mixin.""" -from __future__ import annotations +from .ai_pmo_typed_executor_discovery import TypedExecutorDiscoveryMixin -from . import ai_pmo_typed_executor_common as _common - -for _name, _value in vars(_common).items(): - if not _name.startswith("__"): - globals()[_name] = _value - - -class TypedExecutorDiscoveryMixin: - async def _analyze_signal_batch_with_high_tier_model( - self, - *, - intent: CanonicalIntent, - message_text: str, - requested_count: int, - ) -> dict[str, Any]: - strict_analysis_required = self._requires_strict_signal_analysis( - message_text=message_text, - requested_count=requested_count, - ) - prompt = PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS.format( - message_text=str(message_text or "")[:1600], - project_id=intent.project_id if intent.project_id is not None else "null", - requested_count=max(requested_count, 1), - ).strip() - raw_text = "" - try: - raw_text = await llm_service.generate( - prompt, - db=self.db, - tenant_id=self.tenant_id, - function_name="adaptive_rag_answer_deep_signal_batch_analysis", - use_prompt_profile=False, - temperature=0.1, - max_tokens=min(2800, 520 + max(requested_count, 1) * 420), - response_mode="complete", - ) - except Exception as exc: - logger.warning( - "ai_pmo_signal_batch_analysis_failed", - tenant_id=self.tenant_id, - project_id=intent.project_id, - requested_count=requested_count, - error=str(exc), - ) - - payload = self._parse_signal_batch_payload(raw_text) - raw_items = payload.get("signals") if isinstance(payload.get("signals"), list) else [] - if not raw_items: - logger.warning( - "ai_pmo_signal_batch_analysis_invalid_payload", - tenant_id=self.tenant_id, - project_id=intent.project_id, - requested_count=requested_count, - raw_preview=str(raw_text or "")[:260], - ) - error_message = "你要求先分析再入库,但当前信号分析结果不可解析,本次未自动入库。" - logger.warning( - "ai_pmo_signal_batch_analysis_strict_blocked", - tenant_id=self.tenant_id, - project_id=intent.project_id, - requested_count=requested_count, - message_preview=str(message_text or "")[:200], - ) - return { - "items": [], - "used_fallback": False, - "analysis_error": error_message, - "strict_analysis_required": strict_analysis_required, - } - return { - "items": self._normalize_signal_batch_items( - message_text=message_text, - requested_count=requested_count, - raw_items=raw_items, - ), - "used_fallback": False, - "analysis_error": "", - "strict_analysis_required": strict_analysis_required, - } - - @staticmethod - def _build_candidate_dedupe_key(*, project_id: int, candidate: dict[str, Any]) -> str: - title = str(candidate.get("title") or "").strip().lower() - summary = str(candidate.get("summary") or "").strip().lower() - signal_type = str(candidate.get("signal_type") or "新需求").strip().lower() - payload = f"{int(project_id)}|{title}|{summary}|{signal_type}" - return hashlib.sha1(payload.encode("utf-8"), usedforsecurity=False).hexdigest()[:40] - - async def _collect_project_signal_discovery_context( - self, - *, - project_id: int, - message_text: str, - source_chat_id: Optional[str], - ) -> dict[str, Any]: - rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) - retrieval_result: dict[str, Any] = {} - entity_result: dict[str, Any] = {} - retrieval_error: Optional[str] = None - entity_error: Optional[str] = None - try: - retrieval_result = await rag_service.multi_step_search( - question=message_text, - project_id=int(project_id), - source_chat_id=source_chat_id, - ) - except Exception as exc: - retrieval_error = str(exc) - logger.warning( - "project_signal_discover_multi_step_search_failed", - tenant_id=self.tenant_id, - project_id=project_id, - error=str(exc), - ) - try: - entity_result = await rag_service.entity_resolution( - question=message_text, - project_id=int(project_id), - limit=8, - ) - except Exception as exc: - entity_error = str(exc) - logger.warning( - "project_signal_discover_entity_resolution_failed", - tenant_id=self.tenant_id, - project_id=project_id, - error=str(exc), - ) - if retrieval_error or entity_error: - raise RuntimeError( - "project_signal_discovery_retrieval_failed:" - f"multi_step={retrieval_error or 'ok'};" - f"entity={entity_error or 'ok'}" - ) - - project = ( - await self.db.execute( - select(Project).where( - and_( - Project.tenant_id == self.tenant_id, - Project.id == int(project_id), - ) - ).limit(1) - ) - ).scalar_one_or_none() - if project is None: - raise ValueError(f"project_not_found:{project_id}") - - truth_nodes = ( - await self.db.execute( - select(TruthNode).where( - and_( - TruthNode.tenant_id == self.tenant_id, - TruthNode.project_id == int(project_id), - ) - ).order_by(TruthNode.updated_at.desc(), TruthNode.id.desc()).limit(6) - ) - ).scalars().all() - experiments = ( - await self.db.execute( - select(Experiment).where( - and_( - Experiment.tenant_id == self.tenant_id, - Experiment.project_id == int(project_id), - ) - ).order_by(Experiment.updated_at.desc(), Experiment.id.desc()).limit(5) - ) - ).scalars().all() - recent_signals = ( - await self.db.execute( - select(Signal).where( - and_( - Signal.tenant_id == self.tenant_id, - or_( - Signal.matched_project_id == int(project_id), - Signal.id.in_( - select(ProjectSignalLink.signal_id).where( - and_( - ProjectSignalLink.tenant_id == self.tenant_id, - ProjectSignalLink.project_id == int(project_id), - ) - ) - ), - ), - ) - ).order_by(Signal.updated_at.desc(), Signal.id.desc()).limit(8) - ) - ).scalars().all() - source_registries = ( - await self.db.execute( - select(SignalSourceRegistry).where( - and_( - SignalSourceRegistry.tenant_id == self.tenant_id, - SignalSourceRegistry.project_id == int(project_id), - ) - ).order_by(SignalSourceRegistry.updated_at.desc(), SignalSourceRegistry.id.desc()).limit(8) - ) - ).scalars().all() - - capability_bundle = await ModelContextBundleService.build_context_bundle( - self.db, - tenant_id=self.tenant_id, - function_name="classify_intent_complex", - ) - - return { - "project": { - "id": int(project.id), - "title": str(project.title or project.name or ""), - "status": str(project.status or ""), - }, - "truth_nodes": [ - { - "id": int(node.id), - "node_type": str(node.node_type or ""), - "title": str(node.title or "")[:120], - "status": str(node.status or ""), - } - for node in truth_nodes - ], - "experiments": [ - { - "id": int(exp.id), - "title": str(exp.title or "")[:120], - "status": str(exp.status or ""), - "target_metric": str(exp.target_metric or "")[:80], - } - for exp in experiments - ], - "recent_signals": [ - { - "id": int(sig.id), - "title": str(sig.title or "")[:120], - "status": str(sig.status or ""), - "signal_type": str(sig.signal_type or "")[:30], - } - for sig in recent_signals - ], - "source_registries": [ - { - "id": int(src.id), - "source_kind": str(src.source_kind or ""), - "source_name": str(src.source_name or "")[:120], - "status": str(src.status or ""), - "onboarding_status": str(src.onboarding_status or ""), - } - for src in source_registries - ], - "retrieval_items": retrieval_result.get("items") if isinstance(retrieval_result.get("items"), list) else [], - "retrieval_metrics": retrieval_result.get("retrieval_metrics") if isinstance(retrieval_result.get("retrieval_metrics"), dict) else {}, - "entity_matches": entity_result.get("matched_entities") if isinstance(entity_result.get("matched_entities"), list) else [], - "tool_trace": retrieval_result.get("tool_trace") if isinstance(retrieval_result.get("tool_trace"), list) else [], - "capability_bundle": capability_bundle if isinstance(capability_bundle, dict) else {}, - } - - async def discover_project_signal_candidates( - self, - *, - intent: CanonicalIntent, - message_text: str, - source_system: str, - source_message_id: str, - channel_ref: Optional[str], - ) -> dict[str, Any]: - _ = source_system - _ = source_message_id - project_id = _safe_int(intent.project_id) - if project_id is None: - return { - "status": "failed_safe", - "workflow_action": ROUTE_ACTION_DISCOVER, - "workflow_route": ROUTE_PROJECT_SIGNAL_DISCOVER, - "error_message": "项目不存在或未提供项目ID,无法执行候选发现。", - "candidate_signals": [], - } - metadata = intent.metadata if isinstance(intent.metadata, dict) else {} - requested_count = self._extract_requested_signal_count( - message_text=message_text, - metadata=metadata, - ) - - analysis_result = await self._analyze_signal_batch_with_high_tier_model( - intent=intent, - message_text=message_text, - requested_count=requested_count, - ) - analyzed_signals = analysis_result.get("items") if isinstance(analysis_result.get("items"), list) else [] - if not analyzed_signals: - return { - "status": "failed_safe", - "workflow_action": ROUTE_ACTION_DISCOVER, - "workflow_route": ROUTE_PROJECT_SIGNAL_DISCOVER, - "error_message": str(analysis_result.get("analysis_error") or "候选发现失败,请稍后重试。"), - "candidate_signals": [], - "requested_count": requested_count, - } - try: - context = await self._collect_project_signal_discovery_context( - project_id=int(project_id), - message_text=message_text, - source_chat_id=channel_ref, - ) - except Exception as exc: - logger.warning( - "project_signal_discover_context_failed", - tenant_id=self.tenant_id, - project_id=project_id, - error=str(exc), - ) - return { - "status": "failed_safe", - "workflow_action": ROUTE_ACTION_DISCOVER, - "workflow_route": ROUTE_PROJECT_SIGNAL_DISCOVER, - "project_id": int(project_id), - "requested_count": requested_count, - "error_message": "项目候选检索失败,请稍后重试。", - "candidate_signals": [], - } - retrieval_items = context.get("retrieval_items") if isinstance(context.get("retrieval_items"), list) else [] - candidate_signals: list[dict[str, Any]] = [] - for index, row in enumerate(analyzed_signals[:requested_count]): - if not isinstance(row, dict): - continue - try: - refined_title = await self._refine_signal_title_with_ai( - default_title=f"候选信号 {index + 1}", - title_seed=str(row.get("title") or intent.signal_title or ""), - message_text=message_text, - summary=str(row.get("summary") or ""), - content=str(row.get("content") or ""), - intent_name=ROUTE_PROJECT_SIGNAL_DISCOVER, - ) - except Exception as exc: - return { - "status": "failed_safe", - "workflow_action": ROUTE_ACTION_DISCOVER, - "workflow_route": ROUTE_PROJECT_SIGNAL_DISCOVER, - "project_id": int(project_id), - "requested_count": requested_count, - "error_message": self._build_signal_title_refine_error_message(exc), - "candidate_signals": [], - } - evidence_excerpt = "" - if index < len(retrieval_items) and isinstance(retrieval_items[index], dict): - evidence_excerpt = str(retrieval_items[index].get("content") or "")[:240] - candidate_for_dedupe = dict(row) - candidate_for_dedupe["title"] = refined_title - dedupe_key = self._build_candidate_dedupe_key(project_id=int(project_id), candidate=candidate_for_dedupe) - candidate_signals.append( - { - "title": refined_title, - "summary": str(row.get("summary") or "")[:300], - "content": str(row.get("content") or "")[:1800], - "signal_type": str(row.get("signal_type") or "新需求"), - "priority": str(row.get("priority") or "medium"), - "reasoning_bullets": row.get("reasoning_bullets") if isinstance(row.get("reasoning_bullets"), list) else [], - "dedupe_key": dedupe_key, - "evidence_excerpt": evidence_excerpt, - } - ) - - retrieval_metrics = context.get("retrieval_metrics") if isinstance(context.get("retrieval_metrics"), dict) else {} - search_summary = ( - f"项目#{int(project_id)} 候选发现完成:候选{len(candidate_signals)}条," - f"entity命中{len(context.get('entity_matches') if isinstance(context.get('entity_matches'), list) else [])}," - f"检索耗时db={int(retrieval_metrics.get('db_query_ms') or 0)}ms/" - f"vector={int(retrieval_metrics.get('vector_search_ms') or 0)}ms/" - f"graph={int(retrieval_metrics.get('graph_query_ms') or 0)}ms。" - ) - expires_at = (_utc_now_naive() + timedelta(hours=24)).isoformat() - return { - "status": "pending_confirmation", - "workflow_action": ROUTE_ACTION_DISCOVER, - "workflow_route": ROUTE_PROJECT_SIGNAL_DISCOVER, - "project_id": int(project_id), - "requested_count": requested_count, - "candidate_signals": candidate_signals, - "search_summary": search_summary, - "discovery_sources": { - "tool_trace": context.get("tool_trace") if isinstance(context.get("tool_trace"), list) else [], - "entity_matches": context.get("entity_matches") if isinstance(context.get("entity_matches"), list) else [], - "source_registries": context.get("source_registries") if isinstance(context.get("source_registries"), list) else [], - }, - "capability_context": { - "truth_nodes": context.get("truth_nodes") if isinstance(context.get("truth_nodes"), list) else [], - "experiments": context.get("experiments") if isinstance(context.get("experiments"), list) else [], - "recent_signals": context.get("recent_signals") if isinstance(context.get("recent_signals"), list) else [], - "model_context_bundle": context.get("capability_bundle") if isinstance(context.get("capability_bundle"), dict) else {}, - "workload": metadata.get("world_model_capacity") if isinstance(metadata.get("world_model_capacity"), list) else [], - "skill_graph": metadata.get("world_model_skill_graph") if isinstance(metadata.get("world_model_skill_graph"), list) else [], - }, - "confirmation_scope": "project_signal_commit", - "confirmation_expires_at": expires_at, - "followup_reply": ( - f"已为项目#{int(project_id)} 找到 {len(candidate_signals)} 条候选信号," - "请回复“确认”提交,回复“换一批”刷新,或回复“取消”结束本次候选。" - ), - } +__all__ = ["TypedExecutorDiscoveryMixin"] diff --git a/backend/app/services/ai_pmo_typed_executor_mixins_execute.py b/backend/app/services/ai_pmo_typed_executor_mixins_execute.py index 3532ce84..a8a54191 100644 --- a/backend/app/services/ai_pmo_typed_executor_mixins_execute.py +++ b/backend/app/services/ai_pmo_typed_executor_mixins_execute.py @@ -1,483 +1,5 @@ -"""AI PMO typed executor mixin.""" +"""Compatibility exports for typed executor execute mixin.""" -from __future__ import annotations +from .ai_pmo_typed_executor_execute import TypedExecutorExecuteMixin -from . import ai_pmo_typed_executor_common as _common - -for _name, _value in vars(_common).items(): - if not _name.startswith("__"): - globals()[_name] = _value - - -class TypedExecutorExecuteMixin: - async def execute( - self, - *, - intent: CanonicalIntent, - message_text: str, - sender: str, - operator_user_id: Optional[int], - source_system: str, - source_message_id: str, - channel_ref: Optional[str] = None, - ) -> dict[str, Any]: - if intent.name == INTENT_QUERY: - rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) - try: - rag_result = await rag_service.answer_question( - question=message_text, - asked_by=operator_user_id, - asked_via=source_system, - project_id=intent.project_id, - source_chat_id=source_message_id, - conversation_key=source_message_id, - action_mode="read", - sync_requirement=False, - ) - except Exception as exc: - return { - "status": "failed_safe", - "error_message": self._build_query_error_message(exc), - "project_id": intent.project_id, - } - return { - "status": "succeeded", - "answer": str(rag_result.get("answer") or "").strip(), - "rag_result": rag_result, - "project_id": intent.project_id, - } - - if intent.name == INTENT_PROJECT_CREATE: - if operator_user_id is None: - return { - "status": "failed_safe", - "error_message": "未找到可用项目创建人,当前无法自动创建项目。", - } - steward = AIStewardService(self.db) - payload = await steward.create_project_from_natural_language( - input_text=message_text, - creator_id=int(operator_user_id), - tenant_id=self.tenant_id, - ) - project = payload.get("project") if isinstance(payload.get("project"), dict) else {} - signal = payload.get("signal") if isinstance(payload.get("signal"), dict) else {} - return { - "status": "succeeded", - "project": project, - "signal": signal, - "project_id": _safe_int(project.get("id")), - "signal_id": _safe_int(signal.get("id")), - "tasks": payload.get("tasks") if isinstance(payload.get("tasks"), list) else [], - "raw_payload": payload, - } - - if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: - semantic_payload = self._build_signal_source_semantic_payload( - intent=intent, - message_text=message_text, - ) - signal_summary = str(semantic_payload.get("summary") or intent.signal_summary or message_text) - signal_content = str(semantic_payload.get("content") or message_text) - initial_title_seed = self._normalize_title_candidate( - str(semantic_payload.get("title") or intent.signal_title or "") - ) - initial_signal_title = initial_title_seed if self._is_acceptable_title(initial_title_seed) else "信号源接入请求" - strategic_payload = ( - semantic_payload.get("strategic_analysis") - if isinstance(semantic_payload.get("strategic_analysis"), dict) - else {} - ) - source_context = ( - strategic_payload.get("source_context") - if isinstance(strategic_payload.get("source_context"), dict) - else {} - ) - source_context["source_message_id"] = source_message_id - source_context["channel_ref"] = channel_ref - strategic_payload["source_context"] = source_context - signal = await self._create_signal( - title=initial_signal_title, - summary=signal_summary, - content=signal_content, - project_id=intent.project_id, - source="PMFlow", - signal_type="新需求", - recommended_action="enter_only", - reasoning_bullets=( - semantic_payload.get("reasoning_bullets") - if isinstance(semantic_payload.get("reasoning_bullets"), list) - else None - ), - strategic_analysis=( - strategic_payload - if isinstance(strategic_payload, dict) - else None - ), - ) - domain_workflow = await self.signal_source_workflow.run( - tenant_id=self.tenant_id, - project_id=int(intent.project_id or 0), - signal_id=int(signal.id), - initiating_text=message_text, - requested_via=source_system, - operator_user_id=operator_user_id, - ) - orchestration = domain_workflow.get("raw_result") if isinstance(domain_workflow.get("raw_result"), dict) else {} - signal_source_payload = orchestration.get("signal_source") if isinstance(orchestration.get("signal_source"), dict) else {} - ai_source_title = self._normalize_title_candidate(signal_source_payload.get("source_name")) - if self._is_acceptable_title(ai_source_title): - signal.title = ai_source_title - await self.db.flush() - return { - "status": "succeeded" if str(domain_workflow.get("status") or "").lower() == "completed" else ( - "pending_manual" if str(domain_workflow.get("status") or "").lower() == "pending_manual" else "failed_safe" - ), - "signal_id": int(signal.id), - "project_id": intent.project_id, - "signal_source_workflow": domain_workflow, - "signal_source_orchestration": orchestration, - "auto_tasks": self._extract_auto_tasks( - orchestration.get("execution") if isinstance(orchestration.get("execution"), dict) else orchestration.get("openclaw") - ), - } - - if intent.name == INTENT_SIGNAL_CREATE: - metadata = intent.metadata if isinstance(intent.metadata, dict) else {} - workflow_route = RouteResolver.normalize_route_key( - str(metadata.get("workflow_route") or LEGACY_INTENT_ROUTE_MAP.get(INTENT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_CREATE)) - ) - if workflow_route == ROUTE_PROJECT_SIGNAL_DISCOVER: - return await self.discover_project_signal_candidates( - intent=intent, - message_text=message_text, - source_system=source_system, - source_message_id=source_message_id, - channel_ref=channel_ref, - ) - requested_count = self._extract_requested_signal_count( - message_text=message_text, - metadata=metadata, - ) - analysis_result = await self._analyze_signal_batch_with_high_tier_model( - intent=intent, - message_text=message_text, - requested_count=requested_count, - ) - analyzed_signals = analysis_result.get("items") if isinstance(analysis_result.get("items"), list) else [] - analysis_error_message = str(analysis_result.get("analysis_error") or "").strip() - analysis_fallback_used = bool(analysis_result.get("used_fallback")) - strict_analysis_required = bool(analysis_result.get("strict_analysis_required")) - signal_ids: list[int] = [] - signal_summaries: list[str] = [] - for signal_row in analyzed_signals: - summary_text = str(signal_row.get("summary") or "")[:300] - signal_content = str(signal_row.get("content") or message_text) - try: - refined_signal_title = await self._refine_signal_title_with_ai( - default_title="新信号", - title_seed=str(signal_row.get("title") or intent.signal_title or ""), - message_text=message_text, - summary=summary_text or str(intent.signal_summary or message_text), - content=signal_content, - intent_name=intent.name, - ) - except Exception as exc: - return { - "status": "failed_safe", - "workflow_action": ROUTE_ACTION_CREATE, - "workflow_route": ROUTE_PROJECT_SIGNAL_CREATE, - "error_message": self._build_signal_title_refine_error_message(exc), - "project_id": intent.project_id, - "requested_signal_count": requested_count, - "analyzed_signal_count": len(signal_ids), - } - strategic_analysis = { - "source_context": { - "source_message_id": source_message_id, - "channel_ref": channel_ref, - }, - "ai_analysis": { - "summary": summary_text, - "intent": INTENT_SIGNAL_CREATE, - "requirement_project_scope": ( - "existing_project" if intent.project_id is not None else "pending_classification" - ), - "requested_count": requested_count, - "analysis_method": ( - "high_tier_batch_model_fallback" if analysis_fallback_used else "high_tier_batch_model" - ), - "fallback_used": analysis_fallback_used, - }, - } - signal = await self._create_signal( - title=refined_signal_title, - summary=summary_text or str(intent.signal_summary or message_text), - content=signal_content, - project_id=intent.project_id, - source="PMFlow", - signal_type=str(signal_row.get("signal_type") or "新需求"), - recommended_action="enter_only", - reasoning_bullets=( - signal_row.get("reasoning_bullets") - if isinstance(signal_row.get("reasoning_bullets"), list) - else None - ), - strategic_analysis=strategic_analysis, - priority=str(signal_row.get("priority") or "medium"), - ) - signal_ids.append(int(signal.id)) - signal_summaries.append(summary_text) - - if not signal_ids: - return { - "status": "failed_safe", - "workflow_action": ROUTE_ACTION_CREATE, - "workflow_route": ROUTE_PROJECT_SIGNAL_CREATE, - "error_message": ( - analysis_error_message - or ( - "你要求先分析再入库,但当前分析未成功,本次未自动入库。" - if strict_analysis_required - else "信号分析结果为空,本次未执行入库。" - ) - ), - "project_id": intent.project_id, - "requested_signal_count": requested_count, - "analyzed_signal_count": 0, - } - - primary_signal_id = signal_ids[0] - merged_summary = ";".join([item for item in signal_summaries if item][:2])[:300] - return { - "status": "succeeded", - "workflow_action": ROUTE_ACTION_CREATE, - "workflow_route": ROUTE_PROJECT_SIGNAL_CREATE, - "signal_id": int(primary_signal_id), - "signal_ids": signal_ids, - "project_id": intent.project_id, - "summary": merged_summary or str(intent.signal_summary or message_text)[:300], - "summaries": signal_summaries, - "auto_tasks": [], - "requested_signal_count": requested_count, - "analyzed_signal_count": len(signal_ids), - "analysis_fallback_used": analysis_fallback_used, - } - - return { - "status": "failed_safe", - "error_message": "当前意图尚未迁移到 AI PMO kernel,已保留给兼容链路处理。", - } - - @staticmethod - def _build_query_error_message(error: Exception) -> str: - reason = re.sub(r"\s+", " ", str(error or "")).strip().strip("'\"") - reason = re.sub(r"^(runtimeerror|valueerror|exception)\s*:\s*", "", reason, flags=re.IGNORECASE) - if len(reason) > 120: - reason = f"{reason[:120]}..." - marker = "模型调用失败:" - while reason.startswith(marker): - reason = reason[len(marker):].strip() - if not reason: - reason = "unknown_error" - message = f"{marker}{reason}" - return message if "请稍后重试" in message else f"{message}。请稍后重试。" - - @staticmethod - def _build_signal_title_refine_error_message(error: Exception) -> str: - reason = " ".join(str(error or "").split()).strip() - if not reason: - reason = "unknown_error" - return f"信号标题 AI 提炼失败:{reason}。" - - @staticmethod - def _normalize_title_candidate(value: Any) -> str: - text = str(value or "").strip() - if not text: - return "" - blocked_chars = {"\n", "\r", "\t", "{", "}", "[", "]"} - if any(ch in text for ch in blocked_chars): - return "" - return text - - @staticmethod - def _is_acceptable_title(text: str) -> bool: - if not text: - return False - if len(text) < 2: - return False - if len(text) > 200: - return False - return True - - @staticmethod - def _extract_title_from_ai_response(raw_text: str) -> str: - raw = str(raw_text or "").strip() - if not raw: - return "" - if raw.startswith("```"): - chunks = [chunk.strip() for chunk in raw.split("```") if chunk.strip()] - if chunks: - first = chunks[0] - if first.lower().startswith("json"): - first = first[4:].strip() - raw = first.strip() - if raw.startswith("{") and raw.endswith("}"): - try: - payload = json.loads(raw) - except Exception: - payload = {} - if isinstance(payload, dict): - title = payload.get("title") - if isinstance(title, str): - return title.strip() - if "{" in raw and "}" in raw: - start = raw.find("{") - end = raw.rfind("}") - if start >= 0 and end > start: - try: - payload = json.loads(raw[start : end + 1]) - except Exception: - payload = {} - if isinstance(payload, dict): - title = payload.get("title") - if isinstance(title, str): - return title.strip() - first_line = raw.splitlines()[0].strip() - return first_line.strip("'\" ") - - async def _refine_signal_title_with_ai( - self, - *, - default_title: str, - title_seed: str, - message_text: str, - summary: str, - content: str, - intent_name: str, - ) -> str: - payload = { - "intent": str(intent_name or ""), - "default_title": str(default_title or ""), - "title_seed": str(title_seed or ""), - "message_text": str(message_text or ""), - "summary": str(summary or ""), - "content": str(content or ""), - } - prompt = ( - "你是 PMFlow 的信号标题提炼器。请根据输入语义提炼一个可入库标题。\n" - "要求:\n" - "1) 必须是语义主题短语,不要命令语气,不要'请/帮我/新增/增加'等动作词。\n" - "2) 不要项目ID、@提及、括号说明。\n" - "3) 使用中文,8-24字。\n" - "4) 仅输出标题文本,不要前后缀说明。\n\n" - f"输入数据:{json.dumps(payload, ensure_ascii=False)}" - ) - try: - raw_response = await llm_service.generate( - prompt, - db=self.db, - tenant_id=self.tenant_id, - function_name="project_draft_analysis_glm47_fallback", - use_prompt_profile=False, - temperature=0.0, - max_tokens=240, - response_mode="complete", - ) - ai_title = self._normalize_title_candidate(self._extract_title_from_ai_response(raw_response)) - if self._is_acceptable_title(ai_title): - return ai_title - raise ValueError("ai_pmo_signal_title_refine_invalid_payload") - except Exception as exc: - logger.warning("ai_pmo_signal_title_refine_failed error=%s", exc) - raise RuntimeError(f"ai_pmo_signal_title_refine_failed:{type(exc).__name__}") from exc - - async def _create_signal( - self, - *, - title: str, - summary: str, - content: str, - project_id: Optional[int], - source: str, - signal_type: str, - recommended_action: str, - priority: str = "medium", - reasoning_bullets: Optional[list[str]] = None, - strategic_analysis: Optional[dict[str, Any]] = None, - ) -> Signal: - normalized_priority = str(priority or "medium").strip().lower() - if normalized_priority not in {"high", "medium", "low"}: - normalized_priority = "medium" - normalized_title = self._normalize_title_candidate(title) - if not self._is_acceptable_title(normalized_title): - raise ValueError("signal_title_invalid") - signal = Signal( - tenant_id=self.tenant_id, - source=source, - title=normalized_title, - summary=(summary or content or "")[:300], - content=(content or summary or "")[:2000], - signal_type=signal_type, - reasoning=json.dumps(reasoning_bullets or ["由 AI PMO kernel 统一入库。"], ensure_ascii=False), - strategic_analysis=(json.dumps(strategic_analysis, ensure_ascii=False) if isinstance(strategic_analysis, dict) else None), - confidence=0.78, - recommended_action=recommended_action, - matched_project_id=int(project_id) if project_id is not None else None, - impact_score=0.7, - priority=normalized_priority, - processed=False, - tasks_generated=json.dumps([], ensure_ascii=False), - status="待处理", - ) - self.db.add(signal) - await self.db.flush() - if project_id is not None: - exists = ( - await self.db.execute( - select(ProjectSignalLink.id).where( - and_( - ProjectSignalLink.tenant_id == self.tenant_id, - ProjectSignalLink.project_id == int(project_id), - ProjectSignalLink.signal_id == int(signal.id), - ) - ).limit(1) - ) - ).scalar_one_or_none() - if exists is None: - self.db.add( - ProjectSignalLink( - tenant_id=self.tenant_id, - project_id=int(project_id), - signal_id=int(signal.id), - link_type="related", - relevance_score=1.0, - ) - ) - await self.db.flush() - return signal - - @staticmethod - def _extract_auto_tasks(openclaw_payload: Any) -> list[dict[str, Any]]: - if not isinstance(openclaw_payload, dict): - return [] - result = openclaw_payload.get("result") if isinstance(openclaw_payload.get("result"), dict) else openclaw_payload - execution = result.get("execution") if isinstance(result, dict) and isinstance(result.get("execution"), dict) else {} - rows = execution.get("results") if isinstance(execution.get("results"), list) else [] - items: list[dict[str, Any]] = [] - for row in rows: - if not isinstance(row, dict): - continue - operation_code = str(row.get("operation_code") or "").strip() - if operation_code != "task.create": - continue - payload = row.get("payload") if isinstance(row.get("payload"), dict) else {} - items.append( - { - "operation_code": operation_code, - "status": str(row.get("status") or "").strip(), - "entity_id": row.get("entity_id"), - "title": payload.get("title"), - "task_id": payload.get("task_id"), - } - ) - return items +__all__ = ["TypedExecutorExecuteMixin"] diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index 246f42a2..0b583ec2 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -1,662 +1,5 @@ -""" -PMFlow AI 核心能力封装 +from __future__ import annotations -技术栈:LangChain 0.3+、OpenAI SDK、Redis -- 封装统一 LLM 路由入口调用(流式/非流式) -- Prompt 集中在 config/ai_prompts -- AI 调用日志写入 ai_call_logs -- Redis 结果缓存(1 小时 TTL) -- 指数退避重试 -""" +from .ai_service_core import AIService, get_ai_service -import hashlib -import json -import os -import time -from datetime import datetime -from typing import Any, AsyncGenerator, Optional, Union -from sqlalchemy import and_, select - -# LangChain & OpenAI -from ..utils.timezone import now_cn_naive -try: - from langchain_openai import ChatOpenAI - from langchain_core.messages import HumanMessage, AIMessage, SystemMessage - from langchain_core.language_models.chat_models import BaseChatModel - LANGCHAIN_AVAILABLE = True -except ImportError: - LANGCHAIN_AVAILABLE = False - BaseChatModel = object - -# Redis -try: - import redis.asyncio as redis - REDIS_AVAILABLE = True -except ImportError: - REDIS_AVAILABLE = False - -# Token 定价 (GPT-4o-mini, 2024) -INPUT_PRICE_PER_1K = 0.00015 -OUTPUT_PRICE_PER_1K = 0.0006 -CACHE_TTL_SECONDS = 3600 # 1 小时 -PROMPT_PROFILE_CACHE_TTL_SECONDS = 60 -DEFAULT_OPENAI_BASE_URL = "https://api.edgefn.net/v1" -DEFAULT_OPENAI_MODEL = "MiniMax-M2.5" -DEFAULT_GLM_MODEL = "GLM-4.7" - -from ..config.ai_prompts import build_default_role_prompt_bundle -from ..database import AsyncSessionLocal -from ..models import Integration - - -def _t2s(v: Union[str, int]) -> str: - """统一转为 str""" - return str(v) if v is not None else "" - - -def _hash_input(fn: str, *args, **kwargs) -> str: - """生成缓存 key""" - payload = json.dumps({"fn": fn, "args": args, "kwargs": kwargs}, sort_keys=True, default=str) - return hashlib.sha256(payload.encode()).hexdigest() - - -def _estimate_cost(prompt_tokens: int, completion_tokens: int) -> float: - return (prompt_tokens * INPUT_PRICE_PER_1K + completion_tokens * OUTPUT_PRICE_PER_1K) / 1000 - - -def _tenant_to_int(tenant_id: Union[str, int]) -> int: - try: - return int(tenant_id) - except (TypeError, ValueError): - return 1 - - -class AIService: - """PMFlow AI 服务""" - - def __init__( - self, - api_key: Optional[str] = None, - model: Optional[str] = None, - base_url: Optional[str] = None, - redis_url: Optional[str] = None, - max_retries: int = 3, - ): - runtime = self._resolve_llm_runtime( - api_key=api_key, - model=model, - base_url=base_url, - ) - self.api_key = runtime["api_key"] - self.model = runtime["model"] - self.base_url = runtime["base_url"] - self.redis_url = redis_url or os.getenv("REDIS_URL") - self.max_retries = max_retries - self._llm: Optional[BaseChatModel] = None - self._redis: Optional[Any] = None - self._prompt_profile_cache: dict[int, tuple[float, tuple[str, str]]] = {} - - @staticmethod - def _is_env_true(name: str, default: bool) -> bool: - raw = str(os.getenv(name, str(default)) or "").strip().lower() - if not raw: - return default - return raw in {"1", "true", "yes", "y", "on", "是"} - - def _resolve_llm_runtime( - self, - *, - api_key: Optional[str], - model: Optional[str], - base_url: Optional[str], - ) -> dict[str, str]: - routing_enabled = self._is_env_true("OPENCLAW_ROUTING_ENABLED", True) - direct_emergency_enabled = self._is_env_true("LLM_DIRECT_EMERGENCY_ENABLED", False) - if routing_enabled and not direct_emergency_enabled: - return { - "api_key": str(api_key or os.getenv("OPENAI_API_KEY", "") or "").strip(), - "model": str( - model - or os.getenv("LLM_ROUTING_HIGH_MODEL") - or os.getenv("OPENAI_MODEL") - or DEFAULT_OPENAI_MODEL - or "" - ).strip(), - "base_url": str(base_url or os.getenv("OPENAI_BASE_URL", DEFAULT_OPENAI_BASE_URL) or "").strip(), - } - - provider = str(os.getenv("LLM_PROVIDER", "openai") or "openai").strip().lower() - if provider == "glm": - return { - "api_key": str(api_key or os.getenv("GLM_API_KEY", "") or "").strip(), - "model": str(model or os.getenv("GLM_MODEL", DEFAULT_GLM_MODEL) or "").strip(), - "base_url": str(base_url or os.getenv("GLM_BASE_URL", DEFAULT_OPENAI_BASE_URL) or "").strip(), - } - if provider == "deepseek": - return { - "api_key": str(api_key or os.getenv("DEEPSEEK_API_KEY", "") or "").strip(), - "model": str(model or os.getenv("DEEPSEEK_MODEL", "deepseek-chat") or "").strip(), - "base_url": str(base_url or os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1") or "").strip(), - } - if provider == "ollama": - return { - "api_key": str(api_key or "").strip(), - "model": str(model or os.getenv("OLLAMA_MODEL", "llama3:latest") or "").strip(), - "base_url": str( - base_url - or os.getenv("OLLAMA_BASE_URL") - or os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434") - or "" - ).strip(), - } - - return { - "api_key": str(api_key or os.getenv("OPENAI_API_KEY", "") or "").strip(), - "model": str(model or os.getenv("OPENAI_MODEL", DEFAULT_OPENAI_MODEL) or "").strip(), - "base_url": str(base_url or os.getenv("OPENAI_BASE_URL", DEFAULT_OPENAI_BASE_URL) or "").strip(), - } - - def _get_llm(self) -> BaseChatModel: - if not LANGCHAIN_AVAILABLE: - raise RuntimeError("请安装 langchain-openai: pip install langchain-openai langchain-core") - if not self.api_key: - raise RuntimeError("请设置统一路由所需 API Key 环境变量") - if self._llm is None: - self._llm = ChatOpenAI( - model=self.model, - api_key=self.api_key, - base_url=self.base_url, - temperature=0.3, - ) - return self._llm - - async def _get_redis(self): - if not REDIS_AVAILABLE or not self.redis_url: - return None - if self._redis is None: - self._redis = redis.from_url(self.redis_url, decode_responses=True) - return self._redis - - async def _cache_get(self, key: str) -> Optional[str]: - r = await self._get_redis() - if r is None: - return None - try: - return await r.get(key) - except Exception: - return None - - async def _cache_set(self, key: str, value: str, ttl: int = CACHE_TTL_SECONDS): - r = await self._get_redis() - if r is None: - return - try: - await r.setex(key, ttl, value) - except Exception: - pass - - async def _resolve_active_system_prompt( - self, - *, - tenant_id: Union[str, int], - db=None, - ) -> tuple[str, str]: - tenant_int = _tenant_to_int(tenant_id) - now = time.time() - cached = self._prompt_profile_cache.get(tenant_int) - if cached and cached[0] > now: - return cached[1] - - role_name = "" - system_prompt = "" - try: - async def _load_profile(session) -> tuple[str, str]: - row = ( - await session.execute( - select(Integration).where( - and_( - Integration.tenant_id == int(tenant_int), - Integration.type == "ai_prompt_profiles", - ) - ) - ) - ).scalar_one_or_none() - bundle = row.config if row is not None and isinstance(row.config, dict) else {} - active_key = str(bundle.get("active_role_key") or "").strip() - roles = bundle.get("roles") if isinstance(bundle.get("roles"), list) else [] - selected = None - for item in roles: - if not isinstance(item, dict): - continue - if str(item.get("role_key") or "").strip() == active_key: - selected = item - break - if selected is None: - for item in roles: - if isinstance(item, dict) and bool(item.get("enabled", True)): - selected = item - break - if not isinstance(selected, dict): - return ("", "") - return ( - str(selected.get("role_name") or "").strip(), - str(selected.get("system_prompt") or "").strip(), - ) - - if db is not None: - role_name, system_prompt = await _load_profile(db) - else: - async with AsyncSessionLocal() as session: - role_name, system_prompt = await _load_profile(session) - except Exception: - role_name = "" - system_prompt = "" - - if len(system_prompt) < 20: - fallback = build_default_role_prompt_bundle() - active_key = str(fallback.get("active_role_key") or "").strip() - roles = fallback.get("roles") if isinstance(fallback.get("roles"), list) else [] - selected = None - for row in roles: - if not isinstance(row, dict): - continue - if str(row.get("role_key") or "").strip() == active_key: - selected = row - break - if selected is None and roles and isinstance(roles[0], dict): - selected = roles[0] - if isinstance(selected, dict): - role_name = str(selected.get("role_name") or role_name or "默认角色").strip() - system_prompt = str(selected.get("system_prompt") or "").strip() - - result = (role_name, system_prompt) - self._prompt_profile_cache[tenant_int] = (now + PROMPT_PROFILE_CACHE_TTL_SECONDS, result) - return result - - async def _log_call( - self, - db, - tenant_id: str, - function_name: str, - input_hash: str, - status: str, - prompt_tokens: int = 0, - completion_tokens: int = 0, - latency_ms: Optional[int] = None, - error_message: Optional[str] = None, - ): - try: - from ..models import AICallLog - - total = prompt_tokens + completion_tokens - cost = _estimate_cost(prompt_tokens, completion_tokens) - log = AICallLog( - tenant_id=_t2s(tenant_id), - function_name=function_name, - input_hash=input_hash, - status=status, - prompt_tokens=prompt_tokens, - completion_tokens=completion_tokens, - total_tokens=total, - cost_usd=cost, - latency_ms=latency_ms, - error_message=error_message, - ) - db.add(log) - await db.commit() - except Exception: - pass - - async def _invoke_with_retry( - self, - prompt: str, - db=None, - tenant_id: str = "default", - function_name: str = "unknown", - use_cache: bool = True, - cache_key: Optional[str] = None, - project_id: Optional[int] = None, - ) -> str: - """带缓存、重试、日志的 LLM 调用""" - input_hash = cache_key or _hash_input(function_name, prompt) - - if use_cache and db is None: - cached = await self._cache_get(input_hash) - if cached: - return cached - - # 预算前置检查(仅在有 db 会话时启用) - if db is not None: - try: - from .budget_enforcement_service import BudgetEnforcementService - - tenant_int = _tenant_to_int(tenant_id) - # 预估:按 prompt 长度粗略估算,保守给定 completion 预算 - estimated_prompt_tokens = max(len(prompt) // 4, 1) - estimated_completion_tokens = 1200 - estimated_cost = _estimate_cost(estimated_prompt_tokens, estimated_completion_tokens) - - budget_service = BudgetEnforcementService(db) - budget_check = await budget_service.check_budget_before_ai_call( - tenant_id=tenant_int, - estimated_cost_usd=estimated_cost, - project_id=project_id, - ) - if not budget_check.get("allowed", True): - raise RuntimeError("预算超限,AI 调用被阻止(hard limit)") - except RuntimeError: - raise - except Exception: - # 预算系统异常不阻断主流程,避免影响现有可用性 - pass - - llm = self._get_llm() - _, system_prompt = await self._resolve_active_system_prompt( - tenant_id=tenant_id, - db=db, - ) - last_err = None - for attempt in range(self.max_retries): - try: - t0 = time.perf_counter() - messages = [HumanMessage(content=prompt)] - if system_prompt: - messages = [SystemMessage(content=system_prompt), *messages] - resp = await llm.ainvoke(messages) - latency_ms = int((time.perf_counter() - t0) * 1000) - text = resp.content if hasattr(resp, "content") else str(resp) - pt = getattr(resp, "response_metadata", None) or {} - usage = (pt or {}).get("usage", {}) or (getattr(resp, "response_metadata", None) or {}).get("usage", {}) - prompt_tokens = usage.get("input_tokens", 0) or usage.get("prompt_tokens", 0) - completion_tokens = usage.get("output_tokens", 0) or usage.get("completion_tokens", 0) - - if db: - await self._log_call( - db, tenant_id, function_name, input_hash, "success", - prompt_tokens, completion_tokens, latency_ms, - ) - # 按实际 token 成本累计预算 - try: - from .budget_enforcement_service import BudgetEnforcementService - - actual_cost = _estimate_cost(prompt_tokens, completion_tokens) - await BudgetEnforcementService(db).update_budget_usage_after_ai_call( - tenant_id=_tenant_to_int(tenant_id), - actual_cost_usd=actual_cost, - project_id=project_id, - ) - except Exception: - pass - if use_cache: - await self._cache_set(input_hash, text) - return text - except Exception as e: - last_err = e - wait = 2 ** attempt - time.sleep(wait) - - if db: - await self._log_call( - db, tenant_id, function_name, input_hash, "failed", - error_message=str(last_err), - ) - raise last_err - - def _parse_json(self, text: str) -> dict: - text = text.strip() - for p in ("```json", "```"): - if text.startswith(p): - text = text[len(p):] - if text.endswith("```"): - text = text[:-3] - return json.loads(text.strip()) - - # ======================================================================== - # 核心函数 - # ======================================================================== - - async def generate_charter_from_signal( - self, - signal_content: str, - tenant_id: Union[str, int] = "default", - db=None, - ) -> dict: - """从市场/模型信号生成需求宪章草案""" - from ..config.ai_prompts import PROMPT_GENERATE_CHARTER - - tid = _t2s(tenant_id) - prompt = PROMPT_GENERATE_CHARTER.format(signal_content=signal_content) - text = await self._invoke_with_retry( - prompt, db=db, tenant_id=tid, function_name="generate_charter_from_signal", - ) - return self._parse_json(text) - - async def decompose_tasks_from_charter( - self, - charter_content: str, - project_id: Union[str, int], - tenant_id: Union[str, int] = "default", - db=None, - ) -> dict: - """从宪章拆解任务,自动识别所需技能标签,返回带技能要求的任务列表""" - from ..config.ai_prompts import PROMPT_DECOMPOSE_TASKS - - tid = _t2s(tenant_id) - prompt = PROMPT_DECOMPOSE_TASKS.format( - charter_content=charter_content, - project_id=_t2s(project_id), - ) - text = await self._invoke_with_retry( - prompt, db=db, tenant_id=tid, function_name="decompose_tasks_from_charter", - ) - return self._parse_json(text) - - async def recommend_assignees_for_task( - self, - task_content: str, - required_skill_tag_ids: list, - project_id: Union[str, int], - tenant_id: Union[str, int] = "default", - db=None, - candidates_json: Optional[str] = None, - ) -> dict: - """基于人员能力图谱、负载、熟练度,推荐 Top3 人员""" - from ..config.ai_prompts import PROMPT_RECOMMEND_ASSIGNEES - - tid = _t2s(tenant_id) - candidates = candidates_json or "[]" - prompt = PROMPT_RECOMMEND_ASSIGNEES.format( - task_content=task_content, - required_skill_tag_ids=json.dumps(required_skill_tag_ids), - candidates_json=candidates, - ) - text = await self._invoke_with_retry( - prompt, db=db, tenant_id=tid, function_name="recommend_assignees_for_task", - ) - return self._parse_json(text) - - async def update_user_skill_scores( - self, - task_id: Union[str, int], - tenant_id: Union[str, int] = "default", - db=None, - quality_score: float = 0.0, - efficiency_score: float = 0.0, - assignee_ids: Optional[list] = None, - skill_tag_ids: Optional[list] = None, - ) -> dict: - """任务完成后,根据质量/效率评分更新相关人员技能分""" - from sqlalchemy import select - from ..models import UserSkillBinding - from uuid import UUID - - def to_uuid(v): - if v is None: - return None - if isinstance(v, UUID): - return v - s = str(v) - try: - return UUID(s) if len(s) == 36 else None - except ValueError: - return None - - if db is None: - return {"updated_count": 0, "updates": []} - - tid = _t2s(tenant_id) - updates = [] - if assignee_ids and skill_tag_ids: - for uid in assignee_ids: - for sid in skill_tag_ids: - uid_u = to_uuid(uid) - sid_u = to_uuid(sid) - if not uid_u or not sid_u: - continue - try: - r = await db.execute( - select(UserSkillBinding).where( - UserSkillBinding.tenant_id == tid, - UserSkillBinding.user_id == uid_u, - UserSkillBinding.skill_tag_id == sid_u, - ) - ) - b = r.scalar_one_or_none() - if b: - b.quality_score = min(100, max(0, (b.quality_score or 0) + quality_score)) - b.efficiency_score = min(100, max(0, (b.efficiency_score or 0) + efficiency_score)) - b.last_used_at = now_cn_naive() - await db.commit() - updates.append({ - "user_id": str(uid), - "skill_tag_id": str(sid), - "quality_score_delta": quality_score, - "efficiency_score_delta": efficiency_score, - "last_used_at": b.last_used_at.isoformat(), - }) - except Exception: - pass - - return {"updated_count": len(updates), "updates": updates} - - async def calculate_alignment_score( - self, - user_id: Union[str, int], - truth_node_id: Union[str, int], - tenant_id: Union[str, int] = "default", - db=None, - truth_content: str = "", - user_context: str = "", - ) -> dict: - """计算用户对真理节点的认知对齐度(0-100)""" - from ..config.ai_prompts import PROMPT_ALIGNMENT_SCORE - from sqlalchemy import select - from ..models import TruthNode - - tid = _t2s(tenant_id) - if db and not truth_content: - r = await db.execute( - select(TruthNode).where( - TruthNode.id == truth_node_id, - TruthNode.tenant_id == tid, - ) - ) - tn = r.scalar_one_or_none() - if tn: - truth_content = tn.content or tn.title or "" - - prompt = PROMPT_ALIGNMENT_SCORE.format( - user_id=_t2s(user_id), - truth_node_id=_t2s(truth_node_id), - truth_content=truth_content or "(无内容)", - user_context=user_context or "(无)", - ) - text = await self._invoke_with_retry( - prompt, db=db, tenant_id=tid, function_name="calculate_alignment_score", - ) - data = self._parse_json(text) - data["user_id"] = _t2s(user_id) - data["truth_node_id"] = _t2s(truth_node_id) - return data - - async def detect_cognitive_conflicts( - self, - truth_node_id: Union[str, int], - tenant_id: Union[str, int] = "default", - db=None, - truth_content: str = "", - user_viewpoints: str = "", - ) -> dict: - """识别真理节点下的隐性认知冲突""" - from ..config.ai_prompts import PROMPT_COGNITIVE_CONFLICTS - from sqlalchemy import select - from ..models import TruthNode - - tid = _t2s(tenant_id) - if db and not truth_content: - r = await db.execute( - select(TruthNode).where( - TruthNode.id == truth_node_id, - TruthNode.tenant_id == tid, - ) - ) - tn = r.scalar_one_or_none() - if tn: - truth_content = tn.content or tn.title or "" - - prompt = PROMPT_COGNITIVE_CONFLICTS.format( - truth_node_id=_t2s(truth_node_id), - truth_content=truth_content or "(无内容)", - user_viewpoints=user_viewpoints or "(无)", - ) - text = await self._invoke_with_retry( - prompt, db=db, tenant_id=tid, function_name="detect_cognitive_conflicts", - ) - data = self._parse_json(text) - data["truth_node_id"] = _t2s(truth_node_id) - data["total_count"] = len(data.get("conflicts", [])) - return data - - async def evaluate_model_requirement( - self, - charter_content: str, - model_version_id: Union[str, int], - tenant_id: Union[str, int] = "default", - db=None, - model_info: str = "", - ) -> dict: - """评估需求与模型的匹配度""" - from ..config.ai_prompts import PROMPT_EVALUATE_MODEL_REQUIREMENT - - tid = _t2s(tenant_id) - prompt = PROMPT_EVALUATE_MODEL_REQUIREMENT.format( - charter_content=charter_content, - model_version_id=_t2s(model_version_id), - model_info=model_info or "(未提供)", - ) - text = await self._invoke_with_retry( - prompt, db=db, tenant_id=tid, function_name="evaluate_model_requirement", - ) - return self._parse_json(text) - - # ======================================================================== - # 流式调用 - # ======================================================================== - - async def stream_chat(self, prompt: str) -> AsyncGenerator[str, None]: - """流式生成""" - llm = self._get_llm() - async for chunk in llm.astream([HumanMessage(content=prompt)]): - if hasattr(chunk, "content") and chunk.content: - yield chunk.content - - -# 单例 -_ai_service: Optional[AIService] = None - - -def get_ai_service() -> AIService: - global _ai_service - if _ai_service is None: - _ai_service = AIService() - return _ai_service +__all__ = ["AIService", "get_ai_service"] diff --git a/backend/app/services/ai_service_core/__init__.py b/backend/app/services/ai_service_core/__init__.py new file mode 100644 index 00000000..cb9151f9 --- /dev/null +++ b/backend/app/services/ai_service_core/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .service import AIService, get_ai_service + +__all__ = ["AIService", "get_ai_service"] diff --git a/backend/app/services/ai_service_core/mixin_business_analysis.py b/backend/app/services/ai_service_core/mixin_business_analysis.py new file mode 100644 index 00000000..5e3e9ca2 --- /dev/null +++ b/backend/app/services/ai_service_core/mixin_business_analysis.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +from .shared import * + +class AIServiceBusinessAnalysisMixin: + async def update_user_skill_scores( + self, + task_id: Union[str, int], + tenant_id: Union[str, int] = "default", + db=None, + quality_score: float = 0.0, + efficiency_score: float = 0.0, + assignee_ids: Optional[list] = None, + skill_tag_ids: Optional[list] = None, + ) -> dict: + """任务完成后,根据质量/效率评分更新相关人员技能分""" + from sqlalchemy import select + from ..models import UserSkillBinding + from uuid import UUID + + def to_uuid(v): + if v is None: + return None + if isinstance(v, UUID): + return v + s = str(v) + try: + return UUID(s) if len(s) == 36 else None + except ValueError: + return None + + if db is None: + return {"updated_count": 0, "updates": []} + + tid = _t2s(tenant_id) + updates = [] + if assignee_ids and skill_tag_ids: + for uid in assignee_ids: + for sid in skill_tag_ids: + uid_u = to_uuid(uid) + sid_u = to_uuid(sid) + if not uid_u or not sid_u: + continue + try: + r = await db.execute( + select(UserSkillBinding).where( + UserSkillBinding.tenant_id == tid, + UserSkillBinding.user_id == uid_u, + UserSkillBinding.skill_tag_id == sid_u, + ) + ) + b = r.scalar_one_or_none() + if b: + b.quality_score = min(100, max(0, (b.quality_score or 0) + quality_score)) + b.efficiency_score = min(100, max(0, (b.efficiency_score or 0) + efficiency_score)) + b.last_used_at = now_cn_naive() + await db.commit() + updates.append({ + "user_id": str(uid), + "skill_tag_id": str(sid), + "quality_score_delta": quality_score, + "efficiency_score_delta": efficiency_score, + "last_used_at": b.last_used_at.isoformat(), + }) + except Exception: + pass + + return {"updated_count": len(updates), "updates": updates} + + async def calculate_alignment_score( + self, + user_id: Union[str, int], + truth_node_id: Union[str, int], + tenant_id: Union[str, int] = "default", + db=None, + truth_content: str = "", + user_context: str = "", + ) -> dict: + """计算用户对真理节点的认知对齐度(0-100)""" + from ..config.ai_prompts import PROMPT_ALIGNMENT_SCORE + from sqlalchemy import select + from ..models import TruthNode + + tid = _t2s(tenant_id) + if db and not truth_content: + r = await db.execute( + select(TruthNode).where( + TruthNode.id == truth_node_id, + TruthNode.tenant_id == tid, + ) + ) + tn = r.scalar_one_or_none() + if tn: + truth_content = tn.content or tn.title or "" + + prompt = PROMPT_ALIGNMENT_SCORE.format( + user_id=_t2s(user_id), + truth_node_id=_t2s(truth_node_id), + truth_content=truth_content or "(无内容)", + user_context=user_context or "(无)", + ) + text = await self._invoke_with_retry( + prompt, db=db, tenant_id=tid, function_name="calculate_alignment_score", + ) + data = self._parse_json(text) + data["user_id"] = _t2s(user_id) + data["truth_node_id"] = _t2s(truth_node_id) + return data + + async def detect_cognitive_conflicts( + self, + truth_node_id: Union[str, int], + tenant_id: Union[str, int] = "default", + db=None, + truth_content: str = "", + user_viewpoints: str = "", + ) -> dict: + """识别真理节点下的隐性认知冲突""" + from ..config.ai_prompts import PROMPT_COGNITIVE_CONFLICTS + from sqlalchemy import select + from ..models import TruthNode + + tid = _t2s(tenant_id) + if db and not truth_content: + r = await db.execute( + select(TruthNode).where( + TruthNode.id == truth_node_id, + TruthNode.tenant_id == tid, + ) + ) + tn = r.scalar_one_or_none() + if tn: + truth_content = tn.content or tn.title or "" + + prompt = PROMPT_COGNITIVE_CONFLICTS.format( + truth_node_id=_t2s(truth_node_id), + truth_content=truth_content or "(无内容)", + user_viewpoints=user_viewpoints or "(无)", + ) + text = await self._invoke_with_retry( + prompt, db=db, tenant_id=tid, function_name="detect_cognitive_conflicts", + ) + data = self._parse_json(text) + data["truth_node_id"] = _t2s(truth_node_id) + data["total_count"] = len(data.get("conflicts", [])) + return data + + async def evaluate_model_requirement( + self, + charter_content: str, + model_version_id: Union[str, int], + tenant_id: Union[str, int] = "default", + db=None, + model_info: str = "", + ) -> dict: + """评估需求与模型的匹配度""" + from ..config.ai_prompts import PROMPT_EVALUATE_MODEL_REQUIREMENT + + tid = _t2s(tenant_id) + prompt = PROMPT_EVALUATE_MODEL_REQUIREMENT.format( + charter_content=charter_content, + model_version_id=_t2s(model_version_id), + model_info=model_info or "(未提供)", + ) + text = await self._invoke_with_retry( + prompt, db=db, tenant_id=tid, function_name="evaluate_model_requirement", + ) + return self._parse_json(text) + + async def stream_chat(self, prompt: str) -> AsyncGenerator[str, None]: + """流式生成""" + llm = self._get_llm() + async for chunk in llm.astream([HumanMessage(content=prompt)]): + if hasattr(chunk, "content") and chunk.content: + yield chunk.content + diff --git a/backend/app/services/ai_service_core/mixin_business_tasks.py b/backend/app/services/ai_service_core/mixin_business_tasks.py new file mode 100644 index 00000000..ba2def40 --- /dev/null +++ b/backend/app/services/ai_service_core/mixin_business_tasks.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from .shared import * + +class AIServiceBusinessTasksMixin: + async def generate_charter_from_signal( + self, + signal_content: str, + tenant_id: Union[str, int] = "default", + db=None, + ) -> dict: + """从市场/模型信号生成需求宪章草案""" + from ..config.ai_prompts import PROMPT_GENERATE_CHARTER + + tid = _t2s(tenant_id) + prompt = PROMPT_GENERATE_CHARTER.format(signal_content=signal_content) + text = await self._invoke_with_retry( + prompt, db=db, tenant_id=tid, function_name="generate_charter_from_signal", + ) + return self._parse_json(text) + + async def decompose_tasks_from_charter( + self, + charter_content: str, + project_id: Union[str, int], + tenant_id: Union[str, int] = "default", + db=None, + ) -> dict: + """从宪章拆解任务,自动识别所需技能标签,返回带技能要求的任务列表""" + from ..config.ai_prompts import PROMPT_DECOMPOSE_TASKS + + tid = _t2s(tenant_id) + prompt = PROMPT_DECOMPOSE_TASKS.format( + charter_content=charter_content, + project_id=_t2s(project_id), + ) + text = await self._invoke_with_retry( + prompt, db=db, tenant_id=tid, function_name="decompose_tasks_from_charter", + ) + return self._parse_json(text) + + async def recommend_assignees_for_task( + self, + task_content: str, + required_skill_tag_ids: list, + project_id: Union[str, int], + tenant_id: Union[str, int] = "default", + db=None, + candidates_json: Optional[str] = None, + ) -> dict: + """基于人员能力图谱、负载、熟练度,推荐 Top3 人员""" + from ..config.ai_prompts import PROMPT_RECOMMEND_ASSIGNEES + + tid = _t2s(tenant_id) + candidates = candidates_json or "[]" + prompt = PROMPT_RECOMMEND_ASSIGNEES.format( + task_content=task_content, + required_skill_tag_ids=json.dumps(required_skill_tag_ids), + candidates_json=candidates, + ) + text = await self._invoke_with_retry( + prompt, db=db, tenant_id=tid, function_name="recommend_assignees_for_task", + ) + return self._parse_json(text) + diff --git a/backend/app/services/ai_service_core/mixin_runtime_core.py b/backend/app/services/ai_service_core/mixin_runtime_core.py new file mode 100644 index 00000000..13dacbc2 --- /dev/null +++ b/backend/app/services/ai_service_core/mixin_runtime_core.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from .shared import * + +class AIServiceRuntimeCoreMixin: + def __init__( + self, + api_key: Optional[str] = None, + model: Optional[str] = None, + base_url: Optional[str] = None, + redis_url: Optional[str] = None, + max_retries: int = 3, + ): + runtime = self._resolve_llm_runtime( + api_key=api_key, + model=model, + base_url=base_url, + ) + self.api_key = runtime["api_key"] + self.model = runtime["model"] + self.base_url = runtime["base_url"] + self.redis_url = redis_url or os.getenv("REDIS_URL") + self.max_retries = max_retries + self._llm: Optional[BaseChatModel] = None + self._redis: Optional[Any] = None + self._prompt_profile_cache: dict[int, tuple[float, tuple[str, str]]] = {} + + @staticmethod + def _is_env_true(name: str, default: bool) -> bool: + raw = str(os.getenv(name, str(default)) or "").strip().lower() + if not raw: + return default + return raw in {"1", "true", "yes", "y", "on", "是"} + + def _resolve_llm_runtime( + self, + *, + api_key: Optional[str], + model: Optional[str], + base_url: Optional[str], + ) -> dict[str, str]: + routing_enabled = self._is_env_true("OPENCLAW_ROUTING_ENABLED", True) + direct_emergency_enabled = self._is_env_true("LLM_DIRECT_EMERGENCY_ENABLED", False) + if routing_enabled and not direct_emergency_enabled: + return { + "api_key": str(api_key or os.getenv("OPENAI_API_KEY", "") or "").strip(), + "model": str( + model + or os.getenv("LLM_ROUTING_HIGH_MODEL") + or os.getenv("OPENAI_MODEL") + or DEFAULT_OPENAI_MODEL + or "" + ).strip(), + "base_url": str(base_url or os.getenv("OPENAI_BASE_URL", DEFAULT_OPENAI_BASE_URL) or "").strip(), + } + + provider = str(os.getenv("LLM_PROVIDER", "openai") or "openai").strip().lower() + if provider == "glm": + return { + "api_key": str(api_key or os.getenv("GLM_API_KEY", "") or "").strip(), + "model": str(model or os.getenv("GLM_MODEL", DEFAULT_GLM_MODEL) or "").strip(), + "base_url": str(base_url or os.getenv("GLM_BASE_URL", DEFAULT_OPENAI_BASE_URL) or "").strip(), + } + if provider == "deepseek": + return { + "api_key": str(api_key or os.getenv("DEEPSEEK_API_KEY", "") or "").strip(), + "model": str(model or os.getenv("DEEPSEEK_MODEL", "deepseek-chat") or "").strip(), + "base_url": str(base_url or os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1") or "").strip(), + } + if provider == "ollama": + return { + "api_key": str(api_key or "").strip(), + "model": str(model or os.getenv("OLLAMA_MODEL", "llama3:latest") or "").strip(), + "base_url": str( + base_url + or os.getenv("OLLAMA_BASE_URL") + or os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434") + or "" + ).strip(), + } + + return { + "api_key": str(api_key or os.getenv("OPENAI_API_KEY", "") or "").strip(), + "model": str(model or os.getenv("OPENAI_MODEL", DEFAULT_OPENAI_MODEL) or "").strip(), + "base_url": str(base_url or os.getenv("OPENAI_BASE_URL", DEFAULT_OPENAI_BASE_URL) or "").strip(), + } + + def _get_llm(self) -> BaseChatModel: + if not LANGCHAIN_AVAILABLE: + raise RuntimeError("请安装 langchain-openai: pip install langchain-openai langchain-core") + if not self.api_key: + raise RuntimeError("请设置统一路由所需 API Key 环境变量") + if self._llm is None: + self._llm = ChatOpenAI( + model=self.model, + api_key=self.api_key, + base_url=self.base_url, + temperature=0.3, + ) + return self._llm + + async def _get_redis(self): + if not REDIS_AVAILABLE or not self.redis_url: + return None + if self._redis is None: + self._redis = redis.from_url(self.redis_url, decode_responses=True) + return self._redis + + async def _cache_get(self, key: str) -> Optional[str]: + r = await self._get_redis() + if r is None: + return None + try: + return await r.get(key) + except Exception: + return None + + async def _cache_set(self, key: str, value: str, ttl: int = CACHE_TTL_SECONDS): + r = await self._get_redis() + if r is None: + return + try: + await r.setex(key, ttl, value) + except Exception: + pass + + def _parse_json(self, text: str) -> dict: + text = text.strip() + for p in ("```json", "```"): + if text.startswith(p): + text = text[len(p):] + if text.endswith("```"): + text = text[:-3] + return json.loads(text.strip()) + diff --git a/backend/app/services/ai_service_core/mixin_runtime_prompt_retry.py b/backend/app/services/ai_service_core/mixin_runtime_prompt_retry.py new file mode 100644 index 00000000..b9178f56 --- /dev/null +++ b/backend/app/services/ai_service_core/mixin_runtime_prompt_retry.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +from .shared import * + +class AIServiceRuntimePromptRetryMixin: + async def _resolve_active_system_prompt( + self, + *, + tenant_id: Union[str, int], + db=None, + ) -> tuple[str, str]: + tenant_int = _tenant_to_int(tenant_id) + now = time.time() + cached = self._prompt_profile_cache.get(tenant_int) + if cached and cached[0] > now: + return cached[1] + + role_name = "" + system_prompt = "" + try: + async def _load_profile(session) -> tuple[str, str]: + row = ( + await session.execute( + select(Integration).where( + and_( + Integration.tenant_id == int(tenant_int), + Integration.type == "ai_prompt_profiles", + ) + ) + ) + ).scalar_one_or_none() + bundle = row.config if row is not None and isinstance(row.config, dict) else {} + active_key = str(bundle.get("active_role_key") or "").strip() + roles = bundle.get("roles") if isinstance(bundle.get("roles"), list) else [] + selected = None + for item in roles: + if not isinstance(item, dict): + continue + if str(item.get("role_key") or "").strip() == active_key: + selected = item + break + if selected is None: + for item in roles: + if isinstance(item, dict) and bool(item.get("enabled", True)): + selected = item + break + if not isinstance(selected, dict): + return ("", "") + return ( + str(selected.get("role_name") or "").strip(), + str(selected.get("system_prompt") or "").strip(), + ) + + if db is not None: + role_name, system_prompt = await _load_profile(db) + else: + async with AsyncSessionLocal() as session: + role_name, system_prompt = await _load_profile(session) + except Exception: + role_name = "" + system_prompt = "" + + if len(system_prompt) < 20: + fallback = build_default_role_prompt_bundle() + active_key = str(fallback.get("active_role_key") or "").strip() + roles = fallback.get("roles") if isinstance(fallback.get("roles"), list) else [] + selected = None + for row in roles: + if not isinstance(row, dict): + continue + if str(row.get("role_key") or "").strip() == active_key: + selected = row + break + if selected is None and roles and isinstance(roles[0], dict): + selected = roles[0] + if isinstance(selected, dict): + role_name = str(selected.get("role_name") or role_name or "默认角色").strip() + system_prompt = str(selected.get("system_prompt") or "").strip() + + result = (role_name, system_prompt) + self._prompt_profile_cache[tenant_int] = (now + PROMPT_PROFILE_CACHE_TTL_SECONDS, result) + return result + + async def _log_call( + self, + db, + tenant_id: str, + function_name: str, + input_hash: str, + status: str, + prompt_tokens: int = 0, + completion_tokens: int = 0, + latency_ms: Optional[int] = None, + error_message: Optional[str] = None, + ): + try: + from ..models import AICallLog + + total = prompt_tokens + completion_tokens + cost = _estimate_cost(prompt_tokens, completion_tokens) + log = AICallLog( + tenant_id=_t2s(tenant_id), + function_name=function_name, + input_hash=input_hash, + status=status, + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=total, + cost_usd=cost, + latency_ms=latency_ms, + error_message=error_message, + ) + db.add(log) + await db.commit() + except Exception: + pass + + async def _invoke_with_retry( + self, + prompt: str, + db=None, + tenant_id: str = "default", + function_name: str = "unknown", + use_cache: bool = True, + cache_key: Optional[str] = None, + project_id: Optional[int] = None, + ) -> str: + """带缓存、重试、日志的 LLM 调用""" + input_hash = cache_key or _hash_input(function_name, prompt) + + if use_cache and db is None: + cached = await self._cache_get(input_hash) + if cached: + return cached + + # 预算前置检查(仅在有 db 会话时启用) + if db is not None: + try: + from .budget_enforcement_service import BudgetEnforcementService + + tenant_int = _tenant_to_int(tenant_id) + # 预估:按 prompt 长度粗略估算,保守给定 completion 预算 + estimated_prompt_tokens = max(len(prompt) // 4, 1) + estimated_completion_tokens = 1200 + estimated_cost = _estimate_cost(estimated_prompt_tokens, estimated_completion_tokens) + + budget_service = BudgetEnforcementService(db) + budget_check = await budget_service.check_budget_before_ai_call( + tenant_id=tenant_int, + estimated_cost_usd=estimated_cost, + project_id=project_id, + ) + if not budget_check.get("allowed", True): + raise RuntimeError("预算超限,AI 调用被阻止(hard limit)") + except RuntimeError: + raise + except Exception: + # 预算系统异常不阻断主流程,避免影响现有可用性 + pass + + llm = self._get_llm() + _, system_prompt = await self._resolve_active_system_prompt( + tenant_id=tenant_id, + db=db, + ) + last_err = None + for attempt in range(self.max_retries): + try: + t0 = time.perf_counter() + messages = [HumanMessage(content=prompt)] + if system_prompt: + messages = [SystemMessage(content=system_prompt), *messages] + resp = await llm.ainvoke(messages) + latency_ms = int((time.perf_counter() - t0) * 1000) + text = resp.content if hasattr(resp, "content") else str(resp) + pt = getattr(resp, "response_metadata", None) or {} + usage = (pt or {}).get("usage", {}) or (getattr(resp, "response_metadata", None) or {}).get("usage", {}) + prompt_tokens = usage.get("input_tokens", 0) or usage.get("prompt_tokens", 0) + completion_tokens = usage.get("output_tokens", 0) or usage.get("completion_tokens", 0) + + if db: + await self._log_call( + db, tenant_id, function_name, input_hash, "success", + prompt_tokens, completion_tokens, latency_ms, + ) + # 按实际 token 成本累计预算 + try: + from .budget_enforcement_service import BudgetEnforcementService + + actual_cost = _estimate_cost(prompt_tokens, completion_tokens) + await BudgetEnforcementService(db).update_budget_usage_after_ai_call( + tenant_id=_tenant_to_int(tenant_id), + actual_cost_usd=actual_cost, + project_id=project_id, + ) + except Exception: + pass + if use_cache: + await self._cache_set(input_hash, text) + return text + except Exception as e: + last_err = e + wait = 2 ** attempt + time.sleep(wait) + + if db: + await self._log_call( + db, tenant_id, function_name, input_hash, "failed", + error_message=str(last_err), + ) + raise last_err + diff --git a/backend/app/services/ai_service_core/service.py b/backend/app/services/ai_service_core/service.py new file mode 100644 index 00000000..7148df07 --- /dev/null +++ b/backend/app/services/ai_service_core/service.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from .mixin_business_analysis import AIServiceBusinessAnalysisMixin +from .mixin_business_tasks import AIServiceBusinessTasksMixin +from .mixin_runtime_core import AIServiceRuntimeCoreMixin +from .mixin_runtime_prompt_retry import AIServiceRuntimePromptRetryMixin + + +class AIService( + AIServiceRuntimeCoreMixin, + AIServiceRuntimePromptRetryMixin, + AIServiceBusinessTasksMixin, + AIServiceBusinessAnalysisMixin, +): + """PMFlow AI 服务。""" + + +# 单例 +_ai_service = None + + +def get_ai_service() -> AIService: + global _ai_service + if _ai_service is None: + _ai_service = AIService() + return _ai_service + + +__all__ = ["AIService", "get_ai_service"] diff --git a/backend/app/services/ai_service_core/shared.py b/backend/app/services/ai_service_core/shared.py new file mode 100644 index 00000000..3b659b54 --- /dev/null +++ b/backend/app/services/ai_service_core/shared.py @@ -0,0 +1,73 @@ +""" +PMFlow AI 核心能力封装 + +技术栈:LangChain 0.3+、OpenAI SDK、Redis +- 封装统一 LLM 路由入口调用(流式/非流式) +- Prompt 集中在 config/ai_prompts +- AI 调用日志写入 ai_call_logs +- Redis 结果缓存(1 小时 TTL) +- 指数退避重试 +""" + +import hashlib +import json +import os +import time +from datetime import datetime +from typing import Any, AsyncGenerator, Optional, Union +from sqlalchemy import and_, select + +# LangChain & OpenAI +from ...utils.timezone import now_cn_naive +try: + from langchain_openai import ChatOpenAI + from langchain_core.messages import HumanMessage, AIMessage, SystemMessage + from langchain_core.language_models.chat_models import BaseChatModel + LANGCHAIN_AVAILABLE = True +except ImportError: + LANGCHAIN_AVAILABLE = False + BaseChatModel = object + +# Redis +try: + import redis.asyncio as redis + REDIS_AVAILABLE = True +except ImportError: + REDIS_AVAILABLE = False + +# Token 定价 (GPT-4o-mini, 2024) +INPUT_PRICE_PER_1K = 0.00015 +OUTPUT_PRICE_PER_1K = 0.0006 +CACHE_TTL_SECONDS = 3600 # 1 小时 +PROMPT_PROFILE_CACHE_TTL_SECONDS = 60 +DEFAULT_OPENAI_BASE_URL = "https://api.edgefn.net/v1" +DEFAULT_OPENAI_MODEL = "MiniMax-M2.5" +DEFAULT_GLM_MODEL = "GLM-4.7" + +from ...config.ai_prompts import build_default_role_prompt_bundle +from ...database import AsyncSessionLocal +from ...models import Integration + + +def _t2s(v: Union[str, int]) -> str: + """统一转为 str""" + return str(v) if v is not None else "" + + +def _hash_input(fn: str, *args, **kwargs) -> str: + """生成缓存 key""" + payload = json.dumps({"fn": fn, "args": args, "kwargs": kwargs}, sort_keys=True, default=str) + return hashlib.sha256(payload.encode()).hexdigest() + + +def _estimate_cost(prompt_tokens: int, completion_tokens: int) -> float: + return (prompt_tokens * INPUT_PRICE_PER_1K + completion_tokens * OUTPUT_PRICE_PER_1K) / 1000 + + +def _tenant_to_int(tenant_id: Union[str, int]) -> int: + try: + return int(tenant_id) + except (TypeError, ValueError): + return 1 + + diff --git a/backend/app/services/ai_steward.py b/backend/app/services/ai_steward.py index 24f59fba..5de53483 100644 --- a/backend/app/services/ai_steward.py +++ b/backend/app/services/ai_steward.py @@ -1,969 +1,5 @@ -"""AI Steward Service - 主动式 AI 管家核心服务。""" - from __future__ import annotations -import asyncio -import ast -import json -import os -import re -import time -from typing import Any, Dict, List +from .ai_steward_service import AIStewardService, get_ai_steward_service -from sqlalchemy.ext.asyncio import AsyncSession - -from ..core.logging import get_logger -from ..database import get_db -from ..models import CognitiveConflict, Project, ProjectSignalLink, Signal, TruthNode -from ..utils.project_members import ensure_project_default_members -from ..utils.sequence import ensure_project_id_sequence -from ..utils.title import compact_title -from .adaptive_rag_service import AdaptiveRAGService -from .llm import LLMService -from .task_persistence import ( - build_task_create_draft, - normalize_skill_names, - persist_task, - resolve_skill_tag_ids_by_names, -) - -logger = get_logger(__name__) - - -def _env_float(name: str, default: float) -> float: - raw = os.getenv(name) - if raw is None: - return float(default) - try: - return float(str(raw).strip()) - except Exception: - return float(default) - - -def _env_int(name: str, default: int) -> int: - raw = os.getenv(name) - if raw is None: - return int(default) - try: - return int(str(raw).strip()) - except Exception: - return int(default) - - -PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS = max( - 8.0, - _env_float("AI_STEWARD_PROJECT_CREATION_STEP_TIMEOUT_SECONDS", 30.0), -) -PROJECT_CREATION_STEP_RETRY_COUNT = max( - 0, - _env_int("AI_STEWARD_PROJECT_CREATION_STEP_RETRY_COUNT", 1), -) -PROJECT_CREATION_JSON_PARSE_RETRY_COUNT = max( - 0, - _env_int("AI_STEWARD_PROJECT_CREATION_JSON_PARSE_RETRY_COUNT", 1), -) -PROJECT_CREATION_TASK_CHAIN_TIMEOUT_SECONDS = max( - PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS, - _env_float("AI_STEWARD_PROJECT_CREATION_TASK_CHAIN_TIMEOUT_SECONDS", 60.0), -) -PROJECT_CREATION_STUB_MARKERS = ( - "我暂时无法生成可靠回答", - "模型服务异常", - "请稍后重试", -) -PROJECT_CREATION_LOW_TO_HIGH_FUNCTION_MAP = { - "project_draft_analysis_glm47_fallback": "project_from_text_analysis", - "project_draft_signal_glm47_fallback": "project_from_text_signal", - "project_draft_charter_glm47_fallback": "project_from_text_charter", - "project_draft_task_chain_glm47_fallback": "project_from_text_task_chain", -} -PROJECT_CREATION_SERIAL_DRAFT_STEPS = _env_int("AI_STEWARD_PROJECT_CREATION_SERIAL_DRAFT_STEPS", 1) == 1 - - -class AIStewardService: - """主动式 AI 管家服务。""" - - def __init__(self, db: AsyncSession): - self.db = db - self.llm = LLMService() - - @staticmethod - def _normalize_project_title_candidate(candidate: str) -> str: - cleaned = re.sub(r"\s+", " ", str(candidate or "")).strip(" []【】()()::\"'“”") - if not cleaned: - return "" - cleaned = re.sub( - r"^(?:(?:项目|需求)(?:名字|名称|名)?(?:叫|是|为|名为|名称为|名字为)?|(?:项目|需求)?(?:名为|名称为|名字为|名称是|名字是|叫|为))\s*", - "", - cleaned, - flags=re.IGNORECASE, - ).strip(" []【】()()::\"'“”") - if not cleaned: - return "" - normalized = re.sub(r"\s+", "", cleaned).lower() - generic_tokens = { - "项目", - "一个项目", - "新项目", - "项目列表", - "项目库", - "pmflow", - "pmflow项目列表", - } - if normalized in generic_tokens: - return "" - if any(token in normalized for token in ("创建项目", "新建项目", "新增项目", "不是创建信号")): - return "" - return compact_title(cleaned, max_len=24) - - @classmethod - def _extract_project_title_from_instruction(cls, text: str) -> str: - cleaned = re.sub(r"\s+", " ", str(text or "")).strip() - if not cleaned: - return "" - # 优先去掉“不是X,是Y”中的前置纠正片段,避免把纠正话术落到标题里。 - cleaned = re.sub(r"^.*?不是[^,。,;;]*信号[^,。,;;]*[,,;; ]*是", "", cleaned) - quoted_match = re.search(r"(?:项目|新项目)[^\"“”']{0,10}[\"“”']([^\"“”']{2,40})[\"“”']", cleaned) - if quoted_match: - return cls._normalize_project_title_candidate(quoted_match.group(1)) - for pattern in ( - r"(?:项目|需求)(?:名字|名称|名)?(?:叫|名为|名称是|名称为|是|为)[::,,\s]*(.+)", - r"(?:帮我|请|麻烦|给我)?(?:新增|增加|添加|创建|新建|建立|立项|开个|开一个|建个|建一个|做个|做一个|加个|加一个)(?:一个|个)?项目(?:叫|名为|名称是|名称为|是|为)?[::,,\s]*(.+)", - ): - match = re.search(pattern, cleaned, flags=re.IGNORECASE) - if not match: - continue - candidate = re.sub(r"[,。,.;;!??].*$", "", match.group(1)).strip(" []【】()()::\"'“”") - candidate = re.sub(r"^(?:一个|个|新项目|项目|需求)\s*", "", candidate, flags=re.IGNORECASE) - candidate = re.sub( - r"^(?:(?:项目|需求)(?:名字|名称|名)?(?:叫|是|为|名为|名称为|名字为)?|(?:项目|需求)?(?:名为|名称为|名字为|名称是|名字是|叫|为))\s*", - "", - candidate, - flags=re.IGNORECASE, - ) - candidate = re.sub(r"(?:因为|所以|用来|为了|方便).*$", "", candidate).strip() - normalized = cls._normalize_project_title_candidate(candidate) - if normalized: - return normalized - return "" - - @staticmethod - def _looks_like_instruction_sentence(text: str) -> bool: - normalized = re.sub(r"\s+", "", str(text or "")).lower() - if not normalized: - return False - return "项目" in normalized and any( - token in normalized - for token in ("创建", "新建", "新增", "增加", "添加", "帮我", "给我", "不是", "信号") - ) - - @classmethod - def _compact_project_description(cls, *, input_text: str, title: str, draft_description: str) -> str: - raw = str(draft_description or "").strip() - if raw and not cls._looks_like_instruction_sentence(raw): - return raw[:4000] - if title: - return f"项目:{title}"[:4000] - return str(input_text or "").strip()[:4000] - - async def create_project_from_natural_language( - self, - input_text: str, - creator_id: int, - tenant_id: int = 1, - ) -> Dict[str, Any]: - """零配置项目创建,统一走 draft -> normalize -> persist 三段式。""" - analysis = await self._analyze_project_input(input_text, tenant_id) - draft = await self._generate_project_creation_draft( - input_text=input_text, - analysis=analysis, - tenant_id=tenant_id, - ) - normalized = await self._normalize_project_creation_draft( - draft=draft, - input_text=input_text, - creator_id=creator_id, - tenant_id=tenant_id, - ) - payload = await self._persist_project_creation_draft( - normalized=normalized, - input_text=input_text, - creator_id=creator_id, - tenant_id=tenant_id, - ) - - await self.db.commit() - - try: - charter = payload["charter"] - project = payload["project"] - asyncio.create_task( - AdaptiveRAGService.upsert_pmflow_record_async( - tenant_id=tenant_id, - record_type="charter", - record_id=int(charter["id"]), - project_id=int(project["id"]), - title=str(charter.get("title") or "项目宪章"), - content=json.dumps(charter.get("content") or {}, ensure_ascii=False), - ) - ) - for task in payload.get("tasks", []): - task_id = int(str(task.get("id") or "0") or 0) - if task_id <= 0: - continue - asyncio.create_task( - AdaptiveRAGService.upsert_pmflow_record_async( - tenant_id=tenant_id, - record_type="task", - record_id=task_id, - project_id=int(project["id"]), - title=str(task.get("title") or "任务"), - content=str(task.get("description") or ""), - ) - ) - except Exception: - pass - - return payload - - async def _generate_project_creation_draft( - self, - *, - input_text: str, - analysis: Dict[str, Any], - tenant_id: int, - ) -> dict[str, Any]: - if PROJECT_CREATION_SERIAL_DRAFT_STEPS: - signal_draft = await self._generate_competitive_signal_draft(analysis=analysis, tenant_id=tenant_id) - charter_draft = await self._generate_charter_draft(analysis=analysis, tenant_id=tenant_id) - task_drafts = await self._generate_task_chain_draft(analysis=analysis, tenant_id=tenant_id) - else: - signal_draft, charter_draft, task_drafts = await asyncio.gather( - self._generate_competitive_signal_draft(analysis=analysis, tenant_id=tenant_id), - self._generate_charter_draft(analysis=analysis, tenant_id=tenant_id), - self._generate_task_chain_draft(analysis=analysis, tenant_id=tenant_id), - ) - return { - "analysis": analysis, - "project": { - "name": analysis.get("project_name") or input_text[:20] or "未命名项目", - "title": analysis.get("project_title") or input_text[:50] or "未命名项目", - "description": input_text, - "assigned_role": analysis.get("primary_role") or "backend", - "domain_id": analysis.get("domain_id"), - }, - "signal": signal_draft, - "charter": charter_draft, - "tasks": task_drafts, - "risks": self._build_risk_alert_drafts(analysis=analysis), - } - - @staticmethod - def _looks_like_project_creation_stub_response(text: str) -> bool: - normalized = re.sub(r"\s+", "", str(text or "")).lower() - if not normalized: - return True - return any(marker in normalized for marker in PROJECT_CREATION_STUB_MARKERS) - - @staticmethod - def _project_creation_high_tier_function(function_name: str) -> str: - return str(PROJECT_CREATION_LOW_TO_HIGH_FUNCTION_MAP.get(str(function_name or "").strip(), "") or "").strip() - - @staticmethod - def _strip_code_fence(text: str) -> str: - raw = str(text or "").strip() - if not raw.startswith("```"): - return raw - chunks = raw.split("```") - if len(chunks) < 2: - return raw - candidate = chunks[1].strip() - if candidate.lower().startswith("json"): - candidate = candidate[4:].strip() - return candidate - - @staticmethod - def _extract_balanced_json_segment(text: str, open_char: str, close_char: str) -> str: - raw = str(text or "") - start = raw.find(open_char) - if start < 0: - return "" - depth = 0 - in_str = False - escaped = False - for idx in range(start, len(raw)): - ch = raw[idx] - if in_str: - if escaped: - escaped = False - elif ch == "\\": - escaped = True - elif ch == '"': - in_str = False - continue - if ch == '"': - in_str = True - continue - if ch == open_char: - depth += 1 - elif ch == close_char: - depth -= 1 - if depth == 0: - return raw[start : idx + 1].strip() - return "" - - def _extract_json_candidate(self, text: str) -> str: - raw = self._strip_code_fence(text) - if raw.startswith("{") or raw.startswith("["): - return raw - object_candidate = self._extract_balanced_json_segment(raw, "{", "}") - array_candidate = self._extract_balanced_json_segment(raw, "[", "]") - if object_candidate and array_candidate: - return object_candidate if raw.find("{") <= raw.find("[") else array_candidate - return object_candidate or array_candidate or raw - - @staticmethod - def _repair_json_text(raw: str) -> str: - text = str(raw or "") - if not text: - return text - buf: list[str] = [] - in_str = False - escaped = False - for ch in text: - if in_str: - if escaped: - escaped = False - elif ch == "\\": - escaped = True - elif ch == '"': - in_str = False - elif ch == "\n": - buf.append("\\n") - continue - elif ch == "\r": - buf.append("\\r") - continue - elif ch == "\t": - buf.append("\\t") - continue - else: - if ch == '"': - in_str = True - buf.append(ch) - repaired = "".join(buf) - repaired = re.sub(r",\s*([}\]])", r"\1", repaired) - return repaired - - def _parse_json_object_response(self, response_text: str, *, stage_label: str) -> Dict[str, Any]: - normalized = self._extract_json_candidate(response_text) - try: - payload = json.loads(normalized) - except Exception as exc: - repaired = self._repair_json_text(normalized) - try: - payload = json.loads(repaired) - except Exception: - try: - literal_payload = ast.literal_eval(normalized) - except Exception: - raise RuntimeError(f"{stage_label}:{exc}") from exc - payload = literal_payload - if not isinstance(payload, dict): - raise RuntimeError(f"{stage_label}:返回内容不是 JSON 对象") - return payload - - async def _generate_project_creation_object( - self, - *, - prompt: str, - tenant_id: int, - function_name: str, - stage_label: str, - timeout_seconds: float | None = None, - ) -> Dict[str, Any]: - max_rounds = PROJECT_CREATION_JSON_PARSE_RETRY_COUNT + 1 - last_error: Exception | None = None - for round_index in range(max_rounds): - response = await self._generate_project_creation_text( - prompt=prompt, - tenant_id=tenant_id, - function_name=function_name, - timeout_seconds=float(timeout_seconds) if timeout_seconds is not None else PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS, - ) - try: - return self._parse_json_object_response(response, stage_label=stage_label) - except Exception as exc: - last_error = exc if isinstance(exc, Exception) else RuntimeError(str(exc)) - logger.warning( - "ai_steward_project_creation_json_retry", - tenant_id=int(tenant_id), - function_name=function_name, - stage_label=stage_label, - round=round_index + 1, - error_type=type(last_error).__name__, - error=str(last_error), - ) - raise RuntimeError(str(last_error or f"{stage_label}:unknown_error")) from last_error - - async def _generate_project_creation_text( - self, - *, - prompt: str, - tenant_id: int, - function_name: str, - timeout_seconds: float = PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS, - ) -> str: - attempts_per_function = PROJECT_CREATION_STEP_RETRY_COUNT + 1 - last_error: Exception | None = None - for attempt_index in range(attempts_per_function): - started_at = time.perf_counter() - try: - response = await asyncio.wait_for( - self.llm.generate( - prompt, - tenant_id=tenant_id, - function_name=function_name, - response_mode="fast", - use_prompt_profile=False, - temperature=0.0, - max_tokens=2200, - ), - timeout=timeout_seconds, - ) - cleaned_response = str(response or "").strip() - elapsed_seconds = round(time.perf_counter() - started_at, 2) - if self._looks_like_project_creation_stub_response(cleaned_response): - raise RuntimeError("project_creation_stub_response") - logger.info( - "ai_steward_project_creation_step_ok", - tenant_id=int(tenant_id), - function_name=function_name, - attempt=attempt_index + 1, - elapsed_seconds=elapsed_seconds, - ) - return cleaned_response - except Exception as exc: - last_error = exc if isinstance(exc, Exception) else RuntimeError(str(exc)) - high_tier_function = self._project_creation_high_tier_function(function_name) - if high_tier_function: - high_started_at = time.perf_counter() - try: - fallback_response = await asyncio.wait_for( - self.llm.generate( - prompt, - tenant_id=tenant_id, - function_name=high_tier_function, - response_mode="fast", - use_prompt_profile=False, - temperature=0.0, - max_tokens=2200, - ), - timeout=timeout_seconds, - ) - cleaned_fallback = str(fallback_response or "").strip() - high_elapsed_seconds = round(time.perf_counter() - high_started_at, 2) - if self._looks_like_project_creation_stub_response(cleaned_fallback): - raise RuntimeError("project_creation_stub_response") - logger.info( - "ai_steward_project_creation_step_escalated_ok", - tenant_id=int(tenant_id), - function_name=function_name, - high_tier_function=high_tier_function, - attempt=attempt_index + 1, - elapsed_seconds=high_elapsed_seconds, - ) - return cleaned_fallback - except Exception as high_exc: - last_error = high_exc if isinstance(high_exc, Exception) else RuntimeError(str(high_exc)) - logger.warning( - "ai_steward_project_creation_step_escalated_retry", - tenant_id=int(tenant_id), - function_name=function_name, - high_tier_function=high_tier_function, - attempt=attempt_index + 1, - elapsed_seconds=round(time.perf_counter() - high_started_at, 2), - error_type=type(high_exc).__name__, - error=str(high_exc), - ) - logger.warning( - "ai_steward_project_creation_step_retry", - tenant_id=int(tenant_id), - function_name=function_name, - attempt=attempt_index + 1, - elapsed_seconds=round(time.perf_counter() - started_at, 2), - error_type=type(exc).__name__, - error=str(exc), - ) - - raise RuntimeError( - f"AI 管家项目创建步骤失败:function={function_name}, reason={last_error or 'unknown_error'}" - ) from last_error - - async def _normalize_project_creation_draft( - self, - *, - draft: dict[str, Any], - input_text: str, - creator_id: int, - tenant_id: int, - ) -> dict[str, Any]: - analysis = draft.get("analysis") if isinstance(draft.get("analysis"), dict) else {} - project_draft = draft.get("project") if isinstance(draft.get("project"), dict) else {} - charter_draft = draft.get("charter") if isinstance(draft.get("charter"), dict) else {} - signal_draft = draft.get("signal") if isinstance(draft.get("signal"), dict) else {} - risk_drafts = draft.get("risks") if isinstance(draft.get("risks"), list) else [] - - normalized_task_drafts = [] - task_audit: list[dict[str, Any]] = [] - for raw_task in (draft.get("tasks") if isinstance(draft.get("tasks"), list) else [])[:12]: - if not isinstance(raw_task, dict): - continue - raw_required_skills = normalize_skill_names(raw_task.get("required_skills")) - resolution = await resolve_skill_tag_ids_by_names( - self.db, - tenant_id=tenant_id, - skill_names=raw_required_skills, - ) - validation_error = None - try: - task_draft = build_task_create_draft( - title=str(raw_task.get("title") or ""), - description=str(raw_task.get("description") or "").strip() or None, - required_skill_tag_ids=resolution.matched_ids, - priority=raw_task.get("priority"), - status="todo", - estimated_hours=raw_task.get("estimated_hours"), - due_date=raw_task.get("due_date"), - ) - except ValueError as exc: - validation_error = str(exc) - task_draft = build_task_create_draft( - title=str(raw_task.get("title") or ""), - description=str(raw_task.get("description") or "").strip() or None, - required_skill_tag_ids=resolution.matched_ids, - priority=raw_task.get("priority"), - status="todo", - estimated_hours=None, - due_date=None, - ) - normalized_task_drafts.append(task_draft) - task_audit.append( - { - "raw_task_draft": raw_task, - "normalized_title": task_draft.title, - "required_skills_input": raw_required_skills, - "required_skill_tag_ids": resolution.matched_ids, - "skill_resolution": resolution.matched_items, - "dropped_required_skills": resolution.unmatched_names, - "validation_error": validation_error, - } - ) - - extracted_title = self._extract_project_title_from_instruction(input_text) - draft_title = str(project_draft.get("title") or "").strip() - if self._looks_like_instruction_sentence(draft_title): - draft_title = "" - normalized_title = ( - self._normalize_project_title_candidate(draft_title) - or extracted_title - or compact_title(str(input_text or "").strip(), max_len=24) - or "未命名项目" - ) - draft_name = str(project_draft.get("name") or "").strip() - normalized_name = self._normalize_project_title_candidate(draft_name) or normalized_title - normalized_project = { - "name": normalized_name[:50] or "未命名项目", - "title": normalized_title[:50] or "未命名项目", - "description": self._compact_project_description( - input_text=input_text, - title=normalized_title, - draft_description=str(project_draft.get("description") or ""), - ), - "assigned_role": str(project_draft.get("assigned_role") or "backend").strip()[:50] or "backend", - "domain_id": analysis.get("domain_id"), - } - normalized_charter_content = charter_draft.get("content") if isinstance(charter_draft.get("content"), dict) else { - "rationale": str(charter_draft.get("content") or ""), - "success_criteria": charter_draft.get("success_criteria") or [], - "constraints": charter_draft.get("constraints") or [], - "signal_ids": [], - } - normalized_signal = { - "content": str(signal_draft.get("content") or "").strip()[:2000] or f"竞品分析:{normalized_project['title']}", - "impact_score": float(signal_draft.get("impact_score") or 0.8), - "priority": str(signal_draft.get("priority") or "high").strip().lower() or "high", - "strategic_analysis": signal_draft.get("strategic_analysis") if isinstance(signal_draft.get("strategic_analysis"), dict) else {}, - } - normalized_risks = [risk for risk in risk_drafts if isinstance(risk, dict)] - - return { - "analysis": analysis, - "project": normalized_project, - "signal": normalized_signal, - "charter": { - "title": str(charter_draft.get("title") or f"{normalized_project['name']}项目宪章").strip()[:200] or "项目宪章", - "content": normalized_charter_content, - }, - "tasks": normalized_task_drafts, - "task_audit": task_audit, - "risks": normalized_risks, - "creator_id": int(creator_id), - } - - async def _persist_project_creation_draft( - self, - *, - normalized: dict[str, Any], - input_text: str, - creator_id: int, - tenant_id: int, - ) -> dict[str, Any]: - project_data = normalized["project"] - await ensure_project_id_sequence(self.db) - project = Project( - name=project_data["name"], - title=project_data["title"], - description=project_data["description"], - owner_id=int(creator_id), - status="active", - domain_id=project_data.get("domain_id"), - assigned_role=project_data["assigned_role"], - signal_count=0, - tenant_id=int(tenant_id), - ) - self.db.add(project) - await self.db.flush() - await ensure_project_default_members( - self.db, - tenant_id=int(tenant_id), - project_id=int(project.id), - owner_id=int(creator_id), - assigned_role=project.assigned_role, - ) - - signal_data = normalized["signal"] - signal = Signal( - source="AI-Steward", - content=signal_data["content"], - impact_score=signal_data["impact_score"], - priority=signal_data["priority"], - processed=True, - strategic_analysis=json.dumps(signal_data.get("strategic_analysis") or {}, ensure_ascii=False), - tasks_generated=json.dumps([]), - tenant_id=int(tenant_id), - ) - self.db.add(signal) - await self.db.flush() - - self.db.add( - ProjectSignalLink( - tenant_id=int(tenant_id), - project_id=int(project.id), - signal_id=int(signal.id), - link_type="competitive_analysis", - relevance_score=0.9, - ) - ) - - charter_data = normalized["charter"] - charter = TruthNode( - node_type="charter", - project_id=int(project.id), - created_by=int(creator_id), - title=charter_data["title"], - content=charter_data["content"], - version=1, - status="active", - tenant_id=int(tenant_id), - ) - self.db.add(charter) - await self.db.flush() - - persisted_tasks: list[dict[str, Any]] = [] - persisted_task_ids: list[int] = [] - for task_draft in normalized["tasks"]: - task = await persist_task( - self.db, - tenant_id=int(tenant_id), - truth_node_id=int(charter.id), - project_id=int(project.id), - draft=task_draft, - actor_user_id=int(creator_id), - action="task.create", - remark="ai_steward.project_from_text", - commit=False, - refresh_workload=False, - ) - persisted_task_ids.append(int(task.id)) - persisted_tasks.append( - { - "id": str(task.id), - "title": task.title, - "description": task.description or "", - "estimated_hours": float(task.estimated_hours or 0), - "priority": task.priority, - "status": task.status, - } - ) - - risks_payload: list[dict[str, Any]] = [] - for risk in normalized["risks"]: - description = str(risk.get("description") or "").strip() - conflict_type = str(risk.get("type") or "risk").strip() or "risk" - if not description: - continue - self.db.add( - CognitiveConflict( - truth_node_id=int(charter.id), - conflict_type=conflict_type, - description=description, - involved_user_ids=[], - status="active", - tenant_id=int(tenant_id), - ) - ) - risks_payload.append( - { - "type": conflict_type, - "description": description, - "threshold_hours": risk.get("threshold_hours"), - "threshold": risk.get("threshold"), - "severity": risk.get("severity"), - } - ) - - logger.info( - "ai_steward_project_from_text_persisted", - tenant_id=int(tenant_id), - creator_id=int(creator_id), - project_id=int(project.id), - signal_id=int(signal.id), - charter_id=int(charter.id), - persisted_task_ids=persisted_task_ids, - task_audit=normalized["task_audit"], - input_text=input_text[:500], - ) - - return { - "project": { - "id": str(project.id), - "name": project.name, - "title": project.title, - "description": project.description, - "status": project.status, - }, - "signal": { - "id": str(signal.id), - "content": signal.content, - "impact_score": signal.impact_score, - }, - "charter": { - "id": str(charter.id), - "title": charter.title, - "content": charter.content, - }, - "tasks": persisted_tasks, - "risks": risks_payload, - "analysis": normalized["analysis"], - } - - async def _analyze_project_input(self, input_text: str, tenant_id: int) -> Dict[str, Any]: - """使用 AI 分析项目输入,提取关键信息。""" - prompt = f"""分析以下项目需求,提取关键信息并以 JSON 格式返回: - -用户输入:{input_text} - -请提取以下信息: -{{ - "project_name": "项目名称(简短)", - "project_title": "项目标题", - "tech_stack": ["技术栈列表"], - "target_audience": "目标用户", - "timeline": "时间周期", - "primary_role": "主要角色类型(ai_engineer/backend/frontend)", - "domain_id": 1, - "key_features": ["核心功能列表"], - "competitors": ["竞品列表"], - "success_metrics": ["成功指标"] -}} - -只返回 JSON,不要其他内容。必须返回可被 json.loads 直接解析的单个对象,不要 markdown。""" - - analysis = await self._generate_project_creation_object( - prompt=prompt, - tenant_id=tenant_id, - function_name="project_draft_analysis_glm47_fallback", - stage_label="AI 管家项目需求分析失败", - ) - return analysis - - async def _generate_competitive_signal_draft( - self, - *, - analysis: Dict[str, Any], - tenant_id: int, - ) -> Dict[str, Any]: - competitors = analysis.get("competitors", []) - if not competitors: - competitors = ["行业同类产品"] - - prompt = f"""基于以下项目信息,生成竞品分析信号: - -项目:{analysis.get('project_title')} -技术栈:{', '.join(analysis.get('tech_stack', []))} -竞品:{', '.join(competitors)} - -请生成: -1. 竞品现状分析 -2. 差异化机会 -3. 技术风险评估 - -以 JSON 格式返回: -{{ - "content": "信号内容(200字以内)", - "impact_score": 0.9, - "priority": "high", - "strategic_analysis": {{"is_strategic": true, "threat_level": "MEDIUM"}} -}} - -只返回 JSON,不要 markdown,不要额外解释。""" - - payload = await self._generate_project_creation_object( - prompt=prompt, - tenant_id=tenant_id, - function_name="project_draft_signal_glm47_fallback", - stage_label="AI 管家竞品信号生成失败", - ) - return payload - - async def _generate_charter_draft( - self, - *, - analysis: Dict[str, Any], - tenant_id: int, - ) -> Dict[str, Any]: - prompt = f"""基于以下项目信息,生成项目宪章: - -项目:{analysis.get('project_title')} -描述:{analysis.get('project_title')} -技术栈:{', '.join(analysis.get('tech_stack', []))} -核心功能:{', '.join(analysis.get('key_features', []))} -成功指标:{', '.join(analysis.get('success_metrics', []))} - -请生成包含以下内容的宪章: -1. 项目目标(明确、可衡量) -2. 成功标准(具体指标) -3. 技术约束 -4. 时间节点 - -以 JSON 格式返回: -{{ - "title": "宪章标题", - "content": {{ - "objectives": ["目标1", "目标2"], - "success_criteria": ["标准1", "标准2"], - "constraints": ["约束1", "约束2"], - "milestones": ["里程碑1", "里程碑2"] - }}, - "success_criteria": ["标准1", "标准2"], - "constraints": ["约束1", "约束2"] -}} - -只返回 JSON,不要 markdown,不要额外解释。""" - - charter_data = await self._generate_project_creation_object( - prompt=prompt, - tenant_id=tenant_id, - function_name="project_draft_charter_glm47_fallback", - stage_label="AI 管家项目宪章生成失败", - ) - - raw_content = charter_data.get("content", "") - if isinstance(raw_content, dict): - content = dict(raw_content) - else: - content = { - "rationale": str(raw_content), - "success_criteria": charter_data.get("success_criteria", []), - "constraints": charter_data.get("constraints", []), - "signal_ids": [], - } - return { - "title": charter_data.get("title", "项目宪章"), - "content": content, - } - - async def _generate_task_chain_draft( - self, - *, - analysis: Dict[str, Any], - tenant_id: int, - ) -> List[Dict[str, Any]]: - prompt = f"""基于以下信息,生成完整的任务链: - -项目:{analysis.get('project_title')} -技术栈:{', '.join(analysis.get('tech_stack', []))} -时间周期:{analysis.get('timeline', '3周')} - -请生成: -1. 任务列表(按优先级排序,最多 4 条) -2. 每个任务包含:名称、类型、预估工时、依赖关系、技能要求 -3. 每个 description 必须控制在 60 字以内,不要换行 - -以 JSON 格式返回: -{{ - "tasks": [ - {{ - "title": "任务名称", - "type": "backend/frontend/ai_engineer", - "estimated_hours": 8, - "description": "任务描述", - "priority": "high/medium/low", - "required_skills": ["技能1", "技能2"] - }} - ] -}} - -只返回 JSON,不要 markdown,不要额外解释。""" - - tasks_data = await self._generate_project_creation_object( - prompt=prompt, - tenant_id=tenant_id, - function_name="project_draft_task_chain_glm47_fallback", - stage_label="AI 管家任务链生成失败", - timeout_seconds=PROJECT_CREATION_TASK_CHAIN_TIMEOUT_SECONDS, - ) - tasks_list = tasks_data.get("tasks", []) - if isinstance(tasks_list, list) and tasks_list: - return tasks_list - raise RuntimeError("AI 管家任务链生成失败:返回 tasks 为空") - - @staticmethod - def _build_risk_alert_drafts(*, analysis: Dict[str, Any]) -> List[Dict[str, Any]]: - estimated_hours = analysis.get("estimated_hours", 40) - return [ - { - "type": "schedule_delay", - "description": "任务超时预警", - "threshold_hours": estimated_hours * 1.2, - "severity": "medium", - }, - { - "type": "quality_drop", - "description": "质量下降预警", - "threshold": 0.85, - "severity": "high", - }, - { - "type": "skill_mismatch", - "description": "技能匹配度不足", - "threshold": 0.6, - "severity": "medium", - }, - ] - - -def get_ai_steward_service(db: AsyncSession) -> AIStewardService: - """获取 AI Steward 服务实例。""" - return AIStewardService(db) +__all__ = ["AIStewardService", "get_ai_steward_service"] diff --git a/backend/app/services/ai_steward_service/__init__.py b/backend/app/services/ai_steward_service/__init__.py new file mode 100644 index 00000000..ea036648 --- /dev/null +++ b/backend/app/services/ai_steward_service/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .service import AIStewardService, get_ai_steward_service + +__all__ = ["AIStewardService", "get_ai_steward_service"] diff --git a/backend/app/services/ai_steward_service/mixin_creation_normalize_persist.py b/backend/app/services/ai_steward_service/mixin_creation_normalize_persist.py new file mode 100644 index 00000000..26986975 --- /dev/null +++ b/backend/app/services/ai_steward_service/mixin_creation_normalize_persist.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +from .shared import * + +class AIStewardCreationPersistMixin: + async def _normalize_project_creation_draft( + self, + *, + draft: dict[str, Any], + input_text: str, + creator_id: int, + tenant_id: int, + ) -> dict[str, Any]: + analysis = draft.get("analysis") if isinstance(draft.get("analysis"), dict) else {} + project_draft = draft.get("project") if isinstance(draft.get("project"), dict) else {} + charter_draft = draft.get("charter") if isinstance(draft.get("charter"), dict) else {} + signal_draft = draft.get("signal") if isinstance(draft.get("signal"), dict) else {} + risk_drafts = draft.get("risks") if isinstance(draft.get("risks"), list) else [] + + normalized_task_drafts = [] + task_audit: list[dict[str, Any]] = [] + for raw_task in (draft.get("tasks") if isinstance(draft.get("tasks"), list) else [])[:12]: + if not isinstance(raw_task, dict): + continue + raw_required_skills = normalize_skill_names(raw_task.get("required_skills")) + resolution = await resolve_skill_tag_ids_by_names( + self.db, + tenant_id=tenant_id, + skill_names=raw_required_skills, + ) + validation_error = None + try: + task_draft = build_task_create_draft( + title=str(raw_task.get("title") or ""), + description=str(raw_task.get("description") or "").strip() or None, + required_skill_tag_ids=resolution.matched_ids, + priority=raw_task.get("priority"), + status="todo", + estimated_hours=raw_task.get("estimated_hours"), + due_date=raw_task.get("due_date"), + ) + except ValueError as exc: + validation_error = str(exc) + task_draft = build_task_create_draft( + title=str(raw_task.get("title") or ""), + description=str(raw_task.get("description") or "").strip() or None, + required_skill_tag_ids=resolution.matched_ids, + priority=raw_task.get("priority"), + status="todo", + estimated_hours=None, + due_date=None, + ) + normalized_task_drafts.append(task_draft) + task_audit.append( + { + "raw_task_draft": raw_task, + "normalized_title": task_draft.title, + "required_skills_input": raw_required_skills, + "required_skill_tag_ids": resolution.matched_ids, + "skill_resolution": resolution.matched_items, + "dropped_required_skills": resolution.unmatched_names, + "validation_error": validation_error, + } + ) + + extracted_title = self._extract_project_title_from_instruction(input_text) + draft_title = str(project_draft.get("title") or "").strip() + if self._looks_like_instruction_sentence(draft_title): + draft_title = "" + normalized_title = ( + self._normalize_project_title_candidate(draft_title) + or extracted_title + or compact_title(str(input_text or "").strip(), max_len=24) + or "未命名项目" + ) + draft_name = str(project_draft.get("name") or "").strip() + normalized_name = self._normalize_project_title_candidate(draft_name) or normalized_title + normalized_project = { + "name": normalized_name[:50] or "未命名项目", + "title": normalized_title[:50] or "未命名项目", + "description": self._compact_project_description( + input_text=input_text, + title=normalized_title, + draft_description=str(project_draft.get("description") or ""), + ), + "assigned_role": str(project_draft.get("assigned_role") or "backend").strip()[:50] or "backend", + "domain_id": analysis.get("domain_id"), + } + normalized_charter_content = charter_draft.get("content") if isinstance(charter_draft.get("content"), dict) else { + "rationale": str(charter_draft.get("content") or ""), + "success_criteria": charter_draft.get("success_criteria") or [], + "constraints": charter_draft.get("constraints") or [], + "signal_ids": [], + } + normalized_signal = { + "content": str(signal_draft.get("content") or "").strip()[:2000] or f"竞品分析:{normalized_project['title']}", + "impact_score": float(signal_draft.get("impact_score") or 0.8), + "priority": str(signal_draft.get("priority") or "high").strip().lower() or "high", + "strategic_analysis": signal_draft.get("strategic_analysis") if isinstance(signal_draft.get("strategic_analysis"), dict) else {}, + } + normalized_risks = [risk for risk in risk_drafts if isinstance(risk, dict)] + + return { + "analysis": analysis, + "project": normalized_project, + "signal": normalized_signal, + "charter": { + "title": str(charter_draft.get("title") or f"{normalized_project['name']}项目宪章").strip()[:200] or "项目宪章", + "content": normalized_charter_content, + }, + "tasks": normalized_task_drafts, + "task_audit": task_audit, + "risks": normalized_risks, + "creator_id": int(creator_id), + } + + async def _persist_project_creation_draft( + self, + *, + normalized: dict[str, Any], + input_text: str, + creator_id: int, + tenant_id: int, + ) -> dict[str, Any]: + project_data = normalized["project"] + await ensure_project_id_sequence(self.db) + project = Project( + name=project_data["name"], + title=project_data["title"], + description=project_data["description"], + owner_id=int(creator_id), + status="active", + domain_id=project_data.get("domain_id"), + assigned_role=project_data["assigned_role"], + signal_count=0, + tenant_id=int(tenant_id), + ) + self.db.add(project) + await self.db.flush() + await ensure_project_default_members( + self.db, + tenant_id=int(tenant_id), + project_id=int(project.id), + owner_id=int(creator_id), + assigned_role=project.assigned_role, + ) + + signal_data = normalized["signal"] + signal = Signal( + source="AI-Steward", + content=signal_data["content"], + impact_score=signal_data["impact_score"], + priority=signal_data["priority"], + processed=True, + strategic_analysis=json.dumps(signal_data.get("strategic_analysis") or {}, ensure_ascii=False), + tasks_generated=json.dumps([]), + tenant_id=int(tenant_id), + ) + self.db.add(signal) + await self.db.flush() + + self.db.add( + ProjectSignalLink( + tenant_id=int(tenant_id), + project_id=int(project.id), + signal_id=int(signal.id), + link_type="competitive_analysis", + relevance_score=0.9, + ) + ) + + charter_data = normalized["charter"] + charter = TruthNode( + node_type="charter", + project_id=int(project.id), + created_by=int(creator_id), + title=charter_data["title"], + content=charter_data["content"], + version=1, + status="active", + tenant_id=int(tenant_id), + ) + self.db.add(charter) + await self.db.flush() + + persisted_tasks: list[dict[str, Any]] = [] + persisted_task_ids: list[int] = [] + for task_draft in normalized["tasks"]: + task = await persist_task( + self.db, + tenant_id=int(tenant_id), + truth_node_id=int(charter.id), + project_id=int(project.id), + draft=task_draft, + actor_user_id=int(creator_id), + action="task.create", + remark="ai_steward.project_from_text", + commit=False, + refresh_workload=False, + ) + persisted_task_ids.append(int(task.id)) + persisted_tasks.append( + { + "id": str(task.id), + "title": task.title, + "description": task.description or "", + "estimated_hours": float(task.estimated_hours or 0), + "priority": task.priority, + "status": task.status, + } + ) + + risks_payload: list[dict[str, Any]] = [] + for risk in normalized["risks"]: + description = str(risk.get("description") or "").strip() + conflict_type = str(risk.get("type") or "risk").strip() or "risk" + if not description: + continue + self.db.add( + CognitiveConflict( + truth_node_id=int(charter.id), + conflict_type=conflict_type, + description=description, + involved_user_ids=[], + status="active", + tenant_id=int(tenant_id), + ) + ) + risks_payload.append( + { + "type": conflict_type, + "description": description, + "threshold_hours": risk.get("threshold_hours"), + "threshold": risk.get("threshold"), + "severity": risk.get("severity"), + } + ) + + logger.info( + "ai_steward_project_from_text_persisted", + tenant_id=int(tenant_id), + creator_id=int(creator_id), + project_id=int(project.id), + signal_id=int(signal.id), + charter_id=int(charter.id), + persisted_task_ids=persisted_task_ids, + task_audit=normalized["task_audit"], + input_text=input_text[:500], + ) + + return { + "project": { + "id": str(project.id), + "name": project.name, + "title": project.title, + "description": project.description, + "status": project.status, + }, + "signal": { + "id": str(signal.id), + "content": signal.content, + "impact_score": signal.impact_score, + }, + "charter": { + "id": str(charter.id), + "title": charter.title, + "content": charter.content, + }, + "tasks": persisted_tasks, + "risks": risks_payload, + "analysis": normalized["analysis"], + } + diff --git a/backend/app/services/ai_steward_service/mixin_creation_pipeline.py b/backend/app/services/ai_steward_service/mixin_creation_pipeline.py new file mode 100644 index 00000000..94f7cc5d --- /dev/null +++ b/backend/app/services/ai_steward_service/mixin_creation_pipeline.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from .shared import * + +class AIStewardCreationPipelineMixin: + async def create_project_from_natural_language( + self, + input_text: str, + creator_id: int, + tenant_id: int = 1, + ) -> Dict[str, Any]: + """零配置项目创建,统一走 draft -> normalize -> persist 三段式。""" + analysis = await self._analyze_project_input(input_text, tenant_id) + draft = await self._generate_project_creation_draft( + input_text=input_text, + analysis=analysis, + tenant_id=tenant_id, + ) + normalized = await self._normalize_project_creation_draft( + draft=draft, + input_text=input_text, + creator_id=creator_id, + tenant_id=tenant_id, + ) + payload = await self._persist_project_creation_draft( + normalized=normalized, + input_text=input_text, + creator_id=creator_id, + tenant_id=tenant_id, + ) + + await self.db.commit() + + try: + charter = payload["charter"] + project = payload["project"] + asyncio.create_task( + AdaptiveRAGService.upsert_pmflow_record_async( + tenant_id=tenant_id, + record_type="charter", + record_id=int(charter["id"]), + project_id=int(project["id"]), + title=str(charter.get("title") or "项目宪章"), + content=json.dumps(charter.get("content") or {}, ensure_ascii=False), + ) + ) + for task in payload.get("tasks", []): + task_id = int(str(task.get("id") or "0") or 0) + if task_id <= 0: + continue + asyncio.create_task( + AdaptiveRAGService.upsert_pmflow_record_async( + tenant_id=tenant_id, + record_type="task", + record_id=task_id, + project_id=int(project["id"]), + title=str(task.get("title") or "任务"), + content=str(task.get("description") or ""), + ) + ) + except Exception: + pass + + return payload + + async def _generate_project_creation_draft( + self, + *, + input_text: str, + analysis: Dict[str, Any], + tenant_id: int, + ) -> dict[str, Any]: + if PROJECT_CREATION_SERIAL_DRAFT_STEPS: + signal_draft = await self._generate_competitive_signal_draft(analysis=analysis, tenant_id=tenant_id) + charter_draft = await self._generate_charter_draft(analysis=analysis, tenant_id=tenant_id) + task_drafts = await self._generate_task_chain_draft(analysis=analysis, tenant_id=tenant_id) + else: + signal_draft, charter_draft, task_drafts = await asyncio.gather( + self._generate_competitive_signal_draft(analysis=analysis, tenant_id=tenant_id), + self._generate_charter_draft(analysis=analysis, tenant_id=tenant_id), + self._generate_task_chain_draft(analysis=analysis, tenant_id=tenant_id), + ) + return { + "analysis": analysis, + "project": { + "name": analysis.get("project_name") or input_text[:20] or "未命名项目", + "title": analysis.get("project_title") or input_text[:50] or "未命名项目", + "description": input_text, + "assigned_role": analysis.get("primary_role") or "backend", + "domain_id": analysis.get("domain_id"), + }, + "signal": signal_draft, + "charter": charter_draft, + "tasks": task_drafts, + "risks": self._build_risk_alert_drafts(analysis=analysis), + } + diff --git a/backend/app/services/ai_steward_service/mixin_draft_generation.py b/backend/app/services/ai_steward_service/mixin_draft_generation.py new file mode 100644 index 00000000..394a95dc --- /dev/null +++ b/backend/app/services/ai_steward_service/mixin_draft_generation.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +from .shared import * + +class AIStewardDraftGenerationMixin: + async def _analyze_project_input(self, input_text: str, tenant_id: int) -> Dict[str, Any]: + """使用 AI 分析项目输入,提取关键信息。""" + prompt = f"""分析以下项目需求,提取关键信息并以 JSON 格式返回: + +用户输入:{input_text} + +请提取以下信息: +{{ + "project_name": "项目名称(简短)", + "project_title": "项目标题", + "tech_stack": ["技术栈列表"], + "target_audience": "目标用户", + "timeline": "时间周期", + "primary_role": "主要角色类型(ai_engineer/backend/frontend)", + "domain_id": 1, + "key_features": ["核心功能列表"], + "competitors": ["竞品列表"], + "success_metrics": ["成功指标"] +}} + +只返回 JSON,不要其他内容。必须返回可被 json.loads 直接解析的单个对象,不要 markdown。""" + + analysis = await self._generate_project_creation_object( + prompt=prompt, + tenant_id=tenant_id, + function_name="project_draft_analysis_glm47_fallback", + stage_label="AI 管家项目需求分析失败", + ) + return analysis + + async def _generate_competitive_signal_draft( + self, + *, + analysis: Dict[str, Any], + tenant_id: int, + ) -> Dict[str, Any]: + competitors = analysis.get("competitors", []) + if not competitors: + competitors = ["行业同类产品"] + + prompt = f"""基于以下项目信息,生成竞品分析信号: + +项目:{analysis.get('project_title')} +技术栈:{', '.join(analysis.get('tech_stack', []))} +竞品:{', '.join(competitors)} + +请生成: +1. 竞品现状分析 +2. 差异化机会 +3. 技术风险评估 + +以 JSON 格式返回: +{{ + "content": "信号内容(200字以内)", + "impact_score": 0.9, + "priority": "high", + "strategic_analysis": {{"is_strategic": true, "threat_level": "MEDIUM"}} +}} + +只返回 JSON,不要 markdown,不要额外解释。""" + + payload = await self._generate_project_creation_object( + prompt=prompt, + tenant_id=tenant_id, + function_name="project_draft_signal_glm47_fallback", + stage_label="AI 管家竞品信号生成失败", + ) + return payload + + async def _generate_charter_draft( + self, + *, + analysis: Dict[str, Any], + tenant_id: int, + ) -> Dict[str, Any]: + prompt = f"""基于以下项目信息,生成项目宪章: + +项目:{analysis.get('project_title')} +描述:{analysis.get('project_title')} +技术栈:{', '.join(analysis.get('tech_stack', []))} +核心功能:{', '.join(analysis.get('key_features', []))} +成功指标:{', '.join(analysis.get('success_metrics', []))} + +请生成包含以下内容的宪章: +1. 项目目标(明确、可衡量) +2. 成功标准(具体指标) +3. 技术约束 +4. 时间节点 + +以 JSON 格式返回: +{{ + "title": "宪章标题", + "content": {{ + "objectives": ["目标1", "目标2"], + "success_criteria": ["标准1", "标准2"], + "constraints": ["约束1", "约束2"], + "milestones": ["里程碑1", "里程碑2"] + }}, + "success_criteria": ["标准1", "标准2"], + "constraints": ["约束1", "约束2"] +}} + +只返回 JSON,不要 markdown,不要额外解释。""" + + charter_data = await self._generate_project_creation_object( + prompt=prompt, + tenant_id=tenant_id, + function_name="project_draft_charter_glm47_fallback", + stage_label="AI 管家项目宪章生成失败", + ) + + raw_content = charter_data.get("content", "") + if isinstance(raw_content, dict): + content = dict(raw_content) + else: + content = { + "rationale": str(raw_content), + "success_criteria": charter_data.get("success_criteria", []), + "constraints": charter_data.get("constraints", []), + "signal_ids": [], + } + return { + "title": charter_data.get("title", "项目宪章"), + "content": content, + } + + async def _generate_task_chain_draft( + self, + *, + analysis: Dict[str, Any], + tenant_id: int, + ) -> List[Dict[str, Any]]: + prompt = f"""基于以下信息,生成完整的任务链: + +项目:{analysis.get('project_title')} +技术栈:{', '.join(analysis.get('tech_stack', []))} +时间周期:{analysis.get('timeline', '3周')} + +请生成: +1. 任务列表(按优先级排序,最多 4 条) +2. 每个任务包含:名称、类型、预估工时、依赖关系、技能要求 +3. 每个 description 必须控制在 60 字以内,不要换行 + +以 JSON 格式返回: +{{ + "tasks": [ + {{ + "title": "任务名称", + "type": "backend/frontend/ai_engineer", + "estimated_hours": 8, + "description": "任务描述", + "priority": "high/medium/low", + "required_skills": ["技能1", "技能2"] + }} + ] +}} + +只返回 JSON,不要 markdown,不要额外解释。""" + + tasks_data = await self._generate_project_creation_object( + prompt=prompt, + tenant_id=tenant_id, + function_name="project_draft_task_chain_glm47_fallback", + stage_label="AI 管家任务链生成失败", + timeout_seconds=PROJECT_CREATION_TASK_CHAIN_TIMEOUT_SECONDS, + ) + tasks_list = tasks_data.get("tasks", []) + if isinstance(tasks_list, list) and tasks_list: + return tasks_list + raise RuntimeError("AI 管家任务链生成失败:返回 tasks 为空") + + @staticmethod + def _build_risk_alert_drafts(*, analysis: Dict[str, Any]) -> List[Dict[str, Any]]: + estimated_hours = analysis.get("estimated_hours", 40) + return [ + { + "type": "schedule_delay", + "description": "任务超时预警", + "threshold_hours": estimated_hours * 1.2, + "severity": "medium", + }, + { + "type": "quality_drop", + "description": "质量下降预警", + "threshold": 0.85, + "severity": "high", + }, + { + "type": "skill_mismatch", + "description": "技能匹配度不足", + "threshold": 0.6, + "severity": "medium", + }, + ] + diff --git a/backend/app/services/ai_steward_service/mixin_json_parsing.py b/backend/app/services/ai_steward_service/mixin_json_parsing.py new file mode 100644 index 00000000..7f4712c8 --- /dev/null +++ b/backend/app/services/ai_steward_service/mixin_json_parsing.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +from .shared import * + +class AIStewardJsonParsingMixin: + @staticmethod + def _looks_like_project_creation_stub_response(text: str) -> bool: + normalized = re.sub(r"\s+", "", str(text or "")).lower() + if not normalized: + return True + return any(marker in normalized for marker in PROJECT_CREATION_STUB_MARKERS) + + @staticmethod + def _project_creation_high_tier_function(function_name: str) -> str: + return str(PROJECT_CREATION_LOW_TO_HIGH_FUNCTION_MAP.get(str(function_name or "").strip(), "") or "").strip() + + @staticmethod + def _strip_code_fence(text: str) -> str: + raw = str(text or "").strip() + if not raw.startswith("```"): + return raw + chunks = raw.split("```") + if len(chunks) < 2: + return raw + candidate = chunks[1].strip() + if candidate.lower().startswith("json"): + candidate = candidate[4:].strip() + return candidate + + @staticmethod + def _extract_balanced_json_segment(text: str, open_char: str, close_char: str) -> str: + raw = str(text or "") + start = raw.find(open_char) + if start < 0: + return "" + depth = 0 + in_str = False + escaped = False + for idx in range(start, len(raw)): + ch = raw[idx] + if in_str: + if escaped: + escaped = False + elif ch == "\\": + escaped = True + elif ch == '"': + in_str = False + continue + if ch == '"': + in_str = True + continue + if ch == open_char: + depth += 1 + elif ch == close_char: + depth -= 1 + if depth == 0: + return raw[start : idx + 1].strip() + return "" + + def _extract_json_candidate(self, text: str) -> str: + raw = self._strip_code_fence(text) + if raw.startswith("{") or raw.startswith("["): + return raw + object_candidate = self._extract_balanced_json_segment(raw, "{", "}") + array_candidate = self._extract_balanced_json_segment(raw, "[", "]") + if object_candidate and array_candidate: + return object_candidate if raw.find("{") <= raw.find("[") else array_candidate + return object_candidate or array_candidate or raw + + @staticmethod + def _repair_json_text(raw: str) -> str: + text = str(raw or "") + if not text: + return text + buf: list[str] = [] + in_str = False + escaped = False + for ch in text: + if in_str: + if escaped: + escaped = False + elif ch == "\\": + escaped = True + elif ch == '"': + in_str = False + elif ch == "\n": + buf.append("\\n") + continue + elif ch == "\r": + buf.append("\\r") + continue + elif ch == "\t": + buf.append("\\t") + continue + else: + if ch == '"': + in_str = True + buf.append(ch) + repaired = "".join(buf) + repaired = re.sub(r",\s*([}\]])", r"\1", repaired) + return repaired + + def _parse_json_object_response(self, response_text: str, *, stage_label: str) -> Dict[str, Any]: + normalized = self._extract_json_candidate(response_text) + try: + payload = json.loads(normalized) + except Exception as exc: + repaired = self._repair_json_text(normalized) + try: + payload = json.loads(repaired) + except Exception: + try: + literal_payload = ast.literal_eval(normalized) + except Exception: + raise RuntimeError(f"{stage_label}:{exc}") from exc + payload = literal_payload + if not isinstance(payload, dict): + raise RuntimeError(f"{stage_label}:返回内容不是 JSON 对象") + return payload + + async def _generate_project_creation_object( + self, + *, + prompt: str, + tenant_id: int, + function_name: str, + stage_label: str, + timeout_seconds: float | None = None, + ) -> Dict[str, Any]: + max_rounds = PROJECT_CREATION_JSON_PARSE_RETRY_COUNT + 1 + last_error: Exception | None = None + for round_index in range(max_rounds): + response = await self._generate_project_creation_text( + prompt=prompt, + tenant_id=tenant_id, + function_name=function_name, + timeout_seconds=float(timeout_seconds) if timeout_seconds is not None else PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS, + ) + try: + return self._parse_json_object_response(response, stage_label=stage_label) + except Exception as exc: + last_error = exc if isinstance(exc, Exception) else RuntimeError(str(exc)) + logger.warning( + "ai_steward_project_creation_json_retry", + tenant_id=int(tenant_id), + function_name=function_name, + stage_label=stage_label, + round=round_index + 1, + error_type=type(last_error).__name__, + error=str(last_error), + ) + raise RuntimeError(str(last_error or f"{stage_label}:unknown_error")) from last_error + + async def _generate_project_creation_text( + self, + *, + prompt: str, + tenant_id: int, + function_name: str, + timeout_seconds: float = PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS, + ) -> str: + attempts_per_function = PROJECT_CREATION_STEP_RETRY_COUNT + 1 + last_error: Exception | None = None + for attempt_index in range(attempts_per_function): + started_at = time.perf_counter() + try: + response = await asyncio.wait_for( + self.llm.generate( + prompt, + tenant_id=tenant_id, + function_name=function_name, + response_mode="fast", + use_prompt_profile=False, + temperature=0.0, + max_tokens=2200, + ), + timeout=timeout_seconds, + ) + cleaned_response = str(response or "").strip() + elapsed_seconds = round(time.perf_counter() - started_at, 2) + if self._looks_like_project_creation_stub_response(cleaned_response): + raise RuntimeError("project_creation_stub_response") + logger.info( + "ai_steward_project_creation_step_ok", + tenant_id=int(tenant_id), + function_name=function_name, + attempt=attempt_index + 1, + elapsed_seconds=elapsed_seconds, + ) + return cleaned_response + except Exception as exc: + last_error = exc if isinstance(exc, Exception) else RuntimeError(str(exc)) + high_tier_function = self._project_creation_high_tier_function(function_name) + if high_tier_function: + high_started_at = time.perf_counter() + try: + fallback_response = await asyncio.wait_for( + self.llm.generate( + prompt, + tenant_id=tenant_id, + function_name=high_tier_function, + response_mode="fast", + use_prompt_profile=False, + temperature=0.0, + max_tokens=2200, + ), + timeout=timeout_seconds, + ) + cleaned_fallback = str(fallback_response or "").strip() + high_elapsed_seconds = round(time.perf_counter() - high_started_at, 2) + if self._looks_like_project_creation_stub_response(cleaned_fallback): + raise RuntimeError("project_creation_stub_response") + logger.info( + "ai_steward_project_creation_step_escalated_ok", + tenant_id=int(tenant_id), + function_name=function_name, + high_tier_function=high_tier_function, + attempt=attempt_index + 1, + elapsed_seconds=high_elapsed_seconds, + ) + return cleaned_fallback + except Exception as high_exc: + last_error = high_exc if isinstance(high_exc, Exception) else RuntimeError(str(high_exc)) + logger.warning( + "ai_steward_project_creation_step_escalated_retry", + tenant_id=int(tenant_id), + function_name=function_name, + high_tier_function=high_tier_function, + attempt=attempt_index + 1, + elapsed_seconds=round(time.perf_counter() - high_started_at, 2), + error_type=type(high_exc).__name__, + error=str(high_exc), + ) + logger.warning( + "ai_steward_project_creation_step_retry", + tenant_id=int(tenant_id), + function_name=function_name, + attempt=attempt_index + 1, + elapsed_seconds=round(time.perf_counter() - started_at, 2), + error_type=type(exc).__name__, + error=str(exc), + ) + + raise RuntimeError( + f"AI 管家项目创建步骤失败:function={function_name}, reason={last_error or 'unknown_error'}" + ) from last_error + diff --git a/backend/app/services/ai_steward_service/mixin_title_utils.py b/backend/app/services/ai_steward_service/mixin_title_utils.py new file mode 100644 index 00000000..88c9a913 --- /dev/null +++ b/backend/app/services/ai_steward_service/mixin_title_utils.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from .shared import * + +class AIStewardTitleUtilsMixin: + def __init__(self, db: AsyncSession): + self.db = db + self.llm = LLMService() + + @staticmethod + def _normalize_project_title_candidate(candidate: str) -> str: + cleaned = re.sub(r"\s+", " ", str(candidate or "")).strip(" []【】()()::\"'“”") + if not cleaned: + return "" + cleaned = re.sub( + r"^(?:(?:项目|需求)(?:名字|名称|名)?(?:叫|是|为|名为|名称为|名字为)?|(?:项目|需求)?(?:名为|名称为|名字为|名称是|名字是|叫|为))\s*", + "", + cleaned, + flags=re.IGNORECASE, + ).strip(" []【】()()::\"'“”") + if not cleaned: + return "" + normalized = re.sub(r"\s+", "", cleaned).lower() + generic_tokens = { + "项目", + "一个项目", + "新项目", + "项目列表", + "项目库", + "pmflow", + "pmflow项目列表", + } + if normalized in generic_tokens: + return "" + if any(token in normalized for token in ("创建项目", "新建项目", "新增项目", "不是创建信号")): + return "" + return compact_title(cleaned, max_len=24) + + @classmethod + def _extract_project_title_from_instruction(cls, text: str) -> str: + cleaned = re.sub(r"\s+", " ", str(text or "")).strip() + if not cleaned: + return "" + # 优先去掉“不是X,是Y”中的前置纠正片段,避免把纠正话术落到标题里。 + cleaned = re.sub(r"^.*?不是[^,。,;;]*信号[^,。,;;]*[,,;; ]*是", "", cleaned) + quoted_match = re.search(r"(?:项目|新项目)[^\"“”']{0,10}[\"“”']([^\"“”']{2,40})[\"“”']", cleaned) + if quoted_match: + return cls._normalize_project_title_candidate(quoted_match.group(1)) + for pattern in ( + r"(?:项目|需求)(?:名字|名称|名)?(?:叫|名为|名称是|名称为|是|为)[::,,\s]*(.+)", + r"(?:帮我|请|麻烦|给我)?(?:新增|增加|添加|创建|新建|建立|立项|开个|开一个|建个|建一个|做个|做一个|加个|加一个)(?:一个|个)?项目(?:叫|名为|名称是|名称为|是|为)?[::,,\s]*(.+)", + ): + match = re.search(pattern, cleaned, flags=re.IGNORECASE) + if not match: + continue + candidate = re.sub(r"[,。,.;;!??].*$", "", match.group(1)).strip(" []【】()()::\"'“”") + candidate = re.sub(r"^(?:一个|个|新项目|项目|需求)\s*", "", candidate, flags=re.IGNORECASE) + candidate = re.sub( + r"^(?:(?:项目|需求)(?:名字|名称|名)?(?:叫|是|为|名为|名称为|名字为)?|(?:项目|需求)?(?:名为|名称为|名字为|名称是|名字是|叫|为))\s*", + "", + candidate, + flags=re.IGNORECASE, + ) + candidate = re.sub(r"(?:因为|所以|用来|为了|方便).*$", "", candidate).strip() + normalized = cls._normalize_project_title_candidate(candidate) + if normalized: + return normalized + return "" + + @staticmethod + def _looks_like_instruction_sentence(text: str) -> bool: + normalized = re.sub(r"\s+", "", str(text or "")).lower() + if not normalized: + return False + return "项目" in normalized and any( + token in normalized + for token in ("创建", "新建", "新增", "增加", "添加", "帮我", "给我", "不是", "信号") + ) + + @classmethod + def _compact_project_description(cls, *, input_text: str, title: str, draft_description: str) -> str: + raw = str(draft_description or "").strip() + if raw and not cls._looks_like_instruction_sentence(raw): + return raw[:4000] + if title: + return f"项目:{title}"[:4000] + return str(input_text or "").strip()[:4000] + diff --git a/backend/app/services/ai_steward_service/service.py b/backend/app/services/ai_steward_service/service.py new file mode 100644 index 00000000..74a48415 --- /dev/null +++ b/backend/app/services/ai_steward_service/service.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from .mixin_creation_normalize_persist import AIStewardCreationPersistMixin +from .mixin_creation_pipeline import AIStewardCreationPipelineMixin +from .mixin_draft_generation import AIStewardDraftGenerationMixin +from .mixin_json_parsing import AIStewardJsonParsingMixin +from .mixin_title_utils import AIStewardTitleUtilsMixin + + +class AIStewardService( + AIStewardTitleUtilsMixin, + AIStewardCreationPipelineMixin, + AIStewardJsonParsingMixin, + AIStewardCreationPersistMixin, + AIStewardDraftGenerationMixin, +): + """主动式 AI 管家服务。""" + + +def get_ai_steward_service(db: AsyncSession) -> AIStewardService: + """获取 AI Steward 服务实例。""" + return AIStewardService(db) + + +__all__ = ["AIStewardService", "get_ai_steward_service"] diff --git a/backend/app/services/ai_steward_service/shared.py b/backend/app/services/ai_steward_service/shared.py new file mode 100644 index 00000000..6a4ac0a0 --- /dev/null +++ b/backend/app/services/ai_steward_service/shared.py @@ -0,0 +1,82 @@ +"""AI Steward Service - 主动式 AI 管家核心服务。""" + +from __future__ import annotations + +import asyncio +import ast +import json +import os +import re +import time +from typing import Any, Dict, List + +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.logging import get_logger +from ...database import get_db +from ...models import CognitiveConflict, Project, ProjectSignalLink, Signal, TruthNode +from ...utils.project_members import ensure_project_default_members +from ...utils.sequence import ensure_project_id_sequence +from ...utils.title import compact_title +from ..adaptive_rag_service import AdaptiveRAGService +from ..llm import LLMService +from ..task_persistence import ( + build_task_create_draft, + normalize_skill_names, + persist_task, + resolve_skill_tag_ids_by_names, +) + +logger = get_logger(__name__) + + +def _env_float(name: str, default: float) -> float: + raw = os.getenv(name) + if raw is None: + return float(default) + try: + return float(str(raw).strip()) + except Exception: + return float(default) + + +def _env_int(name: str, default: int) -> int: + raw = os.getenv(name) + if raw is None: + return int(default) + try: + return int(str(raw).strip()) + except Exception: + return int(default) + + +PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS = max( + 8.0, + _env_float("AI_STEWARD_PROJECT_CREATION_STEP_TIMEOUT_SECONDS", 30.0), +) +PROJECT_CREATION_STEP_RETRY_COUNT = max( + 0, + _env_int("AI_STEWARD_PROJECT_CREATION_STEP_RETRY_COUNT", 1), +) +PROJECT_CREATION_JSON_PARSE_RETRY_COUNT = max( + 0, + _env_int("AI_STEWARD_PROJECT_CREATION_JSON_PARSE_RETRY_COUNT", 1), +) +PROJECT_CREATION_TASK_CHAIN_TIMEOUT_SECONDS = max( + PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS, + _env_float("AI_STEWARD_PROJECT_CREATION_TASK_CHAIN_TIMEOUT_SECONDS", 60.0), +) +PROJECT_CREATION_STUB_MARKERS = ( + "我暂时无法生成可靠回答", + "模型服务异常", + "请稍后重试", +) +PROJECT_CREATION_LOW_TO_HIGH_FUNCTION_MAP = { + "project_draft_analysis_glm47_fallback": "project_from_text_analysis", + "project_draft_signal_glm47_fallback": "project_from_text_signal", + "project_draft_charter_glm47_fallback": "project_from_text_charter", + "project_draft_task_chain_glm47_fallback": "project_from_text_task_chain", +} +PROJECT_CREATION_SERIAL_DRAFT_STEPS = _env_int("AI_STEWARD_PROJECT_CREATION_SERIAL_DRAFT_STEPS", 1) == 1 + + diff --git a/backend/app/services/auditor.py b/backend/app/services/auditor.py index 08c3ba3e..f6961e40 100644 --- a/backend/app/services/auditor.py +++ b/backend/app/services/auditor.py @@ -1,636 +1,3 @@ -""" -Neural Sync - Enhanced Code Auditor Service +from __future__ import annotations -增强版代码审计服务 - 支持严格模式的自动合并 -""" - -import asyncio -import re -import logging -from typing import Dict, List, Optional, Any, Tuple -from pydantic import BaseModel, Field -from datetime import datetime -from enum import Enum - -from ..utils.timezone import now_cn_aware -logger = logging.getLogger(__name__) - - -class AuditSeverity(str, Enum): - """审计问题严重程度""" - CRITICAL = "critical" # 阻止合并 - HIGH = "high" # 大幅扣分 - MEDIUM = "medium" # 中度扣分 - LOW = "low" # 轻微扣分 - INFO = "info" # 仅提示 - - -class AuditIssue(BaseModel): - """审计问题""" - severity: AuditSeverity - category: str # security, performance, style, logic, etc. - file_path: str - line_number: Optional[int] = None - message: str - suggestion: Optional[str] = None - score_penalty: float = 0.0 - - -class AuditResult(BaseModel): - """审计结果""" - signal_id: Optional[str] = None - commit_sha: Optional[str] = None - task_name: Optional[str] = None - passed: bool - score: float = Field(default=0.0, ge=0.0, le=100.0) - issues: List[AuditIssue] = Field(default_factory=list) - summary: str = "" - details: Dict[str, Any] = Field(default_factory=dict) - timestamp: float = Field(default_factory=lambda: now_cn_aware().timestamp()) - - -class SecurityIssue(BaseModel): - """安全问题""" - type: str # sql_injection, xss, csrf, etc. - severity: AuditSeverity - file_path: str - line_number: int - code_snippet: str - description: str - fix_suggestion: str - - -class CodeAuditor: - """ - 增强版代码审计器 - - 特性: - - 安全漏洞扫描 - - 代码质量评估 - - 性能问题检测 - - 最佳实践检查 - - 严格评分机制(98+ 分阈值) - """ - - # 审计权重 - WEIGHTS = { - "security": 30, # 安全性最重要 - "functionality": 25, # 功能正确性 - "performance": 15, # 性能优化 - "style": 10, # 代码风格 - "documentation": 10, # 文档完整性 - "testing": 10, # 测试覆盖 - } - - # 安全模式库 - SECURITY_PATTERNS = { - "sql_injection": [ - r'execute\s*\(\s*["\'].*?\+.*?["\']', # 字符串拼接 SQL - r'cursor\.execute\s*\(\s*["\'].*?%s.*?["\']', # %s 格式化 - r'\$\{.*\}.*?(?:SELECT|INSERT|UPDATE|DELETE)', # 模板字符串 SQL - ], - "xss": [ - r'innerHTML\s*=\s*.*?\+.*?(?:request|input|form)', - r'eval\s*\(', - r'document\.write\s*\(', - ], - "hardcoded_secrets": [ - r'(?:password|secret|api_key|token)\s*=\s*["\'][^"\']+["\']', - r'Bearer\s+[A-Za-z0-9]{20,}', - ], - "command_injection": [ - r'subprocess\.call\s*\(\s*["\'].*?\+.*?["\']', - r'os\.system\s*\(\s*["\'].*?\+.*?["\']', - ], - "path_traversal": [ - r'open\s*\(\s*["\'].*?\.\.\/.*?["\']', - r'Path\s*\(\s*["\'].*?\.\.\/.*?["\']', - ], - } - - # 性能问题模式 - PERFORMANCE_PATTERNS = { - "n_plus_1": [ - r'for\s+\w+\s+in\s+range\s*\(\s*len\s*\(', - r'\[[^\]]+\]\s*\.append\s*\(.*?\)\s*for\s+\w+\s+in\s+', - ], - "inefficient_string_concat": [ - r'["\'][^"\']+["\']\s*\+.*?\+', - ], - "unused_import": [], - "large_file": None, # 文件行数检查 - } - - # 代码风格检查 - STYLE_PATTERNS = { - "line_too_long": 100, # 最大行长度 - "function_too_long": 50, # 最大函数行数 - "class_too_long": 200, # 最大类行数 - } - - def __init__(self, llm_service): - self.llm = llm_service - - async def audit_code_change( - self, - task_name: str, - commit_message: str, - code_diff: str, - signal_id: Optional[str] = None, - commit_sha: Optional[str] = None, - file_contexts: Optional[Dict[str, str]] = None, - ) -> AuditResult: - """ - 审计代码变更 - - Args: - task_name: 任务名称 - commit_message: 提交消息 - code_diff: 代码 diff - signal_id: 关联的信号 ID - commit_sha: 提交 SHA - file_contexts: 文件上下文(完整文件内容) - - Returns: - 审计结果 - """ - issues = [] - - try: - # 解析 diff 获取变更的文件 - changed_files = self._parse_diff(code_diff) - - # 对每个变更的文件进行审计 - for file_path, file_changes in changed_files.items(): - # 获取文件内容(如果提供了上下文) - full_content = file_contexts.get(file_path) if file_contexts else None - - # 安全审计 - security_issues = await self._audit_security( - file_path, file_changes, full_content - ) - issues.extend(security_issues) - - # 性能审计 - perf_issues = self._audit_performance( - file_path, file_changes, full_content - ) - issues.extend(perf_issues) - - # 代码风格审计 - style_issues = self._audit_style( - file_path, file_changes, full_content - ) - issues.extend(style_issues) - - # 功能正确性审计(使用 AI) - functional_issues = await self._audit_functionality( - task_name, file_path, file_changes, full_content - ) - issues.extend(functional_issues) - - # AI 深度审计 - ai_issues = await self._ai_deep_audit( - task_name, commit_message, code_diff, changed_files - ) - issues.extend(ai_issues) - - # 计算总分 - score = self._calculate_score(issues) - - # 生成摘要 - summary = self._generate_summary(issues, score) - - # 判断是否通过(严格模式:98分) - passed = score >= 98.0 and not any( - i.severity == AuditSeverity.CRITICAL for i in issues - ) - - return AuditResult( - signal_id=signal_id, - commit_sha=commit_sha, - task_name=task_name, - passed=passed, - score=score, - issues=issues, - summary=summary, - details={ - "changed_files_count": len(changed_files), - "issues_by_severity": self._count_issues_by_severity(issues), - "issues_by_category": self._count_issues_by_category(issues), - "changed_files": list(changed_files.keys()), - }, - ) - - except Exception as e: - logger.error(f"Audit failed: {e}", exc_info=True) - return AuditResult( - signal_id=signal_id, - commit_sha=commit_sha, - task_name=task_name, - passed=False, - score=0.0, - issues=[ - AuditIssue( - severity=AuditSeverity.CRITICAL, - category="system", - file_path="unknown", - message=f"审计过程出错: {str(e)}", - score_penalty=100.0, - ) - ], - summary=f"审计失败: {str(e)}", - ) - - def _parse_diff(self, diff: str) -> Dict[str, str]: - """解析 unified diff 格式""" - files = {} - current_file = None - current_content = [] - - for line in diff.split("\n"): - if line.startswith("diff --git"): - # 新文件开始 - if current_file and current_content: - files[current_file] = "\n".join(current_content) - - # 提取文件路径 - parts = line.split() - if len(parts) >= 4: - current_file = parts[3].lstrip("b/") - else: - current_file = parts[2].lstrip("b/") - current_content = [] - - elif line.startswith("@@") or line.startswith("---") or line.startswith("+++"): - continue - elif current_file: - current_content.append(line) - - # 保存最后一个文件 - if current_file and current_content: - files[current_file] = "\n".join(current_content) - - return files - - async def _audit_security( - self, file_path: str, file_changes: str, full_content: Optional[str] - ) -> List[AuditIssue]: - """安全审计""" - issues = [] - content = full_content or file_changes - - # 检查常见安全问题 - for vuln_type, patterns in self.SECURITY_PATTERNS.items(): - for pattern in patterns: - matches = re.finditer(pattern, content, re.IGNORECASE | re.MULTILINE) - for match in matches: - line_num = content[:match.start()].count("\n") + 1 - - # 计算扣分 - if vuln_type in ["sql_injection", "xss", "command_injection"]: - severity = AuditSeverity.CRITICAL - penalty = 20.0 - elif vuln_type == "hardcoded_secrets": - severity = AuditSeverity.HIGH - penalty = 10.0 - elif vuln_type == "path_traversal": - severity = AuditSeverity.HIGH - penalty = 15.0 - else: - severity = AuditSeverity.MEDIUM - penalty = 5.0 - - issues.append( - AuditIssue( - severity=severity, - category="security", - file_path=file_path, - line_number=line_num, - message=f"潜在{vuln_type}漏洞", - suggestion=f"使用参数化查询/输入验证来避免{vuln_type}", - score_penalty=penalty, - ) - ) - - return issues - - def _audit_performance( - self, file_path: str, file_changes: str, full_content: Optional[str] - ) -> List[AuditIssue]: - """性能审计""" - issues = [] - content = full_content or file_changes - - # 检查 N+1 问题 - for pattern in self.PERFORMANCE_PATTERNS.get("n_plus_1", []): - matches = re.finditer(pattern, content, re.MULTILINE) - for match in matches: - line_num = content[:match.start()].count("\n") + 1 - issues.append( - AuditIssue( - severity=AuditSeverity.MEDIUM, - category="performance", - file_path=file_path, - line_number=line_num, - message="可能的 N+1 查询问题", - suggestion="考虑使用 bulk_create 或 select_related 优化", - score_penalty=5.0, - ) - ) - - # 检查低效字符串拼接 - for pattern in self.PERFORMANCE_PATTERNS.get("inefficient_string_concat", []): - matches = re.finditer(pattern, content) - for match in matches: - line_num = content[:match.start()].count("\n") + 1 - issues.append( - AuditIssue( - severity=AuditSeverity.LOW, - category="performance", - file_path=file_path, - line_number=line_num, - message="低效的字符串拼接", - suggestion="使用 f-string 或 join()", - score_penalty=2.0, - ) - ) - - # 检查文件大小 - if full_content: - line_count = len(full_content.split("\n")) - if line_count > 500: - issues.append( - AuditIssue( - severity=AuditSeverity.MEDIUM, - category="performance", - file_path=file_path, - message=f"文件过大 ({line_count} 行)", - suggestion="考虑拆分为更小的模块", - score_penalty=5.0, - ) - ) - - return issues - - def _audit_style( - self, file_path: str, file_changes: str, full_content: Optional[str] - ) -> List[AuditIssue]: - """代码风格审计""" - issues = [] - content = full_content or file_changes - - # 检查行长度 - max_length = self.STYLE_PATTERNS["line_too_long"] - for i, line in enumerate(content.split("\n"), 1): - if len(line) > max_length: - issues.append( - AuditIssue( - severity=AuditSeverity.INFO, - category="style", - file_path=file_path, - line_number=i, - message=f"行过长 ({len(line)} > {max_length})", - suggestion="拆分为多行", - score_penalty=1.0, - ) - ) - - return issues - - async def _audit_functionality( - self, task_name: str, file_path: str, file_changes: str, full_content: Optional[str] - ) -> List[AuditIssue]: - """功能正确性审计(使用 AI)""" - issues = [] - - # 构建 AI 审计 prompt - prompt = f"""作为一位资深代码审查员,请审计以下代码变更: - -任务需求:{task_name} - -文件:{file_path} - -代码变更: -```diff -{file_changes} -``` - -{f'完整文件内容:\n```{self._detect_language(file_path)}\n{full_content}\n```' if full_content else ''} - -请从以下维度进行审查: -1. 功能正确性:代码是否正确实现了需求 -2. 逻辑完整性:是否考虑了边界情况 -3. 错误处理:是否有适当的异常处理 -4. 资源管理:是否有资源泄漏风险 - -返回格式(JSON): -```json -{{ - "issues": [ - {{ - "severity": "critical|high|medium|low|info", - "line_number": 123, - "message": "问题描述", - "suggestion": "改进建议", - "score_penalty": 10.0 - }} - ] -}} -``` -""" - - try: - # 调用 LLM 进行深度审计 - response = await self.llm._call_llm_chat( - messages=[ - {"role": "system", "content": "你是一位严格但公平的代码审查专家。"}, - {"role": "user", "content": prompt} - ], - temperature=0.2, - ) - - # 解析 AI 响应 - import json - json_match = re.search(r'```json\s*([\s\S]*?)\s*```', response) - if json_match: - ai_result = json.loads(json_match.group(1)) - for issue_data in ai_result.get("issues", []): - # 限制 AI 生成的问题数量,避免过度敏感 - if len(issues) < 10: # 最多 10 个 AI 发现的问题 - severity_map = { - "critical": AuditSeverity.CRITICAL, - "high": AuditSeverity.HIGH, - "medium": AuditSeverity.MEDIUM, - "low": AuditSeverity.LOW, - "info": AuditSeverity.INFO, - } - issues.append( - AuditIssue( - severity=severity_map.get(issue_data.get("severity", "info"), AuditSeverity.INFO), - category="functionality", - file_path=file_path, - line_number=issue_data.get("line_number"), - message=issue_data.get("message", ""), - suggestion=issue_data.get("suggestion", ""), - score_penalty=issue_data.get("score_penalty", 5.0), - ) - ) - except Exception as e: - logger.warning(f"AI functionality audit failed: {e}") - - return issues - - async def _ai_deep_audit( - self, task_name: str, commit_message: str, code_diff: str, changed_files: Dict[str, str] - ) -> List[AuditIssue]: - """AI 深度审计 - 跨文件分析""" - issues = [] - - # 限制文件数量,避免 prompt 过长 - files_to_audit = list(changed_files.keys())[:5] - - prompt = f"""作为一位资深架构师,请从系统层面审查以下代码变更: - -任务:{task_name} -提交消息:{commit_message} - -变更的文件: -{', '.join(files_to_audit)} - -完整 diff: -```diff -{code_diff[:2000]} # 限制长度 -``` - -请检查: -1. 架构一致性:变更是否符合项目架构 -2. 依赖关系:是否引入了不兼容的依赖 -3. 配置变更:是否需要更新配置文件 -4. 迁移影响:是否需要数据迁移 -5. 测试覆盖:是否需要添加测试 - -返回格式(JSON): -```json -{{ - "issues": [ - {{ - "severity": "critical|high|medium|low", - "category": "architecture|dependencies|configuration|migration|testing", - "message": "问题描述", - "suggestion": "建议", - "score_penalty": 5.0 - }} - ] -}} -``` -""" - - try: - response = await self.llm._call_llm_chat( - messages=[ - {"role": "system", "content": "你是一位经验丰富的软件架构师。"}, - {"role": "user", "content": prompt} - ], - temperature=0.2, - ) - - import json - json_match = re.search(r'```json\s*([\s\S]*?)\s*```', response) - if json_match: - ai_result = json.loads(json_match.group(1)) - for issue_data in ai_result.get("issues", []): - if len(issues) < 5: # 限制数量 - severity_map = { - "critical": AuditSeverity.CRITICAL, - "high": AuditSeverity.HIGH, - "medium": AuditSeverity.MEDIUM, - "low": AuditSeverity.LOW, - } - issues.append( - AuditIssue( - severity=severity_map.get(issue_data.get("severity", "low"), AuditSeverity.LOW), - category=issue_data.get("category", "architecture"), - file_path="multiple", - message=issue_data.get("message", ""), - suggestion=issue_data.get("suggestion", ""), - score_penalty=issue_data.get("score_penalty", 3.0), - ) - ) - except Exception as e: - logger.warning(f"AI deep audit failed: {e}") - - return issues - - def _calculate_score(self, issues: List[AuditIssue]) -> float: - """计算审计分数""" - # 初始满分 - score = 100.0 - - # 按权重扣分 - for issue in issues: - weight = self.WEIGHTS.get(issue.category, 10) - penalty = issue.score_penalty * (weight / 100) - score -= penalty - - return max(0.0, score) - - def _generate_summary(self, issues: List[AuditIssue], score: float) -> str: - """生成审计摘要""" - if not issues: - return f"完美!代码审计通过,得分: {score:.1f}/100" - - # 按严重程度统计 - critical = sum(1 for i in issues if i.severity == AuditSeverity.CRITICAL) - high = sum(1 for i in issues if i.severity == AuditSeverity.HIGH) - medium = sum(1 for i in issues if i.severity == AuditSeverity.MEDIUM) - - summary_parts = [f"审计得分: {score:.1f}/100"] - if critical > 0: - summary_parts.append(f"{critical} 个严重问题") - if high > 0: - summary_parts.append(f"{high} 个高优先级问题") - if medium > 0: - summary_parts.append(f"{medium} 个中等问题") - - return " | ".join(summary_parts) - - def _count_issues_by_severity(self, issues: List[AuditIssue]) -> Dict[str, int]: - """按严重程度统计问题""" - counts = {s.value: 0 for s in AuditSeverity} - for issue in issues: - counts[issue.severity.value] += 1 - return counts - - def _count_issues_by_category(self, issues: List[AuditIssue]) -> Dict[str, int]: - """按类别统计问题""" - counts = {} - for issue in issues: - counts[issue.category] = counts.get(issue.category, 0) + 1 - return counts - - def _detect_language(self, file_path: str) -> str: - """检测文件语言""" - ext = file_path.rsplit(".", 1)[-1].lower() - lang_map = { - "py": "python", - "ts": "typescript", - "tsx": "typescript", - "js": "javascript", - "jsx": "javascript", - "vue": "vue", - "sql": "sql", - } - return lang_map.get(ext, "text") - - -# 全局审计器实例 -_auditor = None - - -def get_auditor(llm_service) -> CodeAuditor: - """获取代码审计器实例""" - global _auditor - if _auditor is None: - _auditor = CodeAuditor(llm_service) - return _auditor +from .auditor_core import * diff --git a/backend/app/services/auditor_core/__init__.py b/backend/app/services/auditor_core/__init__.py new file mode 100644 index 00000000..bf7820f3 --- /dev/null +++ b/backend/app/services/auditor_core/__init__.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +from .service import * diff --git a/backend/app/services/auditor_core/mixin_ai_score.py b/backend/app/services/auditor_core/mixin_ai_score.py new file mode 100644 index 00000000..e558e7dc --- /dev/null +++ b/backend/app/services/auditor_core/mixin_ai_score.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +from .shared import * + +class CodeAuditorAIScoreMixin: + async def _audit_functionality( + self, task_name: str, file_path: str, file_changes: str, full_content: Optional[str] + ) -> List[AuditIssue]: + """功能正确性审计(使用 AI)""" + issues = [] + + # 构建 AI 审计 prompt + prompt = f"""作为一位资深代码审查员,请审计以下代码变更: + +任务需求:{task_name} + +文件:{file_path} + +代码变更: +```diff +{file_changes} +``` + +{f'完整文件内容:\n```{self._detect_language(file_path)}\n{full_content}\n```' if full_content else ''} + +请从以下维度进行审查: +1. 功能正确性:代码是否正确实现了需求 +2. 逻辑完整性:是否考虑了边界情况 +3. 错误处理:是否有适当的异常处理 +4. 资源管理:是否有资源泄漏风险 + +返回格式(JSON): +```json +{{ + "issues": [ + {{ + "severity": "critical|high|medium|low|info", + "line_number": 123, + "message": "问题描述", + "suggestion": "改进建议", + "score_penalty": 10.0 + }} + ] +}} +``` +""" + + try: + # 调用 LLM 进行深度审计 + response = await self.llm._call_llm_chat( + messages=[ + {"role": "system", "content": "你是一位严格但公平的代码审查专家。"}, + {"role": "user", "content": prompt} + ], + temperature=0.2, + ) + + # 解析 AI 响应 + import json + json_match = re.search(r'```json\s*([\s\S]*?)\s*```', response) + if json_match: + ai_result = json.loads(json_match.group(1)) + for issue_data in ai_result.get("issues", []): + # 限制 AI 生成的问题数量,避免过度敏感 + if len(issues) < 10: # 最多 10 个 AI 发现的问题 + severity_map = { + "critical": AuditSeverity.CRITICAL, + "high": AuditSeverity.HIGH, + "medium": AuditSeverity.MEDIUM, + "low": AuditSeverity.LOW, + "info": AuditSeverity.INFO, + } + issues.append( + AuditIssue( + severity=severity_map.get(issue_data.get("severity", "info"), AuditSeverity.INFO), + category="functionality", + file_path=file_path, + line_number=issue_data.get("line_number"), + message=issue_data.get("message", ""), + suggestion=issue_data.get("suggestion", ""), + score_penalty=issue_data.get("score_penalty", 5.0), + ) + ) + except Exception as e: + logger.warning(f"AI functionality audit failed: {e}") + + return issues + + async def _ai_deep_audit( + self, task_name: str, commit_message: str, code_diff: str, changed_files: Dict[str, str] + ) -> List[AuditIssue]: + """AI 深度审计 - 跨文件分析""" + issues = [] + + # 限制文件数量,避免 prompt 过长 + files_to_audit = list(changed_files.keys())[:5] + + prompt = f"""作为一位资深架构师,请从系统层面审查以下代码变更: + +任务:{task_name} +提交消息:{commit_message} + +变更的文件: +{', '.join(files_to_audit)} + +完整 diff: +```diff +{code_diff[:2000]} # 限制长度 +``` + +请检查: +1. 架构一致性:变更是否符合项目架构 +2. 依赖关系:是否引入了不兼容的依赖 +3. 配置变更:是否需要更新配置文件 +4. 迁移影响:是否需要数据迁移 +5. 测试覆盖:是否需要添加测试 + +返回格式(JSON): +```json +{{ + "issues": [ + {{ + "severity": "critical|high|medium|low", + "category": "architecture|dependencies|configuration|migration|testing", + "message": "问题描述", + "suggestion": "建议", + "score_penalty": 5.0 + }} + ] +}} +``` +""" + + try: + response = await self.llm._call_llm_chat( + messages=[ + {"role": "system", "content": "你是一位经验丰富的软件架构师。"}, + {"role": "user", "content": prompt} + ], + temperature=0.2, + ) + + import json + json_match = re.search(r'```json\s*([\s\S]*?)\s*```', response) + if json_match: + ai_result = json.loads(json_match.group(1)) + for issue_data in ai_result.get("issues", []): + if len(issues) < 5: # 限制数量 + severity_map = { + "critical": AuditSeverity.CRITICAL, + "high": AuditSeverity.HIGH, + "medium": AuditSeverity.MEDIUM, + "low": AuditSeverity.LOW, + } + issues.append( + AuditIssue( + severity=severity_map.get(issue_data.get("severity", "low"), AuditSeverity.LOW), + category=issue_data.get("category", "architecture"), + file_path="multiple", + message=issue_data.get("message", ""), + suggestion=issue_data.get("suggestion", ""), + score_penalty=issue_data.get("score_penalty", 3.0), + ) + ) + except Exception as e: + logger.warning(f"AI deep audit failed: {e}") + + return issues + + def _calculate_score(self, issues: List[AuditIssue]) -> float: + """计算审计分数""" + # 初始满分 + score = 100.0 + + # 按权重扣分 + for issue in issues: + weight = self.WEIGHTS.get(issue.category, 10) + penalty = issue.score_penalty * (weight / 100) + score -= penalty + + return max(0.0, score) + + def _generate_summary(self, issues: List[AuditIssue], score: float) -> str: + """生成审计摘要""" + if not issues: + return f"完美!代码审计通过,得分: {score:.1f}/100" + + # 按严重程度统计 + critical = sum(1 for i in issues if i.severity == AuditSeverity.CRITICAL) + high = sum(1 for i in issues if i.severity == AuditSeverity.HIGH) + medium = sum(1 for i in issues if i.severity == AuditSeverity.MEDIUM) + + summary_parts = [f"审计得分: {score:.1f}/100"] + if critical > 0: + summary_parts.append(f"{critical} 个严重问题") + if high > 0: + summary_parts.append(f"{high} 个高优先级问题") + if medium > 0: + summary_parts.append(f"{medium} 个中等问题") + + return " | ".join(summary_parts) + + def _count_issues_by_severity(self, issues: List[AuditIssue]) -> Dict[str, int]: + """按严重程度统计问题""" + counts = {s.value: 0 for s in AuditSeverity} + for issue in issues: + counts[issue.severity.value] += 1 + return counts + + def _count_issues_by_category(self, issues: List[AuditIssue]) -> Dict[str, int]: + """按类别统计问题""" + counts = {} + for issue in issues: + counts[issue.category] = counts.get(issue.category, 0) + 1 + return counts + diff --git a/backend/app/services/auditor_core/mixin_checks.py b/backend/app/services/auditor_core/mixin_checks.py new file mode 100644 index 00000000..7932e67e --- /dev/null +++ b/backend/app/services/auditor_core/mixin_checks.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from .shared import * + +class CodeAuditorChecksMixin: + def _parse_diff(self, diff: str) -> Dict[str, str]: + """解析 unified diff 格式""" + files = {} + current_file = None + current_content = [] + + for line in diff.split("\n"): + if line.startswith("diff --git"): + # 新文件开始 + if current_file and current_content: + files[current_file] = "\n".join(current_content) + + # 提取文件路径 + parts = line.split() + if len(parts) >= 4: + current_file = parts[3].lstrip("b/") + else: + current_file = parts[2].lstrip("b/") + current_content = [] + + elif line.startswith("@@") or line.startswith("---") or line.startswith("+++"): + continue + elif current_file: + current_content.append(line) + + # 保存最后一个文件 + if current_file and current_content: + files[current_file] = "\n".join(current_content) + + return files + + async def _audit_security( + self, file_path: str, file_changes: str, full_content: Optional[str] + ) -> List[AuditIssue]: + """安全审计""" + issues = [] + content = full_content or file_changes + + # 检查常见安全问题 + for vuln_type, patterns in self.SECURITY_PATTERNS.items(): + for pattern in patterns: + matches = re.finditer(pattern, content, re.IGNORECASE | re.MULTILINE) + for match in matches: + line_num = content[:match.start()].count("\n") + 1 + + # 计算扣分 + if vuln_type in ["sql_injection", "xss", "command_injection"]: + severity = AuditSeverity.CRITICAL + penalty = 20.0 + elif vuln_type == "hardcoded_secrets": + severity = AuditSeverity.HIGH + penalty = 10.0 + elif vuln_type == "path_traversal": + severity = AuditSeverity.HIGH + penalty = 15.0 + else: + severity = AuditSeverity.MEDIUM + penalty = 5.0 + + issues.append( + AuditIssue( + severity=severity, + category="security", + file_path=file_path, + line_number=line_num, + message=f"潜在{vuln_type}漏洞", + suggestion=f"使用参数化查询/输入验证来避免{vuln_type}", + score_penalty=penalty, + ) + ) + + return issues + + def _audit_performance( + self, file_path: str, file_changes: str, full_content: Optional[str] + ) -> List[AuditIssue]: + """性能审计""" + issues = [] + content = full_content or file_changes + + # 检查 N+1 问题 + for pattern in self.PERFORMANCE_PATTERNS.get("n_plus_1", []): + matches = re.finditer(pattern, content, re.MULTILINE) + for match in matches: + line_num = content[:match.start()].count("\n") + 1 + issues.append( + AuditIssue( + severity=AuditSeverity.MEDIUM, + category="performance", + file_path=file_path, + line_number=line_num, + message="可能的 N+1 查询问题", + suggestion="考虑使用 bulk_create 或 select_related 优化", + score_penalty=5.0, + ) + ) + + # 检查低效字符串拼接 + for pattern in self.PERFORMANCE_PATTERNS.get("inefficient_string_concat", []): + matches = re.finditer(pattern, content) + for match in matches: + line_num = content[:match.start()].count("\n") + 1 + issues.append( + AuditIssue( + severity=AuditSeverity.LOW, + category="performance", + file_path=file_path, + line_number=line_num, + message="低效的字符串拼接", + suggestion="使用 f-string 或 join()", + score_penalty=2.0, + ) + ) + + # 检查文件大小 + if full_content: + line_count = len(full_content.split("\n")) + if line_count > 500: + issues.append( + AuditIssue( + severity=AuditSeverity.MEDIUM, + category="performance", + file_path=file_path, + message=f"文件过大 ({line_count} 行)", + suggestion="考虑拆分为更小的模块", + score_penalty=5.0, + ) + ) + + return issues + + def _audit_style( + self, file_path: str, file_changes: str, full_content: Optional[str] + ) -> List[AuditIssue]: + """代码风格审计""" + issues = [] + content = full_content or file_changes + + # 检查行长度 + max_length = self.STYLE_PATTERNS["line_too_long"] + for i, line in enumerate(content.split("\n"), 1): + if len(line) > max_length: + issues.append( + AuditIssue( + severity=AuditSeverity.INFO, + category="style", + file_path=file_path, + line_number=i, + message=f"行过长 ({len(line)} > {max_length})", + suggestion="拆分为多行", + score_penalty=1.0, + ) + ) + + return issues + + def _detect_language(self, file_path: str) -> str: + """检测文件语言""" + ext = file_path.rsplit(".", 1)[-1].lower() + lang_map = { + "py": "python", + "ts": "typescript", + "tsx": "typescript", + "js": "javascript", + "jsx": "javascript", + "vue": "vue", + "sql": "sql", + } + return lang_map.get(ext, "text") + diff --git a/backend/app/services/auditor_core/mixin_pipeline.py b/backend/app/services/auditor_core/mixin_pipeline.py new file mode 100644 index 00000000..784a0c2c --- /dev/null +++ b/backend/app/services/auditor_core/mixin_pipeline.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from .shared import * + +class CodeAuditorPipelineMixin: + def __init__(self, llm_service): + self.llm = llm_service + + async def audit_code_change( + self, + task_name: str, + commit_message: str, + code_diff: str, + signal_id: Optional[str] = None, + commit_sha: Optional[str] = None, + file_contexts: Optional[Dict[str, str]] = None, + ) -> AuditResult: + """ + 审计代码变更 + + Args: + task_name: 任务名称 + commit_message: 提交消息 + code_diff: 代码 diff + signal_id: 关联的信号 ID + commit_sha: 提交 SHA + file_contexts: 文件上下文(完整文件内容) + + Returns: + 审计结果 + """ + issues = [] + + try: + # 解析 diff 获取变更的文件 + changed_files = self._parse_diff(code_diff) + + # 对每个变更的文件进行审计 + for file_path, file_changes in changed_files.items(): + # 获取文件内容(如果提供了上下文) + full_content = file_contexts.get(file_path) if file_contexts else None + + # 安全审计 + security_issues = await self._audit_security( + file_path, file_changes, full_content + ) + issues.extend(security_issues) + + # 性能审计 + perf_issues = self._audit_performance( + file_path, file_changes, full_content + ) + issues.extend(perf_issues) + + # 代码风格审计 + style_issues = self._audit_style( + file_path, file_changes, full_content + ) + issues.extend(style_issues) + + # 功能正确性审计(使用 AI) + functional_issues = await self._audit_functionality( + task_name, file_path, file_changes, full_content + ) + issues.extend(functional_issues) + + # AI 深度审计 + ai_issues = await self._ai_deep_audit( + task_name, commit_message, code_diff, changed_files + ) + issues.extend(ai_issues) + + # 计算总分 + score = self._calculate_score(issues) + + # 生成摘要 + summary = self._generate_summary(issues, score) + + # 判断是否通过(严格模式:98分) + passed = score >= 98.0 and not any( + i.severity == AuditSeverity.CRITICAL for i in issues + ) + + return AuditResult( + signal_id=signal_id, + commit_sha=commit_sha, + task_name=task_name, + passed=passed, + score=score, + issues=issues, + summary=summary, + details={ + "changed_files_count": len(changed_files), + "issues_by_severity": self._count_issues_by_severity(issues), + "issues_by_category": self._count_issues_by_category(issues), + "changed_files": list(changed_files.keys()), + }, + ) + + except Exception as e: + logger.error(f"Audit failed: {e}", exc_info=True) + return AuditResult( + signal_id=signal_id, + commit_sha=commit_sha, + task_name=task_name, + passed=False, + score=0.0, + issues=[ + AuditIssue( + severity=AuditSeverity.CRITICAL, + category="system", + file_path="unknown", + message=f"审计过程出错: {str(e)}", + score_penalty=100.0, + ) + ], + summary=f"审计失败: {str(e)}", + ) + diff --git a/backend/app/services/auditor_core/service.py b/backend/app/services/auditor_core/service.py new file mode 100644 index 00000000..d65fd572 --- /dev/null +++ b/backend/app/services/auditor_core/service.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from .mixin_ai_score import CodeAuditorAIScoreMixin +from .mixin_checks import CodeAuditorChecksMixin +from .mixin_pipeline import CodeAuditorPipelineMixin +from .shared import AuditIssue, AuditResult, AuditSeverity, SecurityIssue + + +class CodeAuditor( + CodeAuditorPipelineMixin, + CodeAuditorChecksMixin, + CodeAuditorAIScoreMixin, +): + """ + 增强版代码审计器 + + 特性: + - 安全漏洞扫描 + - 代码质量评估 + - 性能问题检测 + - 最佳实践检查 + - 严格评分机制(98+ 分阈值) + """ + + # 审计权重 + WEIGHTS = { + "security": 30, # 安全性最重要 + "functionality": 25, # 功能正确性 + "performance": 15, # 性能优化 + "style": 10, # 代码风格 + "documentation": 10, # 文档完整性 + "testing": 10, # 测试覆盖 + } + + # 安全模式库 + SECURITY_PATTERNS = { + "sql_injection": [ + r'execute\s*\(\s*["\'].*?\+.*?["\']', # 字符串拼接 SQL + r'cursor\.execute\s*\(\s*["\'].*?%s.*?["\']', # %s 格式化 + r'\$\{.*\}.*?(?:SELECT|INSERT|UPDATE|DELETE)', # 模板字符串 SQL + ], + "xss": [ + r'innerHTML\s*=\s*.*?\+.*?(?:request|input|form)', + r'eval\s*\(', + r'document\.write\s*\(', + ], + "hardcoded_secrets": [ + r'(?:password|secret|api_key|token)\s*=\s*["\'][^"\']+["\']', + r'Bearer\s+[A-Za-z0-9]{20,}', + ], + "command_injection": [ + r'subprocess\.call\s*\(\s*["\'].*?\+.*?["\']', + r'os\.system\s*\(\s*["\'].*?\+.*?["\']', + ], + "path_traversal": [ + r'open\s*\(\s*["\'].*?\.\.\/.*?["\']', + r'Path\s*\(\s*["\'].*?\.\.\/.*?["\']', + ], + } + + # 性能问题模式 + PERFORMANCE_PATTERNS = { + "n_plus_1": [ + r'for\s+\w+\s+in\s+range\s*\(\s*len\s*\(', + r'\[[^\]]+\]\s*\.append\s*\(.*?\)\s*for\s+\w+\s+in\s+', + ], + "inefficient_string_concat": [ + r'["\'][^"\']+["\']\s*\+.*?\+', + ], + "unused_import": [], + "large_file": None, # 文件行数检查 + } + + # 代码风格检查 + STYLE_PATTERNS = { + "line_too_long": 100, # 最大行长度 + "function_too_long": 50, # 最大函数行数 + "class_too_long": 200, # 最大类行数 + } + + +def get_auditor(llm_service) -> CodeAuditor: + """获取代码审计器实例""" + global _auditor + if _auditor is None: + _auditor = CodeAuditor(llm_service) + return _auditor + +__all__ = [ + "AuditIssue", + "AuditResult", + "AuditSeverity", + "CodeAuditor", + "SecurityIssue", + "get_auditor", +] diff --git a/backend/app/services/auditor_core/shared.py b/backend/app/services/auditor_core/shared.py new file mode 100644 index 00000000..2b207822 --- /dev/null +++ b/backend/app/services/auditor_core/shared.py @@ -0,0 +1,62 @@ +""" +Neural Sync - Enhanced Code Auditor Service + +增强版代码审计服务 - 支持严格模式的自动合并 +""" + +import asyncio +import re +import logging +from typing import Dict, List, Optional, Any, Tuple +from pydantic import BaseModel, Field +from datetime import datetime +from enum import Enum + +from ...utils.timezone import now_cn_aware +logger = logging.getLogger(__name__) + + +class AuditSeverity(str, Enum): + """审计问题严重程度""" + CRITICAL = "critical" # 阻止合并 + HIGH = "high" # 大幅扣分 + MEDIUM = "medium" # 中度扣分 + LOW = "low" # 轻微扣分 + INFO = "info" # 仅提示 + + +class AuditIssue(BaseModel): + """审计问题""" + severity: AuditSeverity + category: str # security, performance, style, logic, etc. + file_path: str + line_number: Optional[int] = None + message: str + suggestion: Optional[str] = None + score_penalty: float = 0.0 + + +class AuditResult(BaseModel): + """审计结果""" + signal_id: Optional[str] = None + commit_sha: Optional[str] = None + task_name: Optional[str] = None + passed: bool + score: float = Field(default=0.0, ge=0.0, le=100.0) + issues: List[AuditIssue] = Field(default_factory=list) + summary: str = "" + details: Dict[str, Any] = Field(default_factory=dict) + timestamp: float = Field(default_factory=lambda: now_cn_aware().timestamp()) + + +class SecurityIssue(BaseModel): + """安全问题""" + type: str # sql_injection, xss, csrf, etc. + severity: AuditSeverity + file_path: str + line_number: int + code_snippet: str + description: str + fix_suggestion: str + + diff --git a/backend/app/services/auto_merger.py b/backend/app/services/auto_merger.py index df610894..bf51777b 100644 --- a/backend/app/services/auto_merger.py +++ b/backend/app/services/auto_merger.py @@ -1,524 +1,19 @@ -""" -Neural Sync - Auto Merge Service - -自动合并服务 - 严格模式 -- 审计评分 >= 98 分 -- 全量测试通过 -- 二次人工确认 -- 熔断机制 -""" - -import asyncio -import logging -from typing import Optional, Dict, List, Any, Tuple -from pydantic import BaseModel, Field -from datetime import datetime, timedelta -from enum import Enum -from ..events import Event, EventType - -from ..utils.timezone import now_cn_aware, now_cn_naive -logger = logging.getLogger(__name__) - - -class MergeStatus(str, Enum): - """合并状态""" - PENDING = "pending" - WAITING_CONFIRMATION = "waiting_confirmation" - APPROVED = "approved" - REJECTED = "rejected" - FAILED = "failed" - COOLDOWN = "cooldown" - - -class MergeRequest(BaseModel): - """合并请求""" - signal_id: str - commit_sha: str - task_name: str - audit_score: float = Field(ge=0.0, le=100.0) - test_results: Dict[str, Any] - requester: Optional[str] = None - - -class MergeResponse(BaseModel): - """合并响应""" - status: MergeStatus - success: bool - merge_sha: Optional[str] = None - reason: Optional[str] = None - requires_human_confirmation: bool = False - confirmation_token: Optional[str] = None - cooldown_remaining: Optional[float] = None - details: Dict[str, Any] = Field(default_factory=dict) - - -class ConfirmationRequest(BaseModel): - """人工确认请求""" - token: str - merge_request: MergeRequest - created_at: float - expires_at: float - - -class AutoMergeService: - """ - 自动合并服务 - 严格模式 - - 条件: - 1. 审计评分 >= 98 分 - 2. 全量测试通过 - 3. 二次人工确认 - 4. 不在熔断期内 - - 特性: - - 熔断机制(连续失败后暂停) - - 人工确认流程 - - 回滚支持 - - 事件通知 - """ - - # 严格模式阈值 - AUDIT_SCORE_THRESHOLD = 98.0 - HUMAN_CONFIRMATION_REQUIRED = True - CONFIRMATION_TIMEOUT = 300 # 5 minutes - - # 熔断机制 - MAX_CONSECUTIVE_FAILURES = 3 - FAILURE_COOLDOWN_SECONDS = 3600 # 1 hour - - def __init__(self, git_service=None, plane_service=None, lark_service=None, event_bus=None): - self.git = git_service - self.plane = plane_service - self.lark = lark_service - self.event_bus = event_bus - - # 状态跟踪 - self._failure_count = 0 - self._last_failure_time: Optional[datetime] = None - self._successful_merges = 0 - - # 待确认的请求 - self._pending_confirmations: Dict[str, ConfirmationRequest] = {} - - async def try_auto_merge(self, request: MergeRequest) -> MergeResponse: - """ - 尝试自动合并 - """ - # 1. 检查熔断状态 - if self._is_in_cooldown(): - return MergeResponse( - status=MergeStatus.COOLDOWN, - success=False, - reason=f"系统处于熔断期,请在 {self._get_cooldown_remaining()}s 后重试", - requires_human_confirmation=True, - cooldown_remaining=self._get_cooldown_remaining(), - ) - - # 2. 验证审计评分 - if request.audit_score < self.AUDIT_SCORE_THRESHOLD: - return MergeResponse( - status=MergeStatus.REJECTED, - success=False, - reason=f"审计评分 {request.audit_score} 低于阈值 {self.AUDIT_SCORE_THRESHOLD}", - requires_human_confirmation=True, - ) - - # 3. 验证测试结果 - if not self._all_tests_passed(request.test_results): - return MergeResponse( - status=MergeStatus.REJECTED, - success=False, - reason="测试未全部通过", - requires_human_confirmation=True, - details={ - "test_results": request.test_results, - "failed_tests": self._get_failed_tests(request.test_results), - }, - ) - - # 4. 创建人工确认请求 - if self.HUMAN_CONFIRMATION_REQUIRED: - confirmation_token = self._generate_confirmation_token() - expires_at = now_cn_aware().timestamp() + self.CONFIRMATION_TIMEOUT - - confirmation = ConfirmationRequest( - token=confirmation_token, - merge_request=request, - created_at=now_cn_aware().timestamp(), - expires_at=expires_at, - ) - self._pending_confirmations[confirmation_token] = confirmation - - # 发送确认请求事件 - await self._notify_confirmation_request(confirmation) - - return MergeResponse( - status=MergeStatus.WAITING_CONFIRMATION, - success=False, - reason="等待人工二次确认", - requires_human_confirmation=True, - confirmation_token=confirmation_token, - details={ - "expires_at": expires_at, - "timeout_seconds": self.CONFIRMATION_TIMEOUT, - }, - ) - - # 5. 直接执行自动合并(不需要确认) - return await self._execute_merge(request) - - async def confirm_merge(self, confirmation_token: str, requester: str) -> MergeResponse: - """ - 人工确认合并 - """ - confirmation = self._pending_confirmations.get(confirmation_token) - if not confirmation: - return MergeResponse( - status=MergeStatus.REJECTED, - success=False, - reason="确认令牌无效或已过期", - ) - - # 检查是否过期 - if now_cn_aware().timestamp() > confirmation.expires_at: - del self._pending_confirmations[confirmation_token] - return MergeResponse( - status=MergeStatus.REJECTED, - success=False, - reason="确认请求已过期", - ) - - # 执行合并 - result = await self._execute_merge(confirmation.merge_request) - - # 清理确认请求 - if result.success: - del self._pending_confirmations[confirmation_token] - - return result - - async def reject_merge(self, confirmation_token: str, reason: str) -> MergeResponse: - """ - 拒绝合并 - """ - confirmation = self._pending_confirmations.get(confirmation_token) - if not confirmation: - return MergeResponse( - status=MergeStatus.REJECTED, - success=False, - reason="确认令牌无效", - ) - - # 清理确认请求 - del self._pending_confirmations[confirmation_token] - - return MergeResponse( - status=MergeStatus.REJECTED, - success=False, - reason=f"合并被拒绝: {reason}", - ) - - async def _execute_merge(self, request: MergeRequest) -> MergeResponse: - """ - 执行实际的合并操作 - """ - try: - # 1. 执行 Git 合并 - merge_sha = await self._perform_git_merge(request.commit_sha) - - # 2. 推送到远程 - await self._push_to_remote() - - # 3. 更新 Plane 任务状态 - if self.plane: - await self._update_plane_status(request.signal_id, merge_sha) - - # 4. 发送飞书通知 - if self.lark: - await self._send_lark_notification(request, merge_sha) - - # 5. 发送成功事件 - await self._publish_merge_success_event(request, merge_sha) - - # 重置失败计数 - self._reset_failure_count() - self._successful_merges += 1 - - return MergeResponse( - status=MergeStatus.APPROVED, - success=True, - merge_sha=merge_sha, - reason="自动合并成功", - details={ - "successful_merges": self._successful_merges, - }, - ) - - except Exception as e: - logger.error(f"Merge failed: {e}", exc_info=True) - self._record_failure() - - await self._publish_merge_failed_event(request, str(e)) - - return MergeResponse( - status=MergeStatus.FAILED, - success=False, - reason=f"合并失败: {str(e)}", - requires_human_confirmation=True, - ) - - async def _perform_git_merge(self, commit_sha: str) -> str: - """执行 Git 合并""" - # 如果有 git 服务,使用它;否则使用 subprocess - if self.git: - return await self.git.merge(commit_sha) - - # 使用 subprocess 执行 git 命令 - import subprocess - - # 合并提交(不创建合并提交,直接快进) - result = subprocess.run( - ["git", "merge", "--ff", commit_sha], - capture_output=True, - text=True, - timeout=30, - ) - - if result.returncode != 0: - # 尝试 cherry-pick - result = subprocess.run( - ["git", "cherry-pick", commit_sha], - capture_output=True, - text=True, - timeout=30, - ) - if result.returncode != 0: - raise Exception(f"Git merge/cherry-pick failed: {result.stderr}") - - # 获取合并后的 HEAD - result = subprocess.run( - ["git", "rev-parse", "HEAD"], - capture_output=True, - text=True, - check=True, - ) - return result.stdout.strip() - - async def _push_to_remote(self): - """推送到远程仓库""" - import subprocess - - result = subprocess.run( - ["git", "push"], - capture_output=True, - text=True, - timeout=60, - ) - - if result.returncode != 0: - raise Exception(f"Git push failed: {result.stderr}") - - async def _update_plane_status(self, signal_id: str, merge_sha: str): - """更新 Plane 任务状态""" - if not self.plane: - return - - try: - # 这里应该根据 signal_id 找到对应的 Plane issue - # 简化实现:假设任务 ID 已知 - await self.plane.update_issue_status( - signal_id, # 或提取的任务 ID - status="Done" - ) - except Exception as e: - logger.warning(f"Failed to update Plane status: {e}") - - async def _send_lark_notification(self, request: MergeRequest, merge_sha: str): - """发送飞书通知""" - if not self.lark: - return - - try: - await self.lark.send_merge_notification( - task_name=request.task_name, - merge_sha=merge_sha, - audit_score=request.audit_score, - ) - except Exception as e: - logger.warning(f"Failed to send Lark notification: {e}") - - async def _notify_confirmation_request(self, confirmation: ConfirmationRequest): - """通知确认请求""" - if self.event_bus: - from .events import EventType - await self.event_bus.publish( - Event( - type=EventType.AUTO_MERGE_REQUESTED, - data={ - "token": confirmation.token, - "task_name": confirmation.merge_request.task_name, - "commit_sha": confirmation.merge_request.commit_sha, - "audit_score": confirmation.merge_request.audit_score, - "expires_at": confirmation.expires_at, - }, - ) - ) - - async def _publish_merge_success_event(self, request: MergeRequest, merge_sha: str): - """发布合并成功事件""" - if self.event_bus: - from .events import EventType - await self.event_bus.publish( - Event( - type=EventType.AUTO_MERGE_SUCCESS, - data={ - "signal_id": request.signal_id, - "commit_sha": request.commit_sha, - "merge_sha": merge_sha, - "task_name": request.task_name, - "audit_score": request.audit_score, - }, - ) - ) - - async def _publish_merge_failed_event(self, request: MergeRequest, error: str): - """发布合并失败事件""" - if self.event_bus: - from .events import EventType - await self.event_bus.publish( - Event( - type=EventType.AUTO_MERGE_FAILED, - data={ - "signal_id": request.signal_id, - "commit_sha": request.commit_sha, - "task_name": request.task_name, - "error": error, - }, - ) - ) - - def _is_in_cooldown(self) -> bool: - """检查是否在熔断期""" - if self._last_failure_time is None: - return False - - elapsed = (now_cn_naive() - self._last_failure_time).total_seconds() - return elapsed < self.FAILURE_COOLDOWN_SECONDS - - def _get_cooldown_remaining(self) -> float: - """获取熔断剩余时间""" - if self._last_failure_time is None: - return 0.0 - - elapsed = (now_cn_naive() - self._last_failure_time).total_seconds() - return max(0, self.FAILURE_COOLDOWN_SECONDS - elapsed) - - def _all_tests_passed(self, test_results: Dict[str, Any]) -> bool: - """检查所有测试是否通过""" - if not test_results: - return True # 没有测试结果视为通过 - - # 检查是否有失败的测试 - for test_name, result in test_results.items(): - if isinstance(result, dict): - if result.get("status") == "failed": - return False - if result.get("passed") is False: - return False - elif isinstance(result, bool): - if not result: - return False - - return True - - def _get_failed_tests(self, test_results: Dict[str, Any]) -> List[str]: - """获取失败的测试列表""" - failed = [] - for test_name, result in test_results.items(): - if isinstance(result, dict) and result.get("status") == "failed": - failed.append(test_name) - elif isinstance(result, bool) and not result: - failed.append(test_name) - return failed - - def _generate_confirmation_token(self) -> str: - """生成确认令牌""" - import uuid - return str(uuid.uuid4()) - - def _record_failure(self): - """记录失败""" - self._failure_count += 1 - self._last_failure_time = now_cn_naive() - - # 检查是否需要进入熔断期 - if self._failure_count >= self.MAX_CONSECUTIVE_FAILURES: - logger.warning( - f"[AutoMerge] {self._failure_count} consecutive failures. " - f"Entering cooldown for {self.FAILURE_COOLDOWN_SECONDS}s" - ) - if self.event_bus: - import asyncio - asyncio.create_task( - self.event_bus.publish( - Event( - type=EventType.AUTO_MERGE_COOLDOWN, - data={ - "failure_count": self._failure_count, - "cooldown_seconds": self.FAILURE_COOLDOWN_SECONDS, - }, - ) - ) - ) - - def _reset_failure_count(self): - """重置失败计数""" - self._failure_count = 0 - self._last_failure_time = None - - async def get_pending_confirmations(self) -> List[ConfirmationRequest]: - """获取待确认的请求""" - # 清理过期的确认请求 - now = now_cn_aware().timestamp() - expired = [ - token for token, conf in self._pending_confirmations.items() - if now > conf.expires_at - ] - for token in expired: - del self._pending_confirmations[token] - - return list(self._pending_confirmations.values()) - - def get_status(self) -> Dict[str, Any]: - """获取服务状态""" - return { - "audit_threshold": self.AUDIT_SCORE_THRESHOLD, - "human_confirmation_required": self.HUMAN_CONFIRMATION_REQUIRED, - "failure_count": self._failure_count, - "successful_merges": self._successful_merges, - "in_cooldown": self._is_in_cooldown(), - "cooldown_remaining": self._get_cooldown_remaining() if self._is_in_cooldown() else 0, - "pending_confirmations": len(self._pending_confirmations), - } - - -# 全局服务实例 -_auto_merger = None - - -def get_auto_merger( - git_service=None, - plane_service=None, - lark_service=None, - event_bus=None, -) -> AutoMergeService: - """获取自动合并服务实例""" - global _auto_merger - if _auto_merger is None: - _auto_merger = AutoMergeService( - git_service=git_service, - plane_service=plane_service, - lark_service=lark_service, - event_bus=event_bus, - ) - return _auto_merger +"""Compatibility exports for auto merge service.""" + +from .auto_merger_core import ( + AutoMergeService, + ConfirmationRequest, + MergeRequest, + MergeResponse, + MergeStatus, + get_auto_merger, +) + +__all__ = [ + "MergeStatus", + "MergeRequest", + "MergeResponse", + "ConfirmationRequest", + "AutoMergeService", + "get_auto_merger", +] diff --git a/backend/app/services/auto_merger_core/__init__.py b/backend/app/services/auto_merger_core/__init__.py new file mode 100644 index 00000000..5bfc4e0e --- /dev/null +++ b/backend/app/services/auto_merger_core/__init__.py @@ -0,0 +1,14 @@ +"""Auto merge service exports.""" + +from .registry import get_auto_merger +from .schemas import ConfirmationRequest, MergeRequest, MergeResponse, MergeStatus +from .service import AutoMergeService + +__all__ = [ + "MergeStatus", + "MergeRequest", + "MergeResponse", + "ConfirmationRequest", + "AutoMergeService", + "get_auto_merger", +] diff --git a/backend/app/services/auto_merger_core/mixin_entry.py b/backend/app/services/auto_merger_core/mixin_entry.py new file mode 100644 index 00000000..b61a5dad --- /dev/null +++ b/backend/app/services/auto_merger_core/mixin_entry.py @@ -0,0 +1,111 @@ +"""Entry flow for auto merge service.""" + +from __future__ import annotations + +from .schemas import ConfirmationRequest, MergeRequest, MergeResponse, MergeStatus +from .shared import Dict, now_cn_aware + + +class AutoMergerEntryMixin: + """Entry APIs: try/confirm/reject merge.""" + + async def try_auto_merge(self, request: MergeRequest) -> MergeResponse: + """Try auto merge with strict checks and optional confirmation.""" + if self._is_in_cooldown(): + return MergeResponse( + status=MergeStatus.COOLDOWN, + success=False, + reason=f"系统处于熔断期,请在 {self._get_cooldown_remaining()}s 后重试", + requires_human_confirmation=True, + cooldown_remaining=self._get_cooldown_remaining(), + ) + + if request.audit_score < self.AUDIT_SCORE_THRESHOLD: + return MergeResponse( + status=MergeStatus.REJECTED, + success=False, + reason=f"审计评分 {request.audit_score} 低于阈值 {self.AUDIT_SCORE_THRESHOLD}", + requires_human_confirmation=True, + ) + + if not self._all_tests_passed(request.test_results): + return MergeResponse( + status=MergeStatus.REJECTED, + success=False, + reason="测试未全部通过", + requires_human_confirmation=True, + details={ + "test_results": request.test_results, + "failed_tests": self._get_failed_tests(request.test_results), + }, + ) + + if self.HUMAN_CONFIRMATION_REQUIRED: + confirmation_token = self._generate_confirmation_token() + expires_at = now_cn_aware().timestamp() + self.CONFIRMATION_TIMEOUT + + confirmation = ConfirmationRequest( + token=confirmation_token, + merge_request=request, + created_at=now_cn_aware().timestamp(), + expires_at=expires_at, + ) + self._pending_confirmations[confirmation_token] = confirmation + + await self._notify_confirmation_request(confirmation) + + return MergeResponse( + status=MergeStatus.WAITING_CONFIRMATION, + success=False, + reason="等待人工二次确认", + requires_human_confirmation=True, + confirmation_token=confirmation_token, + details={ + "expires_at": expires_at, + "timeout_seconds": self.CONFIRMATION_TIMEOUT, + }, + ) + + return await self._execute_merge(request) + + async def confirm_merge(self, confirmation_token: str, requester: str) -> MergeResponse: + """Approve a pending confirmation and execute merge.""" + confirmation = self._pending_confirmations.get(confirmation_token) + if not confirmation: + return MergeResponse( + status=MergeStatus.REJECTED, + success=False, + reason="确认令牌无效或已过期", + ) + + if now_cn_aware().timestamp() > confirmation.expires_at: + del self._pending_confirmations[confirmation_token] + return MergeResponse( + status=MergeStatus.REJECTED, + success=False, + reason="确认请求已过期", + ) + + result = await self._execute_merge(confirmation.merge_request) + if result.success: + del self._pending_confirmations[confirmation_token] + + return result + + async def reject_merge(self, confirmation_token: str, reason: str) -> MergeResponse: + """Reject a pending merge confirmation.""" + confirmation = self._pending_confirmations.get(confirmation_token) + if not confirmation: + return MergeResponse( + status=MergeStatus.REJECTED, + success=False, + reason="确认令牌无效", + ) + + del self._pending_confirmations[confirmation_token] + + return MergeResponse( + status=MergeStatus.REJECTED, + success=False, + reason=f"合并被拒绝: {reason}", + ) diff --git a/backend/app/services/auto_merger_core/mixin_events_guard.py b/backend/app/services/auto_merger_core/mixin_events_guard.py new file mode 100644 index 00000000..80c5f2f3 --- /dev/null +++ b/backend/app/services/auto_merger_core/mixin_events_guard.py @@ -0,0 +1,165 @@ +"""Event publishing, guard logic, and status helpers for auto merge service.""" + +from __future__ import annotations + +from .schemas import ConfirmationRequest, MergeRequest +from .shared import Any, Dict, Event, EventType, List, asyncio, logger, now_cn_aware, now_cn_naive + + +class AutoMergerEventsGuardMixin: + """Owns event notifications, cooldown checks, and status views.""" + + async def _notify_confirmation_request(self, confirmation: ConfirmationRequest): + """Publish merge confirmation request event.""" + if self.event_bus: + from ..events import EventType + + await self.event_bus.publish( + Event( + type=EventType.AUTO_MERGE_REQUESTED, + data={ + "token": confirmation.token, + "task_name": confirmation.merge_request.task_name, + "commit_sha": confirmation.merge_request.commit_sha, + "audit_score": confirmation.merge_request.audit_score, + "expires_at": confirmation.expires_at, + }, + ) + ) + + async def _publish_merge_success_event(self, request: MergeRequest, merge_sha: str): + """Publish successful merge event.""" + if self.event_bus: + from ..events import EventType + + await self.event_bus.publish( + Event( + type=EventType.AUTO_MERGE_SUCCESS, + data={ + "signal_id": request.signal_id, + "commit_sha": request.commit_sha, + "merge_sha": merge_sha, + "task_name": request.task_name, + "audit_score": request.audit_score, + }, + ) + ) + + async def _publish_merge_failed_event(self, request: MergeRequest, error: str): + """Publish failed merge event.""" + if self.event_bus: + from ..events import EventType + + await self.event_bus.publish( + Event( + type=EventType.AUTO_MERGE_FAILED, + data={ + "signal_id": request.signal_id, + "commit_sha": request.commit_sha, + "task_name": request.task_name, + "error": error, + }, + ) + ) + + def _is_in_cooldown(self) -> bool: + """Check whether service is in cooldown period.""" + if self._last_failure_time is None: + return False + + elapsed = (now_cn_naive() - self._last_failure_time).total_seconds() + return elapsed < self.FAILURE_COOLDOWN_SECONDS + + def _get_cooldown_remaining(self) -> float: + """Get remaining cooldown seconds.""" + if self._last_failure_time is None: + return 0.0 + + elapsed = (now_cn_naive() - self._last_failure_time).total_seconds() + return max(0, self.FAILURE_COOLDOWN_SECONDS - elapsed) + + def _all_tests_passed(self, test_results: Dict[str, Any]) -> bool: + """Check if all test results are passing.""" + if not test_results: + return True + + for _, result in test_results.items(): + if isinstance(result, dict): + if result.get("status") == "failed": + return False + if result.get("passed") is False: + return False + elif isinstance(result, bool): + if not result: + return False + + return True + + def _get_failed_tests(self, test_results: Dict[str, Any]) -> List[str]: + """Collect failed test names.""" + failed: List[str] = [] + for test_name, result in test_results.items(): + if isinstance(result, dict) and result.get("status") == "failed": + failed.append(test_name) + elif isinstance(result, bool) and not result: + failed.append(test_name) + return failed + + def _generate_confirmation_token(self) -> str: + """Generate unique confirmation token.""" + import uuid + + return str(uuid.uuid4()) + + def _record_failure(self): + """Record merge failure and optionally trigger cooldown event.""" + self._failure_count += 1 + self._last_failure_time = now_cn_naive() + + if self._failure_count >= self.MAX_CONSECUTIVE_FAILURES: + logger.warning( + f"[AutoMerge] {self._failure_count} consecutive failures. " + f"Entering cooldown for {self.FAILURE_COOLDOWN_SECONDS}s" + ) + if self.event_bus: + asyncio.create_task( + self.event_bus.publish( + Event( + type=EventType.AUTO_MERGE_COOLDOWN, + data={ + "failure_count": self._failure_count, + "cooldown_seconds": self.FAILURE_COOLDOWN_SECONDS, + }, + ) + ) + ) + + def _reset_failure_count(self): + """Reset failure counters after successful merge.""" + self._failure_count = 0 + self._last_failure_time = None + + async def get_pending_confirmations(self) -> List[ConfirmationRequest]: + """Get unexpired pending confirmations.""" + now = now_cn_aware().timestamp() + expired = [ + token + for token, conf in self._pending_confirmations.items() + if now > conf.expires_at + ] + for token in expired: + del self._pending_confirmations[token] + + return list(self._pending_confirmations.values()) + + def get_status(self) -> Dict[str, Any]: + """Get auto merge service status snapshot.""" + return { + "audit_threshold": self.AUDIT_SCORE_THRESHOLD, + "human_confirmation_required": self.HUMAN_CONFIRMATION_REQUIRED, + "failure_count": self._failure_count, + "successful_merges": self._successful_merges, + "in_cooldown": self._is_in_cooldown(), + "cooldown_remaining": self._get_cooldown_remaining() if self._is_in_cooldown() else 0, + "pending_confirmations": len(self._pending_confirmations), + } diff --git a/backend/app/services/auto_merger_core/mixin_execute.py b/backend/app/services/auto_merger_core/mixin_execute.py new file mode 100644 index 00000000..4e8b6e11 --- /dev/null +++ b/backend/app/services/auto_merger_core/mixin_execute.py @@ -0,0 +1,122 @@ +"""Merge execution and integration side-effects for auto merge service.""" + +from __future__ import annotations + +from .schemas import MergeRequest, MergeResponse, MergeStatus +from .shared import logger + + +class AutoMergerExecuteMixin: + """Executes merge/push and integration notifications.""" + + async def _execute_merge(self, request: MergeRequest) -> MergeResponse: + """Execute actual merge operation.""" + try: + merge_sha = await self._perform_git_merge(request.commit_sha) + await self._push_to_remote() + + if self.plane: + await self._update_plane_status(request.signal_id, merge_sha) + if self.lark: + await self._send_lark_notification(request, merge_sha) + + await self._publish_merge_success_event(request, merge_sha) + + self._reset_failure_count() + self._successful_merges += 1 + + return MergeResponse( + status=MergeStatus.APPROVED, + success=True, + merge_sha=merge_sha, + reason="自动合并成功", + details={ + "successful_merges": self._successful_merges, + }, + ) + + except Exception as exc: + logger.error(f"Merge failed: {exc}", exc_info=True) + self._record_failure() + + await self._publish_merge_failed_event(request, str(exc)) + + return MergeResponse( + status=MergeStatus.FAILED, + success=False, + reason=f"合并失败: {str(exc)}", + requires_human_confirmation=True, + ) + + async def _perform_git_merge(self, commit_sha: str) -> str: + """Run git merge/cherry-pick and return resulting HEAD.""" + if self.git: + return await self.git.merge(commit_sha) + + import subprocess + + result = subprocess.run( + ["git", "merge", "--ff", commit_sha], + capture_output=True, + text=True, + timeout=30, + ) + + if result.returncode != 0: + result = subprocess.run( + ["git", "cherry-pick", commit_sha], + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + raise Exception(f"Git merge/cherry-pick failed: {result.stderr}") + + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + + async def _push_to_remote(self): + """Push current branch to remote.""" + import subprocess + + result = subprocess.run( + ["git", "push"], + capture_output=True, + text=True, + timeout=60, + ) + + if result.returncode != 0: + raise Exception(f"Git push failed: {result.stderr}") + + async def _update_plane_status(self, signal_id: str, merge_sha: str): + """Update Plane issue status after merge.""" + if not self.plane: + return + + try: + await self.plane.update_issue_status( + signal_id, + status="Done", + ) + except Exception as exc: + logger.warning(f"Failed to update Plane status: {exc}") + + async def _send_lark_notification(self, request: MergeRequest, merge_sha: str): + """Send Lark notification for merge result.""" + if not self.lark: + return + + try: + await self.lark.send_merge_notification( + task_name=request.task_name, + merge_sha=merge_sha, + audit_score=request.audit_score, + ) + except Exception as exc: + logger.warning(f"Failed to send Lark notification: {exc}") diff --git a/backend/app/services/auto_merger_core/registry.py b/backend/app/services/auto_merger_core/registry.py new file mode 100644 index 00000000..bcce2d9d --- /dev/null +++ b/backend/app/services/auto_merger_core/registry.py @@ -0,0 +1,25 @@ +"""Singleton registry for auto merge service.""" + +from __future__ import annotations + +from .service import AutoMergeService + +_auto_merger = None + + +def get_auto_merger( + git_service=None, + plane_service=None, + lark_service=None, + event_bus=None, +) -> AutoMergeService: + """Get auto merge service singleton.""" + global _auto_merger + if _auto_merger is None: + _auto_merger = AutoMergeService( + git_service=git_service, + plane_service=plane_service, + lark_service=lark_service, + event_bus=event_bus, + ) + return _auto_merger diff --git a/backend/app/services/auto_merger_core/schemas.py b/backend/app/services/auto_merger_core/schemas.py new file mode 100644 index 00000000..96f0d3cf --- /dev/null +++ b/backend/app/services/auto_merger_core/schemas.py @@ -0,0 +1,57 @@ +"""Data models for auto merge service.""" + +from __future__ import annotations + +from .shared import Any, BaseModel, Dict, Enum, Field, List, Optional + + +class MergeStatus(str, Enum): + """Merge status enum.""" + + PENDING = "pending" + WAITING_CONFIRMATION = "waiting_confirmation" + APPROVED = "approved" + REJECTED = "rejected" + FAILED = "failed" + COOLDOWN = "cooldown" + + +class MergeRequest(BaseModel): + """Merge request payload.""" + + signal_id: str + commit_sha: str + task_name: str + audit_score: float = Field(ge=0.0, le=100.0) + test_results: Dict[str, Any] + requester: Optional[str] = None + + +class MergeResponse(BaseModel): + """Merge execution response.""" + + status: MergeStatus + success: bool + merge_sha: Optional[str] = None + reason: Optional[str] = None + requires_human_confirmation: bool = False + confirmation_token: Optional[str] = None + cooldown_remaining: Optional[float] = None + details: Dict[str, Any] = Field(default_factory=dict) + + +class ConfirmationRequest(BaseModel): + """Human confirmation request.""" + + token: str + merge_request: MergeRequest + created_at: float + expires_at: float + + +__all__ = [ + "MergeStatus", + "MergeRequest", + "MergeResponse", + "ConfirmationRequest", +] diff --git a/backend/app/services/auto_merger_core/service.py b/backend/app/services/auto_merger_core/service.py new file mode 100644 index 00000000..67084fdb --- /dev/null +++ b/backend/app/services/auto_merger_core/service.py @@ -0,0 +1,35 @@ +"""Auto merge service composition.""" + +from __future__ import annotations + +from .mixin_entry import AutoMergerEntryMixin +from .mixin_events_guard import AutoMergerEventsGuardMixin +from .mixin_execute import AutoMergerExecuteMixin +from .schemas import ConfirmationRequest +from .shared import Dict, Optional, datetime + + +class AutoMergeService( + AutoMergerEntryMixin, + AutoMergerExecuteMixin, + AutoMergerEventsGuardMixin, +): + """Strict auto merge workflow with cooldown and confirmation.""" + + AUDIT_SCORE_THRESHOLD = 98.0 + HUMAN_CONFIRMATION_REQUIRED = True + CONFIRMATION_TIMEOUT = 300 + + MAX_CONSECUTIVE_FAILURES = 3 + FAILURE_COOLDOWN_SECONDS = 3600 + + def __init__(self, git_service=None, plane_service=None, lark_service=None, event_bus=None): + self.git = git_service + self.plane = plane_service + self.lark = lark_service + self.event_bus = event_bus + + self._failure_count = 0 + self._last_failure_time: Optional[datetime] = None + self._successful_merges = 0 + self._pending_confirmations: Dict[str, ConfirmationRequest] = {} diff --git a/backend/app/services/auto_merger_core/shared.py b/backend/app/services/auto_merger_core/shared.py new file mode 100644 index 00000000..90a5e955 --- /dev/null +++ b/backend/app/services/auto_merger_core/shared.py @@ -0,0 +1,35 @@ +"""Shared imports and logger for auto merge service.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timedelta +from enum import Enum +import logging +from typing import Any, Dict, List, Optional, Tuple + +from pydantic import BaseModel, Field + +from ...events import Event, EventType +from ...utils.timezone import now_cn_aware, now_cn_naive + +logger = logging.getLogger(__name__) + +__all__ = [ + "Any", + "BaseModel", + "Dict", + "Enum", + "Event", + "EventType", + "Field", + "List", + "Optional", + "Tuple", + "asyncio", + "datetime", + "logger", + "now_cn_aware", + "now_cn_naive", + "timedelta", +] diff --git a/backend/app/services/blocker_detection.py b/backend/app/services/blocker_detection.py index 3431b85a..8e63f1ef 100644 --- a/backend/app/services/blocker_detection.py +++ b/backend/app/services/blocker_detection.py @@ -1,424 +1,8 @@ -""" -Proactive Blocker Detection Service - 主动式阻塞检测服务 +"""Compatibility exports for blocker detection service.""" -核心功能: -1. 扫描代码注释中的阻塞关键词 -2. 自动识别常见的开发阻塞模式 -3. AI 生成解决方案建议 -4. 追踪阻塞解决进度 - -支持的阻塞模式: -- "等待 X 接口完成" -- "这个 API 达不到效果" -- "由于 Y 的限制,暂时跳过" -- "TODO: 等待 Z 确定" -""" -import asyncio -import json -import re -from datetime import datetime, timezone, timedelta -from typing import Any, Dict, List, Optional -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select -from sqlalchemy import text - -from ..models import ( - User, Project, Task, Signal, - CognitiveConflict, TruthNode +from .blocker_detection_core import ( + BlockerDetectionService, + get_blocker_detection_service, ) -from .llm import LLMService - -# 阻塞模式正则表达式 -from ..utils.timezone import now_cn_naive -BLOCKER_PATTERNS = [ - r'等待.*接口', - r'等待.*API', - r'等待.*确定', - r'等待.*完成', - r'blocked by', - r'blocked due to', - r'can\'t proceed until', - r'暂时跳过', - r'TODO.*等待', - r'FIXME.*等待', - r'HACK.*临时', - r'达不到效果', - r'无法.*因为', - r'pending.*approval', - r'waiting for', - r'blocked on', -] - - -class BlockerDetectionService: - """主动式阻塞检测服务""" - - def __init__(self, db: AsyncSession): - self.db = db - self.llm = LLMService() - - async def scan_code_comment( - self, - code_content: str, - file_path: str, - line_number: int, - project_id: int, - user_id: int, - tenant_id: int = 1 - ) -> Optional[Dict[str, Any]]: - """ - 扫描代码注释,检测阻塞 - - Args: - code_content: 代码内容 - file_path: 文件路径 - line_number: 行号 - project_id: 项目ID - user_id: 用户ID - tenant_id: 租户ID - - Returns: - 如果检测到阻塞,返回阻塞信息;否则返回 None - """ - # 检查是否匹配阻塞模式 - detected_pattern = None - for pattern in BLOCKER_PATTERNS: - if re.search(pattern, code_content, re.IGNORECASE): - detected_pattern = pattern - break - - if not detected_pattern: - return None - - # AI 分析阻塞严重程度和生成解决方案 - analysis = await self._analyze_blocker_with_ai( - code_content=code_content, - file_path=file_path, - pattern=detected_pattern, - tenant_id=tenant_id, - ) - - # 创建阻塞记录(复用 CognitiveConflict 模型) - from sqlalchemy import text - next_conflict_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM cognitive_conflicts"))).scalar() or 1 - - blocker = CognitiveConflict( - id=next_conflict_id, - truth_node_id=None, # 阻塞可能不关联特定宪章 - conflict_type='code_blocker', - description=f"代码阻塞检测:\n\n文件:{file_path}:{line_number}\n代码:{code_content[:200]}\n\nAI 分析:{analysis.get('problem_analysis', '')}", - involved_user_ids=json.dumps([user_id]), - status='detected', - tenant_id=tenant_id - ) - self.db.add(blocker) - await self.db.commit() - - # 自动生成任务建议 - suggested_tasks = await self._generate_blocker_resolution_tasks( - blocker_id=blocker.id, - analysis=analysis, - project_id=project_id, - tenant_id=tenant_id - ) - - return { - 'blocker_id': blocker.id, - 'severity': analysis.get('severity', 'medium'), - 'problem_analysis': analysis.get('problem_analysis', ''), - 'suggested_solutions': analysis.get('suggested_solutions', []), - 'suggested_tasks': suggested_tasks - } - - async def detect_task_blockers( - self, - tenant_id: int = 1 - ) -> List[Dict[str, Any]]: - """ - 检测所有任务中的阻塞 - - 扫描所有进行中的任务,检测是否存在阻塞 - """ - from sqlalchemy import text - - # 获取所有进行中的任务 - result = await self.db.execute( - text(""" - SELECT t.id, t.title, t.description, t.status, t.project_id - FROM tasks t - WHERE t.status IN ('pending', 'in_progress') - AND t.tenant_id = :tenant_id - AND (t.updated_at < datetime('now', '-3 days') - OR t.updated_at < datetime('now', '-3 days', 'localtime')) - """), - {"tenant_id": tenant_id} - ) - - tasks = result.fetchall() - - detected_blockers = [] - - for task in tasks: - task_id, title, description, status, project_id = task - - # AI 分析任务是否可能被阻塞 - analysis = await self._analyze_task_for_blockers( - task_id=task_id, - title=title, - description=description, - tenant_id=tenant_id, - ) - - if analysis.get('is_blocked', False): - # 创建阻塞记录 - next_conflict_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM cognitive_conflicts"))).scalar() or 1 - - blocker = CognitiveConflict( - id=next_conflict_id, - truth_node_id=None, - conflict_type='task_blocker', - description=f"任务阻塞检测:\n\n任务:{title}\nID: {task_id}\n\nAI 分析:{analysis.get('reason', '')}", - involved_user_ids=json.dumps([]), - status='detected', - tenant_id=tenant_id - ) - self.db.add(blocker) - await self.db.commit() - - detected_blockers.append({ - 'blocker_id': blocker.id, - 'task_id': task_id, - 'task_title': title, - 'severity': analysis.get('severity', 'medium'), - 'reason': analysis.get('reason', ''), - 'suggested_actions': analysis.get('suggested_actions', []) - }) - - return detected_blockers - - async def resolve_blocker( - self, - blocker_id: int, - resolution_note: str, - tenant_id: int = 1 - ) -> Dict[str, Any]: - """ - 标记阻塞为已解决 - - Args: - blocker_id: 阻塞ID - resolution_note: 解决方案说明 - tenant_id: 租户ID - """ - result = await self.db.execute( - select(CognitiveConflict).where( - CognitiveConflict.id == blocker_id, - CognitiveConflict.tenant_id == tenant_id - ) - ) - blocker = result.scalar_one_or_none() - - if not blocker: - raise ValueError(f"阻塞 {blocker_id} 不存在") - - blocker.status = 'resolved' - blocker.resolved_at = now_cn_naive() - blocker.description += f"\n\n解决方案:{resolution_note}" - - await self.db.commit() - - return { - 'blocker_id': blocker_id, - 'status': 'resolved', - 'resolved_at': blocker.resolved_at.isoformat() - } - - async def get_active_blockers( - self, - tenant_id: int = 1 - ) -> List[Dict[str, Any]]: - """ - 获取所有活跃的阻塞 - - Returns: - 阻塞列表,包含严重程度、描述等信息 - """ - result = await self.db.execute( - select(CognitiveConflict).where( - CognitiveConflict.tenant_id == tenant_id, - CognitiveConflict.conflict_type.in_(['code_blocker', 'task_blocker']), - CognitiveConflict.status == 'detected' - ) - ) - blockers = result.scalars().all() - - return [ - { - 'id': b.id, - 'type': b.conflict_type, - 'description': b.description, - 'severity': self._extract_severity(b.description), - 'detected_at': b.created_at.isoformat() if hasattr(b, 'created_at') else None, - 'involved_users': json.loads(b.involved_user_ids) if b.involved_user_ids else [] - } - for b in blockers - ] - - async def _analyze_blocker_with_ai( - self, - code_content: str, - file_path: str, - pattern: str, - tenant_id: int, - ) -> Dict[str, Any]: - """AI 分析代码阻塞""" - prompt = f"""分析以下代码注释中提到的阻塞问题: - -文件:{file_path} -匹配到的模式:{pattern} -代码内容: -{code_content} - -请分析: -1. 阻塞的根本原因是什么? -2. 严重程度(high/medium/low)? -3. 可能的解决方案有哪些? -4. 是否需要创建新任务来跟踪? - -以 JSON 格式返回: -{{ - "problem_analysis": "问题分析", - "severity": "high/medium/low", - "suggested_solutions": ["方案1", "方案2"], - "requires_new_task": boolean, - "suggested_task_title": "建议的任务标题" -}}""" - - try: - response = await self.llm.generate( - prompt, - db=self.db, - tenant_id=tenant_id, - ) - response = response.strip() - if response.startswith('```'): - response = response.split('```')[1] - if response.startswith('json'): - response = response[4:] - return json.loads(response.strip()) - except: - return { - 'problem_analysis': f'检测到可能的阻塞:{pattern}', - 'severity': 'medium', - 'suggested_solutions': ['请团队负责人评估'], - 'requires_new_task': False - } - - async def _analyze_task_for_blockers( - self, - task_id: int, - title: str, - description: str, - tenant_id: int, - ) -> Dict[str, Any]: - """AI 分析任务是否被阻塞""" - prompt = f"""分析以下任务是否可能被阻塞: - -任务标题:{title} -任务描述:{description} - -请判断: -1. 任务是否被阻塞? -2. 如果阻塞,原因是什么? -3. 严重程度(high/medium/low)? -4. 建议的下一步行动? - -以 JSON 格式返回: -{{ - "is_blocked": boolean, - "reason": "阻塞原因(如果阻塞)", - "severity": "high/medium/low", - "suggested_actions": ["行动1", "行动2"] -}} - -如果任务没有被阻塞,is_blocked 设为 false。""" - - try: - response = await self.llm.generate( - prompt, - db=self.db, - tenant_id=tenant_id, - ) - response = response.strip() - if response.startswith('```'): - response = response.split('```')[1] - if response.startswith('json'): - response = response[4:] - return json.loads(response.strip()) - except: - return {'is_blocked': False} - - async def _generate_blocker_resolution_tasks( - self, - blocker_id: int, - analysis: Dict[str, Any], - project_id: int, - tenant_id: int - ) -> List[Dict[str, Any]]: - """生成阻塞解决任务""" - if not analysis.get('requires_new_task', False): - return [] - - from sqlalchemy import text - - # 获取下一个任务ID - next_task_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM tasks"))).scalar() or 1 - - task_title = analysis.get('suggested_task_title', f'解决阻塞 #{blocker_id}') - task_description = f"""自动生成的阻塞解决任务 - -阻塞原因: -{analysis.get('problem_analysis', '')} - -建议的解决方案: -{chr(10).join(analysis.get('suggested_solutions', []))}""" - - task = Task( - id=next_task_id, - truth_node_id=None, # 不关联特定宪章 - project_id=project_id, - assignee_id=None, - title=task_title, - description=task_description, - required_skill_tag_ids=[], - status='pending', - priority='HIGH' if analysis.get('severity') == 'high' else 'MEDIUM', - estimated_hours=4, - actual_hours=0, - quality_score=0, - tenant_id=tenant_id - ) - self.db.add(task) - await self.db.commit() - - return [{ - 'task_id': task.id, - 'title': task.title, - 'priority': task.priority - }] - - def _extract_severity(self, description: str) -> str: - """从描述中提取严重程度""" - desc_lower = description.lower() - if 'high' in desc_lower or '严重' in desc_lower or 'critical' in desc_lower: - return 'high' - elif 'low' in desc_lower or '轻微' in desc_lower: - return 'low' - return 'medium' - - -# 全局服务实例 -def get_blocker_detection_service(db: AsyncSession) -> BlockerDetectionService: - """获取阻塞检测服务实例""" - return BlockerDetectionService(db) +__all__ = ["BlockerDetectionService", "get_blocker_detection_service"] diff --git a/backend/app/services/blocker_detection_core/__init__.py b/backend/app/services/blocker_detection_core/__init__.py new file mode 100644 index 00000000..643dd7de --- /dev/null +++ b/backend/app/services/blocker_detection_core/__init__.py @@ -0,0 +1,6 @@ +"""Blocker detection exports.""" + +from .registry import get_blocker_detection_service +from .service import BlockerDetectionService + +__all__ = ["BlockerDetectionService", "get_blocker_detection_service"] diff --git a/backend/app/services/blocker_detection_core/mixin_ai.py b/backend/app/services/blocker_detection_core/mixin_ai.py new file mode 100644 index 00000000..0b3a91ea --- /dev/null +++ b/backend/app/services/blocker_detection_core/mixin_ai.py @@ -0,0 +1,156 @@ +"""AI analysis helpers for blocker detection service.""" + +from __future__ import annotations + +from .shared import Any, Dict, List, Task, json, text + + +class BlockerDetectionAiMixin: + """AI-driven blocker analysis and suggested-task generation.""" + + async def _analyze_blocker_with_ai( + self, + code_content: str, + file_path: str, + pattern: str, + tenant_id: int, + ) -> Dict[str, Any]: + """AI 分析代码阻塞。""" + prompt = f"""分析以下代码注释中提到的阻塞问题: + +文件:{file_path} +匹配到的模式:{pattern} +代码内容: +{code_content} + +请分析: +1. 阻塞的根本原因是什么? +2. 严重程度(high/medium/low)? +3. 可能的解决方案有哪些? +4. 是否需要创建新任务来跟踪? + +以 JSON 格式返回: +{{ + "problem_analysis": "问题分析", + "severity": "high/medium/low", + "suggested_solutions": ["方案1", "方案2"], + "requires_new_task": boolean, + "suggested_task_title": "建议的任务标题" +}}""" + + try: + response = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + ) + response = response.strip() + if response.startswith("```"): + response = response.split("```")[1] + if response.startswith("json"): + response = response[4:] + return json.loads(response.strip()) + except Exception: + return { + "problem_analysis": f"检测到可能的阻塞:{pattern}", + "severity": "medium", + "suggested_solutions": ["请团队负责人评估"], + "requires_new_task": False, + } + + async def _analyze_task_for_blockers( + self, + task_id: int, + title: str, + description: str, + tenant_id: int, + ) -> Dict[str, Any]: + """AI 分析任务是否被阻塞。""" + prompt = f"""分析以下任务是否可能被阻塞: + +任务标题:{title} +任务描述:{description} + +请判断: +1. 任务是否被阻塞? +2. 如果阻塞,原因是什么? +3. 严重程度(high/medium/low)? +4. 建议的下一步行动? + +以 JSON 格式返回: +{{ + "is_blocked": boolean, + "reason": "阻塞原因(如果阻塞)", + "severity": "high/medium/low", + "suggested_actions": ["行动1", "行动2"] +}} + +如果任务没有被阻塞,is_blocked 设为 false。""" + + try: + response = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + ) + response = response.strip() + if response.startswith("```"): + response = response.split("```")[1] + if response.startswith("json"): + response = response[4:] + return json.loads(response.strip()) + except Exception: + return {"is_blocked": False} + + async def _generate_blocker_resolution_tasks( + self, + blocker_id: int, + analysis: Dict[str, Any], + project_id: int, + tenant_id: int, + ) -> List[Dict[str, Any]]: + """生成阻塞解决任务。""" + if not analysis.get("requires_new_task", False): + return [] + + next_task_id = ( + await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM tasks")) + ).scalar() or 1 + + task_title = analysis.get("suggested_task_title", f"解决阻塞 #{blocker_id}") + task_description = f"""自动生成的阻塞解决任务 + +阻塞原因: +{analysis.get('problem_analysis', '')} + +建议的解决方案: +{chr(10).join(analysis.get('suggested_solutions', []))}""" + + task = Task( + id=next_task_id, + truth_node_id=None, + project_id=project_id, + assignee_id=None, + title=task_title, + description=task_description, + required_skill_tag_ids=[], + status="pending", + priority="HIGH" if analysis.get("severity") == "high" else "MEDIUM", + estimated_hours=4, + actual_hours=0, + quality_score=0, + tenant_id=tenant_id, + ) + self.db.add(task) + await self.db.commit() + + return [{"task_id": task.id, "title": task.title, "priority": task.priority}] + + def _extract_severity(self, description: str) -> str: + """从描述中提取严重程度。""" + desc_lower = description.lower() + if "high" in desc_lower or "严重" in desc_lower or "critical" in desc_lower: + return "high" + if "low" in desc_lower or "轻微" in desc_lower: + return "low" + return "medium" diff --git a/backend/app/services/blocker_detection_core/mixin_scan.py b/backend/app/services/blocker_detection_core/mixin_scan.py new file mode 100644 index 00000000..9a7d144d --- /dev/null +++ b/backend/app/services/blocker_detection_core/mixin_scan.py @@ -0,0 +1,188 @@ +"""Blocker scanning and lifecycle methods.""" + +from __future__ import annotations + +from .shared import Any, BLOCKER_PATTERNS, CognitiveConflict, Dict, List, Task, json, now_cn_naive, re, select, text + + +class BlockerDetectionScanMixin: + """Scans code/tasks and manages blocker records.""" + + async def scan_code_comment( + self, + code_content: str, + file_path: str, + line_number: int, + project_id: int, + user_id: int, + tenant_id: int = 1, + ) -> Dict[str, Any] | None: + """扫描代码注释,检测阻塞。""" + detected_pattern = None + for pattern in BLOCKER_PATTERNS: + if re.search(pattern, code_content, re.IGNORECASE): + detected_pattern = pattern + break + + if not detected_pattern: + return None + + analysis = await self._analyze_blocker_with_ai( + code_content=code_content, + file_path=file_path, + pattern=detected_pattern, + tenant_id=tenant_id, + ) + + next_conflict_id = ( + await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM cognitive_conflicts")) + ).scalar() or 1 + + blocker = CognitiveConflict( + id=next_conflict_id, + truth_node_id=None, + conflict_type="code_blocker", + description=( + "代码阻塞检测:\n\n" + f"文件:{file_path}:{line_number}\n" + f"代码:{code_content[:200]}\n\n" + f"AI 分析:{analysis.get('problem_analysis', '')}" + ), + involved_user_ids=json.dumps([user_id]), + status="detected", + tenant_id=tenant_id, + ) + self.db.add(blocker) + await self.db.commit() + + suggested_tasks = await self._generate_blocker_resolution_tasks( + blocker_id=blocker.id, + analysis=analysis, + project_id=project_id, + tenant_id=tenant_id, + ) + + return { + "blocker_id": blocker.id, + "severity": analysis.get("severity", "medium"), + "problem_analysis": analysis.get("problem_analysis", ""), + "suggested_solutions": analysis.get("suggested_solutions", []), + "suggested_tasks": suggested_tasks, + } + + async def detect_task_blockers(self, tenant_id: int = 1) -> List[Dict[str, Any]]: + """检测所有任务中的阻塞。""" + result = await self.db.execute( + text( + """ + SELECT t.id, t.title, t.description, t.status, t.project_id + FROM tasks t + WHERE t.status IN ('pending', 'in_progress') + AND t.tenant_id = :tenant_id + AND (t.updated_at < datetime('now', '-3 days') + OR t.updated_at < datetime('now', '-3 days', 'localtime')) + """ + ), + {"tenant_id": tenant_id}, + ) + + tasks = result.fetchall() + detected_blockers = [] + + for task in tasks: + task_id, title, description, status, project_id = task + + analysis = await self._analyze_task_for_blockers( + task_id=task_id, + title=title, + description=description, + tenant_id=tenant_id, + ) + + if analysis.get("is_blocked", False): + next_conflict_id = ( + await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM cognitive_conflicts")) + ).scalar() or 1 + + blocker = CognitiveConflict( + id=next_conflict_id, + truth_node_id=None, + conflict_type="task_blocker", + description=( + "任务阻塞检测:\n\n" + f"任务:{title}\n" + f"ID: {task_id}\n\n" + f"AI 分析:{analysis.get('reason', '')}" + ), + involved_user_ids=json.dumps([]), + status="detected", + tenant_id=tenant_id, + ) + self.db.add(blocker) + await self.db.commit() + + detected_blockers.append( + { + "blocker_id": blocker.id, + "task_id": task_id, + "task_title": title, + "severity": analysis.get("severity", "medium"), + "reason": analysis.get("reason", ""), + "suggested_actions": analysis.get("suggested_actions", []), + } + ) + + return detected_blockers + + async def resolve_blocker( + self, + blocker_id: int, + resolution_note: str, + tenant_id: int = 1, + ) -> Dict[str, Any]: + """标记阻塞为已解决。""" + result = await self.db.execute( + select(CognitiveConflict).where( + CognitiveConflict.id == blocker_id, + CognitiveConflict.tenant_id == tenant_id, + ) + ) + blocker = result.scalar_one_or_none() + + if not blocker: + raise ValueError(f"阻塞 {blocker_id} 不存在") + + blocker.status = "resolved" + blocker.resolved_at = now_cn_naive() + blocker.description += f"\n\n解决方案:{resolution_note}" + + await self.db.commit() + + return { + "blocker_id": blocker_id, + "status": "resolved", + "resolved_at": blocker.resolved_at.isoformat(), + } + + async def get_active_blockers(self, tenant_id: int = 1) -> List[Dict[str, Any]]: + """获取所有活跃的阻塞。""" + result = await self.db.execute( + select(CognitiveConflict).where( + CognitiveConflict.tenant_id == tenant_id, + CognitiveConflict.conflict_type.in_(["code_blocker", "task_blocker"]), + CognitiveConflict.status == "detected", + ) + ) + blockers = result.scalars().all() + + return [ + { + "id": b.id, + "type": b.conflict_type, + "description": b.description, + "severity": self._extract_severity(b.description), + "detected_at": b.created_at.isoformat() if hasattr(b, "created_at") else None, + "involved_users": json.loads(b.involved_user_ids) if b.involved_user_ids else [], + } + for b in blockers + ] diff --git a/backend/app/services/blocker_detection_core/registry.py b/backend/app/services/blocker_detection_core/registry.py new file mode 100644 index 00000000..d39cf8ba --- /dev/null +++ b/backend/app/services/blocker_detection_core/registry.py @@ -0,0 +1,11 @@ +"""Factory for blocker detection service.""" + +from __future__ import annotations + +from .service import BlockerDetectionService +from .shared import AsyncSession + + +def get_blocker_detection_service(db: AsyncSession) -> BlockerDetectionService: + """获取阻塞检测服务实例。""" + return BlockerDetectionService(db) diff --git a/backend/app/services/blocker_detection_core/service.py b/backend/app/services/blocker_detection_core/service.py new file mode 100644 index 00000000..d51f764f --- /dev/null +++ b/backend/app/services/blocker_detection_core/service.py @@ -0,0 +1,15 @@ +"""Blocker detection service composition.""" + +from __future__ import annotations + +from .mixin_ai import BlockerDetectionAiMixin +from .mixin_scan import BlockerDetectionScanMixin +from .shared import AsyncSession, LLMService + + +class BlockerDetectionService(BlockerDetectionAiMixin, BlockerDetectionScanMixin): + """主动式阻塞检测服务。""" + + def __init__(self, db: AsyncSession): + self.db = db + self.llm = LLMService() diff --git a/backend/app/services/blocker_detection_core/shared.py b/backend/app/services/blocker_detection_core/shared.py new file mode 100644 index 00000000..85e0d539 --- /dev/null +++ b/backend/app/services/blocker_detection_core/shared.py @@ -0,0 +1,61 @@ +"""Shared imports/constants for blocker detection service.""" + +from __future__ import annotations + +import asyncio +import json +import re +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from ...models import CognitiveConflict, Project, Signal, Task, TruthNode, User +from ...utils.timezone import now_cn_naive +from ..llm import LLMService + +BLOCKER_PATTERNS = [ + r"等待.*接口", + r"等待.*API", + r"等待.*确定", + r"等待.*完成", + r"blocked by", + r"blocked due to", + r"can't proceed until", + r"暂时跳过", + r"TODO.*等待", + r"FIXME.*等待", + r"HACK.*临时", + r"达不到效果", + r"无法.*因为", + r"pending.*approval", + r"waiting for", + r"blocked on", +] + +__all__ = [ + "Any", + "AsyncSession", + "BLOCKER_PATTERNS", + "CognitiveConflict", + "Dict", + "LLMService", + "List", + "Optional", + "Project", + "Signal", + "Task", + "TruthNode", + "User", + "asyncio", + "datetime", + "json", + "now_cn_naive", + "re", + "select", + "text", + "timedelta", + "timezone", +] diff --git a/backend/app/services/budget_enforcement/__init__.py b/backend/app/services/budget_enforcement/__init__.py new file mode 100644 index 00000000..821779ba --- /dev/null +++ b/backend/app/services/budget_enforcement/__init__.py @@ -0,0 +1,5 @@ +"""Budget enforcement exports.""" + +from .service import BudgetEnforcementService + +__all__ = ["BudgetEnforcementService"] diff --git a/backend/app/services/budget_enforcement/mixin_alerts.py b/backend/app/services/budget_enforcement/mixin_alerts.py new file mode 100644 index 00000000..93cbea36 --- /dev/null +++ b/backend/app/services/budget_enforcement/mixin_alerts.py @@ -0,0 +1,129 @@ +"""Alert and override flows for budget enforcement.""" + +from __future__ import annotations + +from .shared import Any, Budget, BudgetAlert, Optional, and_, desc, now_cn_naive, select, timedelta + + +class BudgetEnforcementAlertsMixin: + """Hard-limit enforcement, alerts, and override handling.""" + + async def enforce_spending_limit( + self, + budget: Budget, + usage_usd: float, + usage_percent: float, + ) -> None: + """执行超支硬性限制:标记预算为 suspended 并记录告警。""" + budget.status = "suspended" + + await self.generate_budget_alert( + budget=budget, + alert_type="exceeded", + usage_usd=usage_usd, + usage_percent=usage_percent, + message=f"预算已超支,当前使用率 {usage_percent:.2f}%", + action_taken="mark_exceeded", + ) + await self.generate_budget_alert( + budget=budget, + alert_type="suspended", + usage_usd=usage_usd, + usage_percent=usage_percent, + message="预算超支,已暂停新的 AI 调用", + action_taken="suspend_ai_calls", + ) + await self.db.commit() + + async def generate_budget_alert( + self, + budget: Budget, + alert_type: str, + usage_usd: float, + usage_percent: float, + message: str, + action_taken: Optional[str], + ) -> BudgetAlert: + """生成预算预警(同类 active 预警存在则复用更新)。""" + result = await self.db.execute( + select(BudgetAlert) + .where( + and_( + BudgetAlert.budget_id == budget.id, + BudgetAlert.alert_type == alert_type, + BudgetAlert.status == "active", + ) + ) + .order_by(desc(BudgetAlert.created_at)) + ) + existing = result.scalar_one_or_none() + if existing: + existing.usage_usd = usage_usd + existing.usage_percent = usage_percent + existing.message = message + existing.action_taken = action_taken + await self.db.commit() + await self.db.refresh(existing) + return existing + + alert = BudgetAlert( + tenant_id=budget.tenant_id, + budget_id=budget.id, + alert_type=alert_type, + usage_usd=usage_usd, + usage_percent=usage_percent, + message=message, + is_read=False, + action_taken=action_taken, + status="active", + ) + self.db.add(alert) + await self.db.commit() + await self.db.refresh(alert) + return alert + + async def override_budget_limit( + self, + budget_id: int, + tenant_id: int, + operator_user_id: int, + note: str = "", + duration_hours: int = 24, + ) -> dict[str, Any]: + """BOSS/ADMIN 超支解锁。""" + result = await self.db.execute( + select(Budget).where( + and_( + Budget.id == budget_id, + Budget.tenant_id == tenant_id, + ) + ) + ) + budget = result.scalar_one_or_none() + if not budget: + raise ValueError(f"预算 {budget_id} 不存在") + + now = now_cn_naive() + expires_at = now + timedelta(hours=max(duration_hours, 1)) + history = dict(budget.override_history or {}) + records = list(history.get("records", [])) + records.append( + { + "operator_user_id": operator_user_id, + "note": note, + "overridden_at": now.isoformat(), + "expires_at": expires_at.isoformat(), + } + ) + history["records"] = records + budget.override_history = history + budget.status = "overridden" + + await self.db.commit() + await self.db.refresh(budget) + return { + "budget_id": budget.id, + "status": budget.status, + "expires_at": expires_at.isoformat(), + "override_count": len(records), + } diff --git a/backend/app/services/budget_enforcement/mixin_checks.py b/backend/app/services/budget_enforcement/mixin_checks.py new file mode 100644 index 00000000..74332051 --- /dev/null +++ b/backend/app/services/budget_enforcement/mixin_checks.py @@ -0,0 +1,141 @@ +"""Budget check/update flows.""" + +from __future__ import annotations + +from .shared import Any, Optional, now_cn_naive + + +class BudgetEnforcementChecksMixin: + """Pre-call checks and post-call usage updates.""" + + async def check_budget_before_ai_call( + self, + tenant_id: int, + estimated_cost_usd: float, + project_id: Optional[int] = None, + ) -> dict[str, Any]: + """AI 调用前预算检查。""" + budget = await self._get_active_budget(tenant_id=tenant_id, project_id=project_id) + if not budget: + return { + "allowed": True, + "reason": "no_budget_configured", + "budget_checked": False, + } + + now = now_cn_naive() + await self._ensure_current_period(budget, now) + + current_usage = float(budget.current_usage_usd or 0.0) + budget_amount = float(budget.budget_amount_usd or 0.0) + if budget_amount <= 0: + return { + "allowed": True, + "reason": "invalid_budget_amount", + "budget_checked": True, + } + + if budget.status == "suspended": + return { + "allowed": False, + "reason": "budget_suspended", + "budget_checked": True, + "budget_id": budget.id, + "current_usage_usd": round(current_usage, 4), + "budget_amount_usd": round(budget_amount, 4), + } + + projected_usage = current_usage + max(estimated_cost_usd, 0.0) + usage_percent = (projected_usage / budget_amount) * 100 + + if usage_percent >= float(budget.alert_threshold_percent or 80.0): + await self.generate_budget_alert( + budget=budget, + alert_type="threshold_reached", + usage_usd=projected_usage, + usage_percent=usage_percent, + message=( + f"预算使用率达到 {usage_percent:.2f}% " + f"(阈值 {float(budget.alert_threshold_percent or 80.0):.2f}%)" + ), + action_taken=None, + ) + + if projected_usage > budget_amount and bool(budget.hard_limit_enabled) and budget.status != "overridden": + await self.enforce_spending_limit( + budget=budget, + usage_usd=projected_usage, + usage_percent=usage_percent, + ) + return { + "allowed": False, + "reason": "budget_exceeded_hard_limit", + "budget_checked": True, + "budget_id": budget.id, + "current_usage_usd": round(current_usage, 4), + "projected_usage_usd": round(projected_usage, 4), + "budget_amount_usd": round(budget_amount, 4), + "usage_percent": round(usage_percent, 2), + } + + return { + "allowed": True, + "reason": "within_budget", + "budget_checked": True, + "budget_id": budget.id, + "current_usage_usd": round(current_usage, 4), + "projected_usage_usd": round(projected_usage, 4), + "budget_amount_usd": round(budget_amount, 4), + "usage_percent": round(usage_percent, 2), + } + + async def update_budget_usage_after_ai_call( + self, + tenant_id: int, + actual_cost_usd: float, + project_id: Optional[int] = None, + ) -> dict[str, Any]: + """AI 调用成功后累计预算使用量。""" + budget = await self._get_active_budget(tenant_id=tenant_id, project_id=project_id) + if not budget: + return {"updated": False, "reason": "no_budget_configured"} + + now = now_cn_naive() + await self._ensure_current_period(budget, now) + + cost = max(float(actual_cost_usd or 0.0), 0.0) + budget.current_usage_usd = float(budget.current_usage_usd or 0.0) + cost + + budget_amount = float(budget.budget_amount_usd or 0.0) + usage_percent = (float(budget.current_usage_usd) / budget_amount * 100) if budget_amount > 0 else 0.0 + + if usage_percent >= float(budget.alert_threshold_percent or 80.0): + await self.generate_budget_alert( + budget=budget, + alert_type="threshold_reached", + usage_usd=float(budget.current_usage_usd), + usage_percent=usage_percent, + message=( + f"预算使用率达到 {usage_percent:.2f}% " + f"(阈值 {float(budget.alert_threshold_percent or 80.0):.2f}%)" + ), + action_taken=None, + ) + + if budget_amount > 0 and float(budget.current_usage_usd) > budget_amount and bool(budget.hard_limit_enabled): + await self.enforce_spending_limit( + budget=budget, + usage_usd=float(budget.current_usage_usd), + usage_percent=usage_percent, + ) + + await self.db.commit() + await self.db.refresh(budget) + + return { + "updated": True, + "budget_id": budget.id, + "current_usage_usd": float(budget.current_usage_usd), + "usage_percent": round(usage_percent, 2), + "status": budget.status, + } diff --git a/backend/app/services/budget_enforcement/mixin_storage.py b/backend/app/services/budget_enforcement/mixin_storage.py new file mode 100644 index 00000000..7481d41f --- /dev/null +++ b/backend/app/services/budget_enforcement/mixin_storage.py @@ -0,0 +1,74 @@ +"""Budget storage and period helpers.""" + +from __future__ import annotations + +from .shared import Budget, Optional, and_, datetime, desc, select + + +class BudgetEnforcementStorageMixin: + """Budget lookup and period-window management helpers.""" + + async def _get_active_budget(self, tenant_id: int, project_id: Optional[int]) -> Optional[Budget]: + """优先读取项目预算,否则回退租户预算。""" + if project_id is not None: + result = await self.db.execute( + select(Budget) + .where( + and_( + Budget.tenant_id == tenant_id, + Budget.budget_type == "project", + Budget.project_id == project_id, + ) + ) + .order_by(desc(Budget.created_at)) + ) + project_budget = result.scalar_one_or_none() + if project_budget: + return project_budget + + result = await self.db.execute( + select(Budget) + .where( + and_( + Budget.tenant_id == tenant_id, + Budget.budget_type == "tenant", + ) + ) + .order_by(desc(Budget.created_at)) + ) + return result.scalar_one_or_none() + + async def _ensure_current_period(self, budget: Budget, now: datetime) -> None: + """若预算周期已过,自动切换到新周期并清零使用量。""" + start = budget.current_period_start + end = budget.current_period_end + if start is None or end is None or now >= end: + next_start, next_end = self._compute_period_window(now, budget.period) + budget.current_period_start = next_start + budget.current_period_end = next_end + budget.current_usage_usd = 0.0 + budget.status = "active" + await self.db.commit() + + def _compute_period_window(self, now: datetime, period: str) -> tuple[datetime, datetime]: + period = (period or "monthly").lower() + if period == "yearly": + start = datetime(now.year, 1, 1) + end = datetime(now.year + 1, 1, 1) + return start, end + if period == "quarterly": + quarter = (now.month - 1) // 3 + start_month = quarter * 3 + 1 + start = datetime(now.year, start_month, 1) + if start_month == 10: + end = datetime(now.year + 1, 1, 1) + else: + end = datetime(now.year, start_month + 3, 1) + return start, end + + start = datetime(now.year, now.month, 1) + if now.month == 12: + end = datetime(now.year + 1, 1, 1) + else: + end = datetime(now.year, now.month + 1, 1) + return start, end diff --git a/backend/app/services/budget_enforcement/service.py b/backend/app/services/budget_enforcement/service.py new file mode 100644 index 00000000..7d6f064c --- /dev/null +++ b/backend/app/services/budget_enforcement/service.py @@ -0,0 +1,19 @@ +"""Budget enforcement service composition.""" + +from __future__ import annotations + +from .mixin_alerts import BudgetEnforcementAlertsMixin +from .mixin_checks import BudgetEnforcementChecksMixin +from .mixin_storage import BudgetEnforcementStorageMixin +from .shared import AsyncSession + + +class BudgetEnforcementService( + BudgetEnforcementStorageMixin, + BudgetEnforcementAlertsMixin, + BudgetEnforcementChecksMixin, +): + """预算强制执行服务。""" + + def __init__(self, db: AsyncSession): + self.db = db diff --git a/backend/app/services/budget_enforcement/shared.py b/backend/app/services/budget_enforcement/shared.py new file mode 100644 index 00000000..e27da44b --- /dev/null +++ b/backend/app/services/budget_enforcement/shared.py @@ -0,0 +1,26 @@ +"""Shared imports for budget enforcement service.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any, Optional + +from sqlalchemy import and_, desc, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...models import Budget, BudgetAlert +from ...utils.timezone import now_cn_naive + +__all__ = [ + "Any", + "AsyncSession", + "Budget", + "BudgetAlert", + "Optional", + "and_", + "datetime", + "desc", + "now_cn_naive", + "select", + "timedelta", +] diff --git a/backend/app/services/budget_enforcement_service.py b/backend/app/services/budget_enforcement_service.py index 67e57ac4..c9769c73 100644 --- a/backend/app/services/budget_enforcement_service.py +++ b/backend/app/services/budget_enforcement_service.py @@ -1,346 +1,5 @@ -"""预算强制执行服务 +"""Compatibility exports for budget enforcement service.""" -实现: -- AI 调用前预算检查 -- 超支硬限制执行 -- 预算使用量累计 -- 80% 阈值预警与超支告警 -""" - -from __future__ import annotations - -from datetime import datetime, timedelta -from typing import Any, Optional - -from sqlalchemy import and_, desc, select -from sqlalchemy.ext.asyncio import AsyncSession - -from ..models import Budget, BudgetAlert - - -from ..utils.timezone import now_cn_naive -class BudgetEnforcementService: - """预算强制执行服务""" - - def __init__(self, db: AsyncSession): - self.db = db - - async def check_budget_before_ai_call( - self, - tenant_id: int, - estimated_cost_usd: float, - project_id: Optional[int] = None, - ) -> dict[str, Any]: - """AI 调用前预算检查""" - budget = await self._get_active_budget(tenant_id=tenant_id, project_id=project_id) - if not budget: - return { - "allowed": True, - "reason": "no_budget_configured", - "budget_checked": False, - } - - now = now_cn_naive() - await self._ensure_current_period(budget, now) - - current_usage = float(budget.current_usage_usd or 0.0) - budget_amount = float(budget.budget_amount_usd or 0.0) - if budget_amount <= 0: - return { - "allowed": True, - "reason": "invalid_budget_amount", - "budget_checked": True, - } - - if budget.status == "suspended": - return { - "allowed": False, - "reason": "budget_suspended", - "budget_checked": True, - "budget_id": budget.id, - "current_usage_usd": round(current_usage, 4), - "budget_amount_usd": round(budget_amount, 4), - } - - projected_usage = current_usage + max(estimated_cost_usd, 0.0) - usage_percent = (projected_usage / budget_amount) * 100 - - # 阈值预警(80% 默认) - if usage_percent >= float(budget.alert_threshold_percent or 80.0): - await self.generate_budget_alert( - budget=budget, - alert_type="threshold_reached", - usage_usd=projected_usage, - usage_percent=usage_percent, - message=( - f"预算使用率达到 {usage_percent:.2f}% " - f"(阈值 {float(budget.alert_threshold_percent or 80.0):.2f}%)" - ), - action_taken=None, - ) - - # 超支硬拦截 - if ( - projected_usage > budget_amount - and bool(budget.hard_limit_enabled) - and budget.status != "overridden" - ): - await self.enforce_spending_limit( - budget=budget, - usage_usd=projected_usage, - usage_percent=usage_percent, - ) - return { - "allowed": False, - "reason": "budget_exceeded_hard_limit", - "budget_checked": True, - "budget_id": budget.id, - "current_usage_usd": round(current_usage, 4), - "projected_usage_usd": round(projected_usage, 4), - "budget_amount_usd": round(budget_amount, 4), - "usage_percent": round(usage_percent, 2), - } - - return { - "allowed": True, - "reason": "within_budget", - "budget_checked": True, - "budget_id": budget.id, - "current_usage_usd": round(current_usage, 4), - "projected_usage_usd": round(projected_usage, 4), - "budget_amount_usd": round(budget_amount, 4), - "usage_percent": round(usage_percent, 2), - } - - async def update_budget_usage_after_ai_call( - self, - tenant_id: int, - actual_cost_usd: float, - project_id: Optional[int] = None, - ) -> dict[str, Any]: - """AI 调用成功后累计预算使用量""" - budget = await self._get_active_budget(tenant_id=tenant_id, project_id=project_id) - if not budget: - return {"updated": False, "reason": "no_budget_configured"} - - now = now_cn_naive() - await self._ensure_current_period(budget, now) - - cost = max(float(actual_cost_usd or 0.0), 0.0) - budget.current_usage_usd = float(budget.current_usage_usd or 0.0) + cost - - budget_amount = float(budget.budget_amount_usd or 0.0) - usage_percent = (float(budget.current_usage_usd) / budget_amount * 100) if budget_amount > 0 else 0.0 - - if usage_percent >= float(budget.alert_threshold_percent or 80.0): - await self.generate_budget_alert( - budget=budget, - alert_type="threshold_reached", - usage_usd=float(budget.current_usage_usd), - usage_percent=usage_percent, - message=( - f"预算使用率达到 {usage_percent:.2f}% " - f"(阈值 {float(budget.alert_threshold_percent or 80.0):.2f}%)" - ), - action_taken=None, - ) - - if budget_amount > 0 and float(budget.current_usage_usd) > budget_amount and bool(budget.hard_limit_enabled): - await self.enforce_spending_limit( - budget=budget, - usage_usd=float(budget.current_usage_usd), - usage_percent=usage_percent, - ) - - await self.db.commit() - await self.db.refresh(budget) - - return { - "updated": True, - "budget_id": budget.id, - "current_usage_usd": float(budget.current_usage_usd), - "usage_percent": round(usage_percent, 2), - "status": budget.status, - } - - async def enforce_spending_limit( - self, - budget: Budget, - usage_usd: float, - usage_percent: float, - ) -> None: - """执行超支硬性限制:标记预算为 suspended 并记录告警""" - budget.status = "suspended" - - await self.generate_budget_alert( - budget=budget, - alert_type="exceeded", - usage_usd=usage_usd, - usage_percent=usage_percent, - message=f"预算已超支,当前使用率 {usage_percent:.2f}%", - action_taken="mark_exceeded", - ) - await self.generate_budget_alert( - budget=budget, - alert_type="suspended", - usage_usd=usage_usd, - usage_percent=usage_percent, - message="预算超支,已暂停新的 AI 调用", - action_taken="suspend_ai_calls", - ) - await self.db.commit() - - async def generate_budget_alert( - self, - budget: Budget, - alert_type: str, - usage_usd: float, - usage_percent: float, - message: str, - action_taken: Optional[str], - ) -> BudgetAlert: - """生成预算预警(同类 active 预警存在则复用更新)""" - result = await self.db.execute( - select(BudgetAlert).where( - and_( - BudgetAlert.budget_id == budget.id, - BudgetAlert.alert_type == alert_type, - BudgetAlert.status == "active", - ) - ).order_by(desc(BudgetAlert.created_at)) - ) - existing = result.scalar_one_or_none() - if existing: - existing.usage_usd = usage_usd - existing.usage_percent = usage_percent - existing.message = message - existing.action_taken = action_taken - await self.db.commit() - await self.db.refresh(existing) - return existing - - alert = BudgetAlert( - tenant_id=budget.tenant_id, - budget_id=budget.id, - alert_type=alert_type, - usage_usd=usage_usd, - usage_percent=usage_percent, - message=message, - is_read=False, - action_taken=action_taken, - status="active", - ) - self.db.add(alert) - await self.db.commit() - await self.db.refresh(alert) - return alert - - async def override_budget_limit( - self, - budget_id: int, - tenant_id: int, - operator_user_id: int, - note: str = "", - duration_hours: int = 24, - ) -> dict[str, Any]: - """BOSS/ADMIN 超支解锁""" - result = await self.db.execute( - select(Budget).where( - and_( - Budget.id == budget_id, - Budget.tenant_id == tenant_id, - ) - ) - ) - budget = result.scalar_one_or_none() - if not budget: - raise ValueError(f"预算 {budget_id} 不存在") - - now = now_cn_naive() - expires_at = now + timedelta(hours=max(duration_hours, 1)) - history = dict(budget.override_history or {}) - records = list(history.get("records", [])) - records.append( - { - "operator_user_id": operator_user_id, - "note": note, - "overridden_at": now.isoformat(), - "expires_at": expires_at.isoformat(), - } - ) - history["records"] = records - budget.override_history = history - budget.status = "overridden" - - await self.db.commit() - await self.db.refresh(budget) - return { - "budget_id": budget.id, - "status": budget.status, - "expires_at": expires_at.isoformat(), - "override_count": len(records), - } - - async def _get_active_budget(self, tenant_id: int, project_id: Optional[int]) -> Optional[Budget]: - """优先读取项目预算,否则回退租户预算""" - if project_id is not None: - result = await self.db.execute( - select(Budget).where( - and_( - Budget.tenant_id == tenant_id, - Budget.budget_type == "project", - Budget.project_id == project_id, - ) - ).order_by(desc(Budget.created_at)) - ) - project_budget = result.scalar_one_or_none() - if project_budget: - return project_budget - - result = await self.db.execute( - select(Budget).where( - and_( - Budget.tenant_id == tenant_id, - Budget.budget_type == "tenant", - ) - ).order_by(desc(Budget.created_at)) - ) - return result.scalar_one_or_none() - - async def _ensure_current_period(self, budget: Budget, now: datetime) -> None: - """若预算周期已过,自动切换到新周期并清零使用量""" - start = budget.current_period_start - end = budget.current_period_end - if start is None or end is None or now >= end: - next_start, next_end = self._compute_period_window(now, budget.period) - budget.current_period_start = next_start - budget.current_period_end = next_end - budget.current_usage_usd = 0.0 - # 周期切换后恢复 active,允许重新消费 - budget.status = "active" - await self.db.commit() - - def _compute_period_window(self, now: datetime, period: str) -> tuple[datetime, datetime]: - period = (period or "monthly").lower() - if period == "yearly": - start = datetime(now.year, 1, 1) - end = datetime(now.year + 1, 1, 1) - return start, end - if period == "quarterly": - quarter = (now.month - 1) // 3 - start_month = quarter * 3 + 1 - start = datetime(now.year, start_month, 1) - if start_month == 10: - end = datetime(now.year + 1, 1, 1) - else: - end = datetime(now.year, start_month + 3, 1) - return start, end - - # default monthly - start = datetime(now.year, now.month, 1) - if now.month == 12: - end = datetime(now.year + 1, 1, 1) - else: - end = datetime(now.year, now.month + 1, 1) - return start, end +from .budget_enforcement import BudgetEnforcementService +__all__ = ["BudgetEnforcementService"] diff --git a/backend/app/services/codegen.py b/backend/app/services/codegen.py index 48e10d07..e77a5c5f 100644 --- a/backend/app/services/codegen.py +++ b/backend/app/services/codegen.py @@ -1,554 +1,19 @@ -""" -Neural Sync - Code Draft Generation Service - -AI 代码草案生成服务 - 激进策略 -AI 尝试生成所有代码(包括复杂逻辑),但提供详细的 review points -""" - -import asyncio -import os -import re -import subprocess -import logging -from typing import List, Optional, Dict, Any -from pydantic import BaseModel, Field -from datetime import datetime -from pathlib import Path - -from ..utils.timezone import now_cn_aware -logger = logging.getLogger(__name__) - - -class CodeDraftRequest(BaseModel): - """代码草案生成请求""" - signal_id: str - signal_content: str - strategic_analysis: Dict[str, Any] - target_files: List[str] - project_root: str = "." - - -class FileInfo(BaseModel): - """文件信息""" - path: str - language: str - exists: bool - content: Optional[str] = None - lines_count: int = 0 - - -class GeneratedFile(BaseModel): - """生成的文件""" - path: str - content: str - language: str - diff: str - is_new: bool - review_points: List[str] = [] - - -class CodeDraftResponse(BaseModel): - """代码草案生成响应""" - success: bool - files: List[GeneratedFile] - summary: str - confidence: float = Field(default=0.0, ge=0.0, le=1.0) - warnings: List[str] = Field(default_factory=list) - review_points: List[str] = Field(default_factory=list) - estimated_complexity: str = "low" - generation_time: float = 0.0 - metadata: Dict[str, Any] = Field(default_factory=dict) - - -class CodeDraftService: - """ - AI 代码草案生成服务 - 激进策略 - - 特性: - - 支持全栈代码生成 - - 读取现有代码上下文 - - 生成 unified diff - - 提取人工 review 关键点 - - 深度代码验证 - """ - - def __init__(self, llm_service): - self.llm = llm_service - self._language_map = { - ".py": "python", - ".ts": "typescript", - ".tsx": "typescript", - ".js": "javascript", - ".jsx": "javascript", - ".vue": "vue", - ".sql": "sql", - ".sh": "bash", - ".yaml": "yaml", - ".yml": "yaml", - ".json": "json", - } - - async def generate_draft(self, request: CodeDraftRequest) -> CodeDraftResponse: - """ - 生成代码草案(激进策略) - """ - start_time = now_cn_aware().timestamp() - - try: - # 1. 读取目标文件 - file_contexts = await self._read_target_files(request) - - # 2. 分析代码库结构 - repo_context = await self._analyze_repo_structure(request) - - # 3. 构建激进策略的生成 prompt - prompt = self._build_aggressive_prompt(request, file_contexts, repo_context) - - # 4. 调用 LLM 生成代码 - llm_response = await self._call_llm_for_code(prompt) - - # 5. 解析和验证生成的代码 - generated_files = await self._parse_and_validate( - llm_response, file_contexts - ) - - # 6. 生成 unified diff - for file in generated_files: - file.diff = self._generate_diff(file_contexts, file) - - # 7. 提取 review points - review_points = await self._extract_review_points( - generated_files, file_contexts, request - ) - - # 8. 计算置信度和复杂度 - confidence = self._calculate_confidence(generated_files, file_contexts) - complexity = self._estimate_complexity(generated_files) - - # 9. 生成摘要 - summary = self._generate_summary(generated_files, request) - - # 10. 收集警告 - warnings = self._collect_warnings(generated_files, file_contexts) - - generation_time = now_cn_aware().timestamp() - start_time - - return CodeDraftResponse( - success=True, - files=generated_files, - summary=summary, - confidence=confidence, - warnings=warnings, - review_points=review_points, - estimated_complexity=complexity, - generation_time=generation_time, - metadata={ - "signal_id": request.signal_id, - "files_count": len(generated_files), - "new_files": sum(1 for f in generated_files if f.is_new), - "modified_files": sum(1 for f in generated_files if not f.is_new), - }, - ) - - except Exception as e: - logger.error(f"Code generation failed: {e}", exc_info=True) - return CodeDraftResponse( - success=False, - files=[], - summary=f"代码生成失败: {str(e)}", - confidence=0.0, - warnings=[f"生成过程出错: {str(e)}"], - review_points=[], - generation_time=now_cn_aware().timestamp() - start_time, - ) - - async def _read_target_files( - self, request: CodeDraftRequest - ) -> Dict[str, FileInfo]: - """读取目标文件内容""" - file_contexts = {} - - for file_path in request.target_files: - full_path = os.path.join(request.project_root, file_path) - - info = FileInfo( - path=file_path, - language=self._detect_language(file_path), - exists=os.path.exists(full_path), - ) - - if info.exists: - try: - with open(full_path, "r", encoding="utf-8") as f: - info.content = f.read() - info.lines_count = len(info.content.split("\n")) - except Exception as e: - logger.warning(f"Failed to read {file_path}: {e}") - info.content = None - - file_contexts[file_path] = info - - return file_contexts - - def _detect_language(self, file_path: str) -> str: - """检测文件语言""" - for ext, lang in self._language_map.items(): - if file_path.endswith(ext): - return lang - return "text" - - async def _analyze_repo_structure(self, request: CodeDraftRequest) -> Dict[str, Any]: - """分析代码库结构""" - repo_context = { - "existing_modules": [], - "imports_available": [], - "patterns_used": [], - } - - try: - # 扫描 Python 文件的导入 - py_files = list( - Path(request.project_root).rglob("*.py") - ) - for py_file in py_files[:20]: # 限制扫描数量 - try: - content = py_file.read_text(encoding="utf-8", errors="ignore") - # 提取导入 - imports = re.findall(r"^import (\w+)|^from (\w+) import", content, re.MULTILINE) - for imp in imports: - module = imp[0] or imp[1] - if module and module not in repo_context["imports_available"]: - repo_context["imports_available"].append(module) - except: - pass - - except Exception as e: - logger.warning(f"Repo structure analysis failed: {e}") - - return repo_context - - def _build_aggressive_prompt( - self, - request: CodeDraftRequest, - file_contexts: Dict[str, FileInfo], - repo_context: Dict[str, Any], - ) -> str: - """构建激进的代码生成 prompt""" - prompt = f"""You are a senior full-stack engineer. Generate COMPLETE, PRODUCTION-READY code based on the following requirements. - -## Signal (Requirement) -**Content**: {request.signal_content} - -## Strategic Analysis -```json -{self._format_json(request.strategic_analysis)} -``` - -## Available Imports/Modules -{', '.join(repo_context['imports_available'][:20])} - -## Existing Code Context -""" - - for file_path, info in file_contexts.items(): - prompt += f"\n### {file_path} ({info.language})\n" - if info.exists and info.content: - # 只显示关键部分(导入、类定义、函数签名) - lines = info.content.split("\n") - key_lines = [] - for line in lines: - stripped = line.strip() - # 保留导入、类/函数定义、装饰器 - if any( - stripped.startswith(p) - for p in ["import ", "from ", "class ", "def ", "@", "async def "] - ): - key_lines.append(line) - elif len(key_lines) < 30: # 保留前30行作为上下文 - key_lines.append(line) - - prompt += "```" + info.language + "\n" - prompt += "\n".join(key_lines) - if len(lines) > 30: - prompt += f"\n... ({len(lines) - 30} more lines)\n" - prompt += "```\n" - else: - prompt += "*(New file to be created)*\n" - - prompt += """ -## Code Generation Requirements -1. **Complete Implementation**: Generate FULL, WORKING code including complex business logic -2. **Error Handling**: Proper exception handling and edge cases -3. **Performance**: Consider performance implications -4. **Type Safety**: Use proper type hints -5. **Comments**: Add concise comments for complex logic -6. **Testing**: Consider testability - -## Output Format -Return a JSON response with this EXACT structure: -```json -{ - "files": [ - { - "path": "relative/path/to/file.ext", - "content": "full file content here", - "is_new": true/false, - "review_points": ["list of key points needing human review"] - } - ], - "summary": "Brief description of changes", - "warnings": ["any potential issues or risks"] -} -``` - -IMPORTANT: -- Generate complete, runnable code -- Handle edge cases and errors -- Follow existing code patterns in the project -- Highlight complex logic in review_points -""" - - return prompt - - async def _call_llm_for_code(self, prompt: str) -> Dict[str, Any]: - """调用 LLM 生成代码""" - try: - # 使用 LLM 服务的聊天接口 - response = await self.llm._call_llm_chat( - messages=[ - {"role": "system", "content": "You are an expert software engineer."}, - {"role": "user", "content": prompt} - ], - temperature=0.3, # 降低温度以获得更稳定的代码 - ) - - # 解析 JSON 响应 - json_match = re.search(r'```json\s*([\s\S]*?)\s*```', response) - if json_match: - import json - return json.loads(json_match.group(1)) - - # 尝试直接解析 JSON - import json - return json.loads(response) - - except Exception as e: - logger.error(f"LLM call or parsing failed: {e}") - # 返回错误响应 - return { - "files": [], - "summary": f"LLM 生成失败: {str(e)}", - "warnings": [str(e)] - } - - async def _parse_and_validate( - self, llm_response: Dict[str, Any], file_contexts: Dict[str, FileInfo] - ) -> List[GeneratedFile]: - """解析和验证生成的代码""" - generated_files = [] - - for file_data in llm_response.get("files", []): - file_path = file_data.get("path", "") - content = file_data.get("content", "") - review_points = file_data.get("review_points", []) - - file_info = file_contexts.get(file_path) - if not file_info: - file_info = FileInfo( - path=file_path, - language=self._detect_language(file_path), - exists=False, - ) - - # 基础验证 - validation_warnings = self._validate_code_syntax(content, file_info.language) - if validation_warnings: - review_points.extend(validation_warnings) - - generated_files.append( - GeneratedFile( - path=file_path, - content=content, - language=file_info.language, - diff="", # 稍后生成 - is_new=not file_info.exists, - review_points=review_points, - ) - ) - - return generated_files - - def _validate_code_syntax(self, content: str, language: str) -> List[str]: - """验证代码语法""" - warnings = [] - - try: - if language == "python": - import ast - ast.parse(content) - elif language in ["typescript", "javascript"]: - # 简单的括号匹配检查 - if content.count("(") != content.count(")"): - warnings.append("括号不匹配") - if content.count("{") != content.count("}"): - warnings.append("花括号不匹配") - except SyntaxError as e: - warnings.append(f"语法错误: {str(e)}") - - return warnings - - def _generate_diff( - self, file_contexts: Dict[str, FileInfo], file: GeneratedFile - ) -> str: - """生成 unified diff""" - if file.is_new: - return f"--- /dev/null\n+++ {file.path}\n@@ -0,0 +1,{file.content.count(chr(10))} @@\n" + "\n".join( - f"+{line}" for line in file.content.split("\n") - ) - - # 对于修改的文件 - old_file_info = file_contexts.get(file.path) - old_content = old_file_info.content if old_file_info is not None else "" - old_lines = old_content.split("\n") - new_lines = file.content.split("\n") - - # 简化的 diff 生成(实际应该使用 difflib) - diff_lines = [f"--- {file.path}", f"+++ {file.path}"] - - # 找出差异的行范围 - changed_ranges = self._find_changed_ranges(old_lines, new_lines) - - for start_old, start_new, length in changed_ranges: - diff_lines.append(f"@@ -{start_old + 1},{length} +{start_new + 1},{length} @@") - for i in range(length): - old_line = old_lines[start_old + i] if start_old + i < len(old_lines) else "" - new_line = new_lines[start_new + i] if start_new + i < len(new_lines) else "" - - if old_line == new_line: - diff_lines.append(f" {new_line}") - elif new_line and not old_line: - diff_lines.append(f"+{new_line}") - elif old_line and not new_line: - diff_lines.append(f"-{old_line}") - else: - diff_lines.append(f"-{old_line}") - diff_lines.append(f"+{new_line}") - - return "\n".join(diff_lines) - - def _find_changed_ranges( - self, old_lines: List[str], new_lines: List[str] - ) -> List[tuple]: - """找出变化的代码行范围""" - # 简化实现:假设整个文件是变化的 - return [(0, 0, max(len(old_lines), len(new_lines)))] - - async def _extract_review_points( - self, generated_files: List[GeneratedFile], - file_contexts: Dict[str, FileInfo], - request: CodeDraftRequest, - ) -> List[str]: - """提取需要人工 review 的关键点""" - review_points = [] - - # 收集所有文件的 review points - for file in generated_files: - review_points.extend(file.review_points) - - # 添加通用 review points - review_points.extend([ - "验证生成的代码是否符合项目的编码规范", - "检查是否有安全漏洞(SQL注入、XSS等)", - "确认复杂业务逻辑的正确性", - "验证错误处理是否完整", - "检查是否有性能优化空间", - ]) - - # 根据战略分析添加特定 review points - strategic_tags = request.strategic_analysis.get("decision_proposal", {}).get("tags", []) - if "security" in strategic_tags: - review_points.append("重点关注安全相关代码") - if "performance" in strategic_tags: - review_points.append("验证性能指标是否达标") - - return list(set(review_points)) # 去重 - - def _calculate_confidence( - self, generated_files: List[GeneratedFile], file_contexts: Dict[str, FileInfo] - ) -> float: - """计算生成置信度""" - if not generated_files: - return 0.0 - - confidence = 0.7 # 基础置信度 - - # 根据文件数量调整 - confidence -= min(len(generated_files) * 0.05, 0.2) - - # 根据警告数量调整 - total_warnings = sum(len(f.review_points) for f in generated_files) - confidence -= min(total_warnings * 0.02, 0.3) - - return max(0.0, min(1.0, confidence)) - - def _estimate_complexity(self, generated_files: List[GeneratedFile]) -> str: - """估算代码复杂度""" - total_lines = sum(len(f.content.split("\n")) for f in generated_files) - - if total_lines < 100: - return "low" - elif total_lines < 300: - return "medium" - else: - return "high" - - def _generate_summary( - self, generated_files: List[GeneratedFile], request: CodeDraftRequest - ) -> str: - """生成变更摘要""" - new_files = [f for f in generated_files if f.is_new] - modified_files = [f for f in generated_files if not f.is_new] - - summary_parts = [ - f"为信号 '{request.signal_content[:50]}...' 生成代码草案", - f"新建文件: {len(new_files)} 个", - f"修改文件: {len(modified_files)} 个", - ] - - if modified_files: - summary_parts.append(f"主要变更: {modified_files[0].path}") - - return " | ".join(summary_parts) - - def _collect_warnings( - self, generated_files: List[GeneratedFile], file_contexts: Dict[str, FileInfo] - ) -> List[str]: - """收集警告信息""" - warnings = [] - - for file in generated_files: - if not file.content.strip(): - warnings.append(f"{file.path}: 文件内容为空") - - # 检查是否有 TODO/FIXME - if "TODO" in file.content or "FIXME" in file.content: - warnings.append(f"{file.path}: 包含未完成的标记") - - return warnings - - def _format_json(self, obj: Any) -> str: - """格式化 JSON""" - import json - return json.dumps(obj, ensure_ascii=False, indent=2) - - -# 全局服务实例 -_codegen_service = None - - -def get_codegen_service(llm_service) -> CodeDraftService: - """获取代码生成服务实例""" - global _codegen_service - if _codegen_service is None: - _codegen_service = CodeDraftService(llm_service) - return _codegen_service +"""Compatibility exports for code draft generation service.""" + +from .codegen_core import ( + CodeDraftRequest, + CodeDraftResponse, + CodeDraftService, + FileInfo, + GeneratedFile, + get_codegen_service, +) + +__all__ = [ + "CodeDraftRequest", + "FileInfo", + "GeneratedFile", + "CodeDraftResponse", + "CodeDraftService", + "get_codegen_service", +] diff --git a/backend/app/services/codegen_core/__init__.py b/backend/app/services/codegen_core/__init__.py new file mode 100644 index 00000000..16e0c2bf --- /dev/null +++ b/backend/app/services/codegen_core/__init__.py @@ -0,0 +1,14 @@ +"""Codegen service package exports.""" + +from .registry import get_codegen_service +from .schemas import CodeDraftRequest, CodeDraftResponse, FileInfo, GeneratedFile +from .service import CodeDraftService + +__all__ = [ + "CodeDraftRequest", + "FileInfo", + "GeneratedFile", + "CodeDraftResponse", + "CodeDraftService", + "get_codegen_service", +] diff --git a/backend/app/services/codegen_core/mixin_context.py b/backend/app/services/codegen_core/mixin_context.py new file mode 100644 index 00000000..27d41a46 --- /dev/null +++ b/backend/app/services/codegen_core/mixin_context.py @@ -0,0 +1,90 @@ +"""Repository/file context helpers for code draft generation.""" + +from __future__ import annotations + +from .schemas import CodeDraftRequest, FileInfo +from .shared import Any, Dict, Path, logger, os, re + + +class CodegenContextMixin: + """Handles target file reading and repository structure discovery.""" + + def _init_language_map(self) -> None: + self._language_map = { + ".py": "python", + ".ts": "typescript", + ".tsx": "typescript", + ".js": "javascript", + ".jsx": "javascript", + ".vue": "vue", + ".sql": "sql", + ".sh": "bash", + ".yaml": "yaml", + ".yml": "yaml", + ".json": "json", + } + + async def _read_target_files(self, request: CodeDraftRequest) -> Dict[str, FileInfo]: + """Read target file contents.""" + file_contexts: Dict[str, FileInfo] = {} + + for file_path in request.target_files: + full_path = os.path.join(request.project_root, file_path) + + info = FileInfo( + path=file_path, + language=self._detect_language(file_path), + exists=os.path.exists(full_path), + ) + + if info.exists: + try: + with open(full_path, "r", encoding="utf-8") as f: + info.content = f.read() + info.lines_count = len(info.content.split("\n")) + except Exception as exc: + logger.warning(f"Failed to read {file_path}: {exc}") + info.content = None + + file_contexts[file_path] = info + + return file_contexts + + def _detect_language(self, file_path: str) -> str: + """Detect file language by extension.""" + for ext, lang in self._language_map.items(): + if file_path.endswith(ext): + return lang + return "text" + + async def _analyze_repo_structure(self, request: CodeDraftRequest) -> Dict[str, Any]: + """Analyze repository imports/modules summary.""" + repo_context: Dict[str, Any] = { + "existing_modules": [], + "imports_available": [], + "patterns_used": [], + } + + try: + py_files = list(Path(request.project_root).rglob("*.py")) + for py_file in py_files[:20]: + try: + content = py_file.read_text(encoding="utf-8", errors="ignore") + imports = re.findall(r"^import (\w+)|^from (\w+) import", content, re.MULTILINE) + for imp in imports: + module = imp[0] or imp[1] + if module and module not in repo_context["imports_available"]: + repo_context["imports_available"].append(module) + except Exception: + pass + + except Exception as exc: + logger.warning(f"Repo structure analysis failed: {exc}") + + return repo_context + + def _format_json(self, obj: Any) -> str: + """Render compact pretty JSON for prompts.""" + import json + + return json.dumps(obj, ensure_ascii=False, indent=2) diff --git a/backend/app/services/codegen_core/mixin_prompt_llm.py b/backend/app/services/codegen_core/mixin_prompt_llm.py new file mode 100644 index 00000000..5b4813e9 --- /dev/null +++ b/backend/app/services/codegen_core/mixin_prompt_llm.py @@ -0,0 +1,120 @@ +"""Prompt building and LLM call helpers for code draft generation.""" + +from __future__ import annotations + +from .schemas import CodeDraftRequest, FileInfo +from .shared import Any, Dict, re, logger + + +class CodegenPromptLlmMixin: + """Builds generation prompts and parses model outputs.""" + + def _build_aggressive_prompt( + self, + request: CodeDraftRequest, + file_contexts: Dict[str, FileInfo], + repo_context: Dict[str, Any], + ) -> str: + """Build aggressive full-code generation prompt.""" + prompt = f"""You are a senior full-stack engineer. Generate COMPLETE, PRODUCTION-READY code based on the following requirements. + +## Signal (Requirement) +**Content**: {request.signal_content} + +## Strategic Analysis +```json +{self._format_json(request.strategic_analysis)} +``` + +## Available Imports/Modules +{', '.join(repo_context['imports_available'][:20])} + +## Existing Code Context +""" + + for file_path, info in file_contexts.items(): + prompt += f"\n### {file_path} ({info.language})\n" + if info.exists and info.content: + lines = info.content.split("\n") + key_lines = [] + for line in lines: + stripped = line.strip() + if any( + stripped.startswith(prefix) + for prefix in ["import ", "from ", "class ", "def ", "@", "async def "] + ): + key_lines.append(line) + elif len(key_lines) < 30: + key_lines.append(line) + + prompt += "```" + info.language + "\n" + prompt += "\n".join(key_lines) + if len(lines) > 30: + prompt += f"\n... ({len(lines) - 30} more lines)\n" + prompt += "```\n" + else: + prompt += "*(New file to be created)*\n" + + prompt += """ +## Code Generation Requirements +1. **Complete Implementation**: Generate FULL, WORKING code including complex business logic +2. **Error Handling**: Proper exception handling and edge cases +3. **Performance**: Consider performance implications +4. **Type Safety**: Use proper type hints +5. **Comments**: Add concise comments for complex logic +6. **Testing**: Consider testability + +## Output Format +Return a JSON response with this EXACT structure: +```json +{ + "files": [ + { + "path": "relative/path/to/file.ext", + "content": "full file content here", + "is_new": true/false, + "review_points": ["list of key points needing human review"] + } + ], + "summary": "Brief description of changes", + "warnings": ["any potential issues or risks"] +} +``` + +IMPORTANT: +- Generate complete, runnable code +- Handle edge cases and errors +- Follow existing code patterns in the project +- Highlight complex logic in review_points +""" + + return prompt + + async def _call_llm_for_code(self, prompt: str) -> Dict[str, Any]: + """Call LLM and parse JSON payload.""" + try: + response = await self.llm._call_llm_chat( + messages=[ + {"role": "system", "content": "You are an expert software engineer."}, + {"role": "user", "content": prompt}, + ], + temperature=0.3, + ) + + json_match = re.search(r"```json\s*([\s\S]*?)\s*```", response) + if json_match: + import json + + return json.loads(json_match.group(1)) + + import json + + return json.loads(response) + + except Exception as exc: + logger.error(f"LLM call or parsing failed: {exc}") + return { + "files": [], + "summary": f"LLM 生成失败: {str(exc)}", + "warnings": [str(exc)], + } diff --git a/backend/app/services/codegen_core/mixin_review_summary.py b/backend/app/services/codegen_core/mixin_review_summary.py new file mode 100644 index 00000000..7099b2b4 --- /dev/null +++ b/backend/app/services/codegen_core/mixin_review_summary.py @@ -0,0 +1,99 @@ +"""Review points, confidence, and summary helpers for code draft generation.""" + +from __future__ import annotations + +from .schemas import CodeDraftRequest, FileInfo, GeneratedFile +from .shared import Dict, List + + +class CodegenReviewSummaryMixin: + """Builds review output and quality metadata.""" + + async def _extract_review_points( + self, + generated_files: List[GeneratedFile], + file_contexts: Dict[str, FileInfo], + request: CodeDraftRequest, + ) -> List[str]: + """Extract key review points for generated draft.""" + review_points: List[str] = [] + + for file in generated_files: + review_points.extend(file.review_points) + + review_points.extend( + [ + "验证生成的代码是否符合项目的编码规范", + "检查是否有安全漏洞(SQL注入、XSS等)", + "确认复杂业务逻辑的正确性", + "验证错误处理是否完整", + "检查是否有性能优化空间", + ] + ) + + strategic_tags = request.strategic_analysis.get("decision_proposal", {}).get("tags", []) + if "security" in strategic_tags: + review_points.append("重点关注安全相关代码") + if "performance" in strategic_tags: + review_points.append("验证性能指标是否达标") + + return list(set(review_points)) + + def _calculate_confidence( + self, + generated_files: List[GeneratedFile], + file_contexts: Dict[str, FileInfo], + ) -> float: + """Compute draft confidence score.""" + if not generated_files: + return 0.0 + + confidence = 0.7 + confidence -= min(len(generated_files) * 0.05, 0.2) + + total_warnings = sum(len(f.review_points) for f in generated_files) + confidence -= min(total_warnings * 0.02, 0.3) + + return max(0.0, min(1.0, confidence)) + + def _estimate_complexity(self, generated_files: List[GeneratedFile]) -> str: + """Estimate overall complexity by generated line count.""" + total_lines = sum(len(f.content.split("\n")) for f in generated_files) + + if total_lines < 100: + return "low" + if total_lines < 300: + return "medium" + return "high" + + def _generate_summary(self, generated_files: List[GeneratedFile], request: CodeDraftRequest) -> str: + """Generate human-readable draft summary.""" + new_files = [f for f in generated_files if f.is_new] + modified_files = [f for f in generated_files if not f.is_new] + + summary_parts = [ + f"为信号 '{request.signal_content[:50]}...' 生成代码草案", + f"新建文件: {len(new_files)} 个", + f"修改文件: {len(modified_files)} 个", + ] + + if modified_files: + summary_parts.append(f"主要变更: {modified_files[0].path}") + + return " | ".join(summary_parts) + + def _collect_warnings( + self, + generated_files: List[GeneratedFile], + file_contexts: Dict[str, FileInfo], + ) -> List[str]: + """Collect warning messages from generated files.""" + warnings: List[str] = [] + + for file in generated_files: + if not file.content.strip(): + warnings.append(f"{file.path}: 文件内容为空") + if "TODO" in file.content or "FIXME" in file.content: + warnings.append(f"{file.path}: 包含未完成的标记") + + return warnings diff --git a/backend/app/services/codegen_core/mixin_validation_diff.py b/backend/app/services/codegen_core/mixin_validation_diff.py new file mode 100644 index 00000000..f2dc76d6 --- /dev/null +++ b/backend/app/services/codegen_core/mixin_validation_diff.py @@ -0,0 +1,104 @@ +"""Validation and diff helpers for code draft generation.""" + +from __future__ import annotations + +from .schemas import FileInfo, GeneratedFile +from .shared import Dict, List + + +class CodegenValidationDiffMixin: + """Parses model outputs, validates syntax, and builds diffs.""" + + async def _parse_and_validate( + self, + llm_response: Dict[str, object], + file_contexts: Dict[str, FileInfo], + ) -> List[GeneratedFile]: + """Parse and validate generated code payload.""" + generated_files: List[GeneratedFile] = [] + + for file_data in llm_response.get("files", []): + file_path = file_data.get("path", "") + content = file_data.get("content", "") + review_points = file_data.get("review_points", []) + + file_info = file_contexts.get(file_path) + if not file_info: + file_info = FileInfo( + path=file_path, + language=self._detect_language(file_path), + exists=False, + ) + + validation_warnings = self._validate_code_syntax(content, file_info.language) + if validation_warnings: + review_points.extend(validation_warnings) + + generated_files.append( + GeneratedFile( + path=file_path, + content=content, + language=file_info.language, + diff="", + is_new=not file_info.exists, + review_points=review_points, + ) + ) + + return generated_files + + def _validate_code_syntax(self, content: str, language: str) -> List[str]: + """Run lightweight syntax checks for generated code.""" + warnings: List[str] = [] + + try: + if language == "python": + import ast + + ast.parse(content) + elif language in ["typescript", "javascript"]: + if content.count("(") != content.count(")"): + warnings.append("括号不匹配") + if content.count("{") != content.count("}"): + warnings.append("花括号不匹配") + except SyntaxError as exc: + warnings.append(f"语法错误: {str(exc)}") + + return warnings + + def _generate_diff(self, file_contexts: Dict[str, FileInfo], file: GeneratedFile) -> str: + """Generate simplified unified diff.""" + if file.is_new: + return f"--- /dev/null\n+++ {file.path}\n@@ -0,0 +1,{file.content.count(chr(10))} @@\n" + "\n".join( + f"+{line}" for line in file.content.split("\n") + ) + + old_file_info = file_contexts.get(file.path) + old_content = old_file_info.content if old_file_info is not None else "" + old_lines = old_content.split("\n") + new_lines = file.content.split("\n") + + diff_lines = [f"--- {file.path}", f"+++ {file.path}"] + + changed_ranges = self._find_changed_ranges(old_lines, new_lines) + for start_old, start_new, length in changed_ranges: + diff_lines.append(f"@@ -{start_old + 1},{length} +{start_new + 1},{length} @@") + for i in range(length): + old_line = old_lines[start_old + i] if start_old + i < len(old_lines) else "" + new_line = new_lines[start_new + i] if start_new + i < len(new_lines) else "" + + if old_line == new_line: + diff_lines.append(f" {new_line}") + elif new_line and not old_line: + diff_lines.append(f"+{new_line}") + elif old_line and not new_line: + diff_lines.append(f"-{old_line}") + else: + diff_lines.append(f"-{old_line}") + diff_lines.append(f"+{new_line}") + + return "\n".join(diff_lines) + + def _find_changed_ranges(self, old_lines: List[str], new_lines: List[str]) -> List[tuple]: + """Return changed line ranges.""" + return [(0, 0, max(len(old_lines), len(new_lines)))] diff --git a/backend/app/services/codegen_core/registry.py b/backend/app/services/codegen_core/registry.py new file mode 100644 index 00000000..d3299d80 --- /dev/null +++ b/backend/app/services/codegen_core/registry.py @@ -0,0 +1,15 @@ +"""Singleton registry for code draft service.""" + +from __future__ import annotations + +from .service import CodeDraftService + +_codegen_service = None + + +def get_codegen_service(llm_service) -> CodeDraftService: + """Get singleton code draft generation service.""" + global _codegen_service + if _codegen_service is None: + _codegen_service = CodeDraftService(llm_service) + return _codegen_service diff --git a/backend/app/services/codegen_core/schemas.py b/backend/app/services/codegen_core/schemas.py new file mode 100644 index 00000000..6f62aac9 --- /dev/null +++ b/backend/app/services/codegen_core/schemas.py @@ -0,0 +1,58 @@ +"""Pydantic models for code draft generation.""" + +from __future__ import annotations + +from .shared import Any, BaseModel, Dict, Field, List, Optional + + +class CodeDraftRequest(BaseModel): + """Code draft generation request.""" + + signal_id: str + signal_content: str + strategic_analysis: Dict[str, Any] + target_files: List[str] + project_root: str = "." + + +class FileInfo(BaseModel): + """Target file metadata and optional content.""" + + path: str + language: str + exists: bool + content: Optional[str] = None + lines_count: int = 0 + + +class GeneratedFile(BaseModel): + """Generated file payload.""" + + path: str + content: str + language: str + diff: str + is_new: bool + review_points: List[str] = [] + + +class CodeDraftResponse(BaseModel): + """Code draft generation response.""" + + success: bool + files: List[GeneratedFile] + summary: str + confidence: float = Field(default=0.0, ge=0.0, le=1.0) + warnings: List[str] = Field(default_factory=list) + review_points: List[str] = Field(default_factory=list) + estimated_complexity: str = "low" + generation_time: float = 0.0 + metadata: Dict[str, Any] = Field(default_factory=dict) + + +__all__ = [ + "CodeDraftRequest", + "FileInfo", + "GeneratedFile", + "CodeDraftResponse", +] diff --git a/backend/app/services/codegen_core/service.py b/backend/app/services/codegen_core/service.py new file mode 100644 index 00000000..57e05902 --- /dev/null +++ b/backend/app/services/codegen_core/service.py @@ -0,0 +1,73 @@ +"""Code draft generation service composition.""" + +from __future__ import annotations + +from .mixin_context import CodegenContextMixin +from .mixin_prompt_llm import CodegenPromptLlmMixin +from .mixin_review_summary import CodegenReviewSummaryMixin +from .mixin_validation_diff import CodegenValidationDiffMixin +from .schemas import CodeDraftRequest, CodeDraftResponse +from .shared import logger, now_cn_aware + + +class CodeDraftService( + CodegenContextMixin, + CodegenPromptLlmMixin, + CodegenValidationDiffMixin, + CodegenReviewSummaryMixin, +): + """AI code draft generation service (aggressive strategy).""" + + def __init__(self, llm_service): + self.llm = llm_service + self._init_language_map() + + async def generate_draft(self, request: CodeDraftRequest) -> CodeDraftResponse: + """Generate draft code for the given signal.""" + start_time = now_cn_aware().timestamp() + + try: + file_contexts = await self._read_target_files(request) + repo_context = await self._analyze_repo_structure(request) + prompt = self._build_aggressive_prompt(request, file_contexts, repo_context) + llm_response = await self._call_llm_for_code(prompt) + generated_files = await self._parse_and_validate(llm_response, file_contexts) + + for file in generated_files: + file.diff = self._generate_diff(file_contexts, file) + + review_points = await self._extract_review_points(generated_files, file_contexts, request) + confidence = self._calculate_confidence(generated_files, file_contexts) + complexity = self._estimate_complexity(generated_files) + summary = self._generate_summary(generated_files, request) + warnings = self._collect_warnings(generated_files, file_contexts) + generation_time = now_cn_aware().timestamp() - start_time + + return CodeDraftResponse( + success=True, + files=generated_files, + summary=summary, + confidence=confidence, + warnings=warnings, + review_points=review_points, + estimated_complexity=complexity, + generation_time=generation_time, + metadata={ + "signal_id": request.signal_id, + "files_count": len(generated_files), + "new_files": sum(1 for f in generated_files if f.is_new), + "modified_files": sum(1 for f in generated_files if not f.is_new), + }, + ) + + except Exception as exc: + logger.error(f"Code generation failed: {exc}", exc_info=True) + return CodeDraftResponse( + success=False, + files=[], + summary=f"代码生成失败: {str(exc)}", + confidence=0.0, + warnings=[f"生成过程出错: {str(exc)}"], + review_points=[], + generation_time=now_cn_aware().timestamp() - start_time, + ) diff --git a/backend/app/services/codegen_core/shared.py b/backend/app/services/codegen_core/shared.py new file mode 100644 index 00000000..5e02bbb1 --- /dev/null +++ b/backend/app/services/codegen_core/shared.py @@ -0,0 +1,35 @@ +"""Shared imports and constants for code draft generation service.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime +import logging +import os +from pathlib import Path +import re +import subprocess +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + +from ...utils.timezone import now_cn_aware + +logger = logging.getLogger(__name__) + +__all__ = [ + "Any", + "BaseModel", + "Dict", + "Field", + "List", + "Optional", + "Path", + "asyncio", + "datetime", + "logger", + "now_cn_aware", + "os", + "re", + "subprocess", +] diff --git a/backend/app/services/cognitive_conflict.py b/backend/app/services/cognitive_conflict.py index 26475a88..a53eafb1 100644 --- a/backend/app/services/cognitive_conflict.py +++ b/backend/app/services/cognitive_conflict.py @@ -1,473 +1,8 @@ -""" -Cognitive Conflict Detection & Resolution Service - 认知冲突检测与解决服务 +"""Compatibility exports for cognitive conflict service.""" -核心功能: -1. 自动检测团队隐性认知差异 -2. 识别定义不一致(如"留存"vs"DAU") -3. 检测目标冲突(产品说X,开发实现Y) -4. 主动推送对齐提醒 -5. 异步 1v1 认知对齐 -""" -import asyncio -import json -from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Optional -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select -from sqlalchemy import text - -from ..models import ( - User, Project, TruthNode, Task, - CognitiveConflict, ProjectDomain +from .cognitive_conflict_core import ( + CognitiveConflictService, + get_cognitive_conflict_service, ) -from .llm import LLMService - -class CognitiveConflictService: - """认知冲突检测与解决服务""" - - def __init__(self, db: AsyncSession): - self.db = db - self.llm = LLMService() - - async def detect_charter_conflicts( - self, - project_id: int, - tenant_id: int = 1 - ) -> List[Dict[str, Any]]: - """ - 检测项目宪章中的潜在冲突 - - 扫描: - 1. 宪章中的成功指标定义是否清晰 - 2. 是否有模糊的目标描述 - 3. 是否有相互冲突的约束 - """ - # 获取项目宪章 - result = await self.db.execute( - select(TruthNode).where( - TruthNode.project_id == project_id, - TruthNode.node_type == 'charter', - TruthNode.tenant_id == tenant_id - ) - ) - charter = result.scalar_one_or_none() - - if not charter: - return [] - - # AI 分析宪章内容 - conflicts = await self._analyze_charter_for_conflicts( - charter.content, - tenant_id=tenant_id, - ) - - # 保存检测到的冲突 - for conflict_data in conflicts: - await self._create_conflict_record( - truth_node_id=charter.id, - conflict_type=conflict_data['type'], - description=conflict_data['description'], - severity=conflict_data['severity'], - suggested_resolution=conflict_data['suggested_resolution'], - tenant_id=tenant_id - ) - - return conflicts - - async def detect_task_charter_mismatch( - self, - task: Task, - charter_content: str, - tenant_id: int = 1 - ) -> Optional[Dict[str, Any]]: - """ - 检测任务与宪章的一致性 - - 检查: - 1. 任务描述是否与宪章目标一致 - 2. 技术选型是否符合约束 - 3. 是否偏离项目方向 - """ - prompt = f"""检查以下任务是否符合项目宪章: - -宪章内容: -{charter_content} - -任务信息: -标题:{task.title} -描述:{task.description} -优先级:{task.priority} -预计工时:{task.estimated_hours} 小时 - -请判断: -1. 任务是否与宪章目标一致? -2. 是否有潜在的偏离? -3. 是否存在隐性冲突? - -以 JSON 格式返回: -{{ - "is_aligned": boolean, - "conflict_type": "目标不一致/技术约束冲突/优先级错配", - "description": "冲突描述", - "severity": "high/medium/low", - "suggestion": "建议调整" -}} - -如果任务与宪章一致,is_aligned 设为 true,conflict_type 为 null。""" - - try: - response = await self.llm.generate( - prompt, - db=self.db, - tenant_id=tenant_id, - project_id=task.project_id if getattr(task, "project_id", None) is not None else None, - ) - response = response.strip() - if response.startswith('```'): - response = response.split('```')[1] - if response.startswith('json'): - response = response[4:] - result = json.loads(response.strip()) - - if not result.get('is_aligned', True): - # 创建冲突记录 - await self._create_conflict_record( - truth_node_id=task.truth_node_id, - conflict_type=result.get('conflict_type', 'unknown'), - description=result.get('description', ''), - severity=result.get('severity', 'medium'), - suggested_resolution=result.get('suggestion', ''), - related_task_id=task.id, - tenant_id=tenant_id - ) - return result - - return None - - except Exception as e: - print(f"[CognitiveConflict] Task mismatch detection failed: {e}") - return None - - async def detect_user_definition_conflicts( - self, - project_id: int, - tenant_id: int = 1 - ) -> List[Dict[str, Any]]: - """ - 检测用户定义冲突 - - 例如: - - 产品在宪章中写"用户留存提升10%" - - 开发在实现"DAU提升10%" - - 双方都没有争论,但目标不一致 - """ - # 获取项目的所有宪章和任务 - charters_result = await self.db.execute( - select(TruthNode).where( - TruthNode.project_id == project_id, - TruthNode.node_type == 'charter', - TruthNode.tenant_id == tenant_id - ) - ) - charters = charters_result.scalars().all() - - tasks_result = await self.db.execute( - select(Task).where( - Task.project_id == project_id, - Task.tenant_id == tenant_id - ) - ) - tasks = tasks_result.scalars().all() - - # 构建所有定义的上下文 - definitions = [] - - for charter in charters: - definitions.append({ - 'source_type': 'charter', - 'source_id': charter.id, - 'content': charter.content - }) - - for task in tasks: - definitions.append({ - 'source_type': 'task', - 'source_id': task.id, - 'content': f"{task.title}: {task.description}" - }) - - if not definitions: - return [] - - # AI 检测定义冲突 - prompt = f"""分析以下项目定义中是否存在隐性认知冲突: - -{json.dumps(definitions, ensure_ascii=False, indent=2)} - -请查找: -1. 相同概念的不同表述(如"留存"vs"DAU") -2. 相矛盾的目标(如"降低成本"vs"增加功能") -3. 不一致的指标定义 - -以 JSON 格式返回: -[ - {{ - "conflict_type": "定义不一致", - "description": "具体描述冲突", - "involved_sources": ["charter:1", "task:2"], - "severity": "high/medium/low", - "suggested_alignment": "建议的对齐方案" - }} -] - -如果没有发现冲突,返回空数组。""" - - try: - response = await self.llm.generate( - prompt, - db=self.db, - tenant_id=tenant_id, - project_id=project_id, - ) - response = response.strip() - if response.startswith('```'): - response = response.split('```')[1] - if response.startswith('json'): - response = response[4:] - conflicts = json.loads(response.strip()) - - # 保存检测到的冲突 - for conflict in conflicts: - await self._create_conflict_record( - truth_node_id=int(conflict['involved_sources'][0].split(':')[1]), - conflict_type=conflict['conflict_type'], - description=conflict['description'], - severity=conflict['severity'], - suggested_resolution=conflict.get('suggested_alignment', ''), - tenant_id=tenant_id - ) - - return conflicts - - except Exception as e: - print(f"[CognitiveConflict] Definition conflict detection failed: {e}") - return [] - - async def propose_alignment_resolution( - self, - conflict_id: int, - tenant_id: int = 1 - ) -> Dict[str, Any]: - """ - 提议认知冲突解决方案 - - 为冲突双方生成对齐方案: - 1. 分析冲突根因 - 2. 提出妥协方案 - 3. 生成可执行的对齐计划 - """ - # 获取冲突记录 - result = await self.db.execute( - select(CognitiveConflict).where( - CognitiveConflict.id == conflict_id, - CognitiveConflict.tenant_id == tenant_id - ) - ) - conflict = result.scalar_one_or_none() - - if not conflict: - raise ValueError(f"Conflict {conflict_id} not found") - - # AI 生成解决方案 - prompt = f"""为以下认知冲突提供解决方案: - -冲突类型:{conflict.conflict_type} -描述:{conflict.description} - -请提供: -1. 冲突根因分析 -2. 双方都能接受的妥协方案 -3. 具体的对齐步骤 - -以 JSON 格式返回: -{{ - "root_cause": "根本原因", - "resolution_plan": ["步骤1", "步骤2"], - "compromise": "妥协方案", - "expected_outcome": "预期结果" -}}""" - - try: - response = await self.llm.generate( - prompt, - db=self.db, - tenant_id=tenant_id, - ) - response = response.strip() - if response.startswith('```'): - response = response.split('```')[1] - if response.startswith('json'): - response = response[4:] - resolution = json.loads(response.strip()) - - # 更新冲突状态 - conflict.status = 'resolution_proposed' - conflict.description += f"\n\nAI 建议方案:{resolution.get('compromise', '')}" - - await self.db.commit() - - return resolution - - except Exception as e: - print(f"[CognitiveConflict] Resolution proposal failed: {e}") - return {} - - async def trigger_async_alignment( - self, - conflict_id: int, - user1_id: int, - user2_id: int, - tenant_id: int = 1 - ) -> Dict[str, Any]: - """ - 触发异步 1v1 认知对齐 - - 为冲突双方创建独立的对齐任务: - 1. 发送对齐邀请 - 2. 提供背景信息 - 3. 收集双方观点 - 4. AI 生成共识方案 - """ - # 获取用户信息 - result1 = await self.db.execute( - select(User).where(User.id == user1_id) - ) - user1 = result1.scalar_one_or_none() - - result2 = await self.db.execute( - select(User).where(User.id == user2_id) - ) - user2 = result2.scalar_one_or_none() - - if not user1 or not user2: - raise ValueError("Users not found") - - # 获取冲突详情 - result = await self.db.execute( - select(CognitiveConflict).where( - CognitiveConflict.id == conflict_id, - CognitiveConflict.tenant_id == tenant_id - ) - ) - conflict = result.scalar_one_or_none() - - # TODO: 实际实现需要: - # 1. 发送飞书消息/邮件通知双方 - # 2. 创建对齐任务 - # 3. 记录对齐过程 - - return { - 'alignment_session_id': f"align_{conflict_id}", - 'user1': user1.username, - 'user2': user2.username, - 'conflict': conflict.conflict_type, - 'status': 'initiated' - } - - async def _create_conflict_record( - self, - truth_node_id: int, - conflict_type: str, - description: str, - severity: str, - suggested_resolution: str = "", - related_task_id: Optional[int] = None, - tenant_id: int = 1 - ): - """创建认知冲突记录""" - from sqlalchemy import text - next_conflict_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM cognitive_conflicts"))).scalar() or 1 - - conflict = CognitiveConflict( - id=next_conflict_id, - truth_node_id=truth_node_id, - conflict_type=conflict_type, - description=description, - involved_user_ids=json.dumps([]), - status='detected', - tenant_id=tenant_id - ) - self.db.add(conflict) - await self.db.commit() - - async def _analyze_charter_for_conflicts( - self, - charter_content: str, - tenant_id: int, - ) -> List[Dict[str, Any]]: - """AI 分析宪章内容中的冲突""" - prompt = f"""分析以下项目宪章中是否存在潜在冲突或问题: - -{charter_content} - -请检查: -1. 目标是否模糊或相互矛盾? -2. 约束条件是否现实? -3. 成功指标是否可衡量? -4. 是否遗漏重要方面? - -以 JSON 格式返回: -[ - {{ - "type": "目标模糊/约束冲突/指标不可测量", - "description": "具体问题描述", - "severity": "high/medium/low", - "suggested_resolution": "建议的改进方案" - }} -""" - - try: - response = await self.llm.generate( - prompt, - db=self.db, - tenant_id=tenant_id, - ) - response = response.strip() - if response.startswith('```'): - response = response.split('```')[1] - if response.startswith('json'): - response = response[4:] - return json.loads(response.strip()) - except: - return [] - - async def get_detected_conflicts( - self, - tenant_id: int = 1 - ) -> List[Dict[str, Any]]: - """获取所有检测到的冲突""" - result = await self.db.execute( - select(CognitiveConflict).where( - CognitiveConflict.tenant_id == tenant_id, - CognitiveConflict.status == 'detected' - ) - ) - conflicts = result.scalars().all() - - return [ - { - 'id': str(c.id), - 'type': c.conflict_type, - 'description': c.description, - 'severity': 'medium' if 'high' not in c.description.lower() else 'high', - 'truth_node_id': str(c.truth_node_id) - } - for c in conflicts - ] - - -# 全局服务实例 -def get_cognitive_conflict_service(db: AsyncSession) -> CognitiveConflictService: - """获取认知冲突服务实例""" - return CognitiveConflictService(db) +__all__ = ["CognitiveConflictService", "get_cognitive_conflict_service"] diff --git a/backend/app/services/cognitive_conflict_core/__init__.py b/backend/app/services/cognitive_conflict_core/__init__.py new file mode 100644 index 00000000..2992a8e6 --- /dev/null +++ b/backend/app/services/cognitive_conflict_core/__init__.py @@ -0,0 +1,6 @@ +"""Cognitive conflict service exports.""" + +from .registry import get_cognitive_conflict_service +from .service import CognitiveConflictService + +__all__ = ["CognitiveConflictService", "get_cognitive_conflict_service"] diff --git a/backend/app/services/cognitive_conflict_core/mixin_detection.py b/backend/app/services/cognitive_conflict_core/mixin_detection.py new file mode 100644 index 00000000..aaefb101 --- /dev/null +++ b/backend/app/services/cognitive_conflict_core/mixin_detection.py @@ -0,0 +1,207 @@ +"""Conflict detection flows for cognitive conflict service.""" + +from __future__ import annotations + +from .shared import Any, Dict, List, Task, TruthNode, json, select + + +class CognitiveConflictDetectionMixin: + """Detect charter/task/definition conflicts.""" + + async def detect_charter_conflicts( + self, + project_id: int, + tenant_id: int = 1, + ) -> List[Dict[str, Any]]: + """检测项目宪章中的潜在冲突。""" + result = await self.db.execute( + select(TruthNode).where( + TruthNode.project_id == project_id, + TruthNode.node_type == "charter", + TruthNode.tenant_id == tenant_id, + ) + ) + charter = result.scalar_one_or_none() + + if not charter: + return [] + + conflicts = await self._analyze_charter_for_conflicts( + charter.content, + tenant_id=tenant_id, + ) + + for conflict_data in conflicts: + await self._create_conflict_record( + truth_node_id=charter.id, + conflict_type=conflict_data["type"], + description=conflict_data["description"], + severity=conflict_data["severity"], + suggested_resolution=conflict_data["suggested_resolution"], + tenant_id=tenant_id, + ) + + return conflicts + + async def detect_task_charter_mismatch( + self, + task: Task, + charter_content: str, + tenant_id: int = 1, + ) -> Dict[str, Any] | None: + """检测任务与宪章的一致性。""" + prompt = f"""检查以下任务是否符合项目宪章: + +宪章内容: +{charter_content} + +任务信息: +标题:{task.title} +描述:{task.description} +优先级:{task.priority} +预计工时:{task.estimated_hours} 小时 + +请判断: +1. 任务是否与宪章目标一致? +2. 是否有潜在的偏离? +3. 是否存在隐性冲突? + +以 JSON 格式返回: +{{ + "is_aligned": boolean, + "conflict_type": "目标不一致/技术约束冲突/优先级错配", + "description": "冲突描述", + "severity": "high/medium/low", + "suggestion": "建议调整" +}} + +如果任务与宪章一致,is_aligned 设为 true,conflict_type 为 null。""" + + try: + response = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + project_id=task.project_id if getattr(task, "project_id", None) is not None else None, + ) + response = response.strip() + if response.startswith("```"): + response = response.split("```")[1] + if response.startswith("json"): + response = response[4:] + result = json.loads(response.strip()) + + if not result.get("is_aligned", True): + await self._create_conflict_record( + truth_node_id=task.truth_node_id, + conflict_type=result.get("conflict_type", "unknown"), + description=result.get("description", ""), + severity=result.get("severity", "medium"), + suggested_resolution=result.get("suggestion", ""), + related_task_id=task.id, + tenant_id=tenant_id, + ) + return result + + return None + + except Exception as exc: + print(f"[CognitiveConflict] Task mismatch detection failed: {exc}") + return None + + async def detect_user_definition_conflicts( + self, + project_id: int, + tenant_id: int = 1, + ) -> List[Dict[str, Any]]: + """检测用户定义冲突。""" + charters_result = await self.db.execute( + select(TruthNode).where( + TruthNode.project_id == project_id, + TruthNode.node_type == "charter", + TruthNode.tenant_id == tenant_id, + ) + ) + charters = charters_result.scalars().all() + + tasks_result = await self.db.execute( + select(Task).where( + Task.project_id == project_id, + Task.tenant_id == tenant_id, + ) + ) + tasks = tasks_result.scalars().all() + + definitions = [] + for charter in charters: + definitions.append( + { + "source_type": "charter", + "source_id": charter.id, + "content": charter.content, + } + ) + + for task in tasks: + definitions.append( + { + "source_type": "task", + "source_id": task.id, + "content": f"{task.title}: {task.description}", + } + ) + + if not definitions: + return [] + + prompt = f"""分析以下项目定义中是否存在隐性认知冲突: + +{json.dumps(definitions, ensure_ascii=False, indent=2)} + +请查找: +1. 相同概念的不同表述(如"留存"vs"DAU") +2. 相矛盾的目标(如"降低成本"vs"增加功能") +3. 不一致的指标定义 + +以 JSON 格式返回: +[ + {{ + "conflict_type": "定义不一致", + "description": "具体描述冲突", + "involved_sources": ["charter:1", "task:2"], + "severity": "high/medium/low", + "suggested_alignment": "建议的对齐方案" + }} +] + +如果没有发现冲突,返回空数组。""" + + try: + response = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + project_id=project_id, + ) + response = response.strip() + if response.startswith("```"): + response = response.split("```")[1] + if response.startswith("json"): + response = response[4:] + conflicts = json.loads(response.strip()) + + for conflict in conflicts: + await self._create_conflict_record( + truth_node_id=int(conflict["involved_sources"][0].split(":")[1]), + conflict_type=conflict["conflict_type"], + description=conflict["description"], + severity=conflict["severity"], + suggested_resolution=conflict.get("suggested_alignment", ""), + tenant_id=tenant_id, + ) + + return conflicts + + except Exception as exc: + print(f"[CognitiveConflict] Definition conflict detection failed: {exc}") + return [] diff --git a/backend/app/services/cognitive_conflict_core/mixin_resolution.py b/backend/app/services/cognitive_conflict_core/mixin_resolution.py new file mode 100644 index 00000000..8a547d9d --- /dev/null +++ b/backend/app/services/cognitive_conflict_core/mixin_resolution.py @@ -0,0 +1,100 @@ +"""Resolution and alignment flows for cognitive conflict service.""" + +from __future__ import annotations + +from .shared import Any, Dict, CognitiveConflict, User, json, select + + +class CognitiveConflictResolutionMixin: + """Generate resolution plans and trigger async alignments.""" + + async def propose_alignment_resolution( + self, + conflict_id: int, + tenant_id: int = 1, + ) -> Dict[str, Any]: + """提议认知冲突解决方案。""" + result = await self.db.execute( + select(CognitiveConflict).where( + CognitiveConflict.id == conflict_id, + CognitiveConflict.tenant_id == tenant_id, + ) + ) + conflict = result.scalar_one_or_none() + + if not conflict: + raise ValueError(f"Conflict {conflict_id} not found") + + prompt = f"""为以下认知冲突提供解决方案: + +冲突类型:{conflict.conflict_type} +描述:{conflict.description} + +请提供: +1. 冲突根因分析 +2. 双方都能接受的妥协方案 +3. 具体的对齐步骤 + +以 JSON 格式返回: +{{ + "root_cause": "根本原因", + "resolution_plan": ["步骤1", "步骤2"], + "compromise": "妥协方案", + "expected_outcome": "预期结果" +}}""" + + try: + response = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + ) + response = response.strip() + if response.startswith("```"): + response = response.split("```")[1] + if response.startswith("json"): + response = response[4:] + resolution = json.loads(response.strip()) + + conflict.status = "resolution_proposed" + conflict.description += f"\n\nAI 建议方案:{resolution.get('compromise', '')}" + + await self.db.commit() + return resolution + + except Exception as exc: + print(f"[CognitiveConflict] Resolution proposal failed: {exc}") + return {} + + async def trigger_async_alignment( + self, + conflict_id: int, + user1_id: int, + user2_id: int, + tenant_id: int = 1, + ) -> Dict[str, Any]: + """触发异步 1v1 认知对齐。""" + result1 = await self.db.execute(select(User).where(User.id == user1_id)) + user1 = result1.scalar_one_or_none() + + result2 = await self.db.execute(select(User).where(User.id == user2_id)) + user2 = result2.scalar_one_or_none() + + if not user1 or not user2: + raise ValueError("Users not found") + + result = await self.db.execute( + select(CognitiveConflict).where( + CognitiveConflict.id == conflict_id, + CognitiveConflict.tenant_id == tenant_id, + ) + ) + conflict = result.scalar_one_or_none() + + return { + "alignment_session_id": f"align_{conflict_id}", + "user1": user1.username, + "user2": user2.username, + "conflict": conflict.conflict_type, + "status": "initiated", + } diff --git a/backend/app/services/cognitive_conflict_core/mixin_storage.py b/backend/app/services/cognitive_conflict_core/mixin_storage.py new file mode 100644 index 00000000..a33fa139 --- /dev/null +++ b/backend/app/services/cognitive_conflict_core/mixin_storage.py @@ -0,0 +1,98 @@ +"""Persistence and listing helpers for cognitive conflict service.""" + +from __future__ import annotations + +from .shared import Any, CognitiveConflict, Dict, List, Optional, json, select, text + + +class CognitiveConflictStorageMixin: + """Persist conflict records and query detected conflicts.""" + + async def _create_conflict_record( + self, + truth_node_id: int, + conflict_type: str, + description: str, + severity: str, + suggested_resolution: str = "", + related_task_id: Optional[int] = None, + tenant_id: int = 1, + ): + """创建认知冲突记录。""" + next_conflict_id = ( + await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM cognitive_conflicts")) + ).scalar() or 1 + + conflict = CognitiveConflict( + id=next_conflict_id, + truth_node_id=truth_node_id, + conflict_type=conflict_type, + description=description, + involved_user_ids=json.dumps([]), + status="detected", + tenant_id=tenant_id, + ) + self.db.add(conflict) + await self.db.commit() + + async def _analyze_charter_for_conflicts( + self, + charter_content: str, + tenant_id: int, + ) -> List[Dict[str, Any]]: + """AI 分析宪章内容中的冲突。""" + prompt = f"""分析以下项目宪章中是否存在潜在冲突或问题: + +{charter_content} + +请检查: +1. 目标是否模糊或相互矛盾? +2. 约束条件是否现实? +3. 成功指标是否可衡量? +4. 是否遗漏重要方面? + +以 JSON 格式返回: +[ + {{ + "type": "目标模糊/约束冲突/指标不可测量", + "description": "具体问题描述", + "severity": "high/medium/low", + "suggested_resolution": "建议的改进方案" + }} +""" + + try: + response = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + ) + response = response.strip() + if response.startswith("```"): + response = response.split("```")[1] + if response.startswith("json"): + response = response[4:] + return json.loads(response.strip()) + except Exception: + return [] + + async def get_detected_conflicts(self, tenant_id: int = 1) -> List[Dict[str, Any]]: + """获取所有检测到的冲突。""" + result = await self.db.execute( + select(CognitiveConflict).where( + CognitiveConflict.tenant_id == tenant_id, + CognitiveConflict.status == "detected", + ) + ) + conflicts = result.scalars().all() + + return [ + { + "id": str(c.id), + "type": c.conflict_type, + "description": c.description, + "severity": "medium" if "high" not in c.description.lower() else "high", + "truth_node_id": str(c.truth_node_id), + } + for c in conflicts + ] diff --git a/backend/app/services/cognitive_conflict_core/registry.py b/backend/app/services/cognitive_conflict_core/registry.py new file mode 100644 index 00000000..ba000346 --- /dev/null +++ b/backend/app/services/cognitive_conflict_core/registry.py @@ -0,0 +1,11 @@ +"""Factory for cognitive conflict service.""" + +from __future__ import annotations + +from .service import CognitiveConflictService +from .shared import AsyncSession + + +def get_cognitive_conflict_service(db: AsyncSession) -> CognitiveConflictService: + """获取认知冲突服务实例。""" + return CognitiveConflictService(db) diff --git a/backend/app/services/cognitive_conflict_core/service.py b/backend/app/services/cognitive_conflict_core/service.py new file mode 100644 index 00000000..ef967ef4 --- /dev/null +++ b/backend/app/services/cognitive_conflict_core/service.py @@ -0,0 +1,20 @@ +"""Cognitive conflict service composition.""" + +from __future__ import annotations + +from .mixin_detection import CognitiveConflictDetectionMixin +from .mixin_resolution import CognitiveConflictResolutionMixin +from .mixin_storage import CognitiveConflictStorageMixin +from .shared import AsyncSession, LLMService + + +class CognitiveConflictService( + CognitiveConflictStorageMixin, + CognitiveConflictDetectionMixin, + CognitiveConflictResolutionMixin, +): + """认知冲突检测与解决服务。""" + + def __init__(self, db: AsyncSession): + self.db = db + self.llm = LLMService() diff --git a/backend/app/services/cognitive_conflict_core/shared.py b/backend/app/services/cognitive_conflict_core/shared.py new file mode 100644 index 00000000..81db498a --- /dev/null +++ b/backend/app/services/cognitive_conflict_core/shared.py @@ -0,0 +1,37 @@ +"""Shared imports for cognitive conflict service.""" + +from __future__ import annotations + +import asyncio +import json +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from ...models import CognitiveConflict, Project, ProjectDomain, Task, TruthNode, User +from ..llm import LLMService + +__all__ = [ + "Any", + "AsyncSession", + "CognitiveConflict", + "Dict", + "LLMService", + "List", + "Optional", + "Project", + "ProjectDomain", + "Task", + "TruthNode", + "User", + "asyncio", + "datetime", + "json", + "select", + "text", + "timedelta", + "timezone", +] diff --git a/backend/app/services/context_cache.py b/backend/app/services/context_cache.py index 2a9d9e14..fc68eaad 100644 --- a/backend/app/services/context_cache.py +++ b/backend/app/services/context_cache.py @@ -1,1172 +1,5 @@ -"""AI Steward 上下文缓存服务(Redis)与失效钩子。""" - from __future__ import annotations -import json -import os -import re -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Optional +from .context_cache_service import ContextCacheInvalidator, ContextCacheService, context_cache_service, register_context_cache_invalidation_hooks -from sqlalchemy import and_, case, event, func, inspect, or_, select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import Session - -from ..models import Project, ProjectSignalLink, Signal, Task, User, UserProject, UserWorkload - -from ..utils.timezone import now_cn_aware -try: - import redis.asyncio as redis_async - from redis import Redis as RedisSync - - REDIS_AVAILABLE = True -except Exception: # pragma: no cover - REDIS_AVAILABLE = False - redis_async = None - RedisSync = None - - -SYSTEM_TTL_SECONDS = 30 * 60 -USER_TTL_SECONDS = 5 * 60 -SESSION_TTL_SECONDS = 60 * 60 -PROJECT_LIST_TTL_SECONDS = 10 * 60 -SIGNAL_PENDING_TTL_SECONDS = 5 * 60 -SESSION_MAX_MESSAGES = 20 -DONE_TASK_STATUSES = {"done", "completed", "closed", "resolved", "archived"} -PENDING_SIGNAL_STATUSES = {"pending", "todo", "open", "待处理"} - - -def _safe_int(value: Any, fallback: int = 0) -> int: - try: - return int(value) - except Exception: - return fallback - - -def _json_loads(raw: Optional[str], default: Any) -> Any: - if not raw: - return default - try: - return json.loads(raw) - except Exception: - return default - - -class ContextCacheService: - def __init__(self, redis_url: Optional[str] = None) -> None: - self.redis_url = redis_url or os.getenv("REDIS_URL") - self._redis: Optional[Any] = None - - @staticmethod - def _system_key(tenant_id: int) -> str: - return f"pmflow:context:system:{tenant_id}" - - @staticmethod - def _user_key(tenant_id: int, user_id: int) -> str: - return f"pmflow:context:user:{tenant_id}:{user_id}" - - @staticmethod - def _session_key(tenant_id: int, session_id: int) -> str: - return f"pmflow:qa:session:{tenant_id}:{session_id}" - - @staticmethod - def _project_list_key(tenant_id: int) -> str: - return f"pmflow:project:list:{tenant_id}" - - @staticmethod - def _signal_pending_key(tenant_id: int) -> str: - return f"pmflow:signal:pending_count:{tenant_id}" - - async def _get_redis(self): - if not REDIS_AVAILABLE or not self.redis_url: - return None - if self._redis is None: - self._redis = redis_async.from_url(self.redis_url, decode_responses=True) - return self._redis - - async def _get_json(self, key: str) -> Optional[dict[str, Any]]: - redis = await self._get_redis() - if redis is None: - return None - try: - raw = await redis.get(key) - except Exception: - return None - if not raw: - return None - payload = _json_loads(raw, None) - return payload if isinstance(payload, dict) else None - - async def _set_json(self, key: str, value: dict[str, Any], ttl: int) -> None: - redis = await self._get_redis() - if redis is None: - return - try: - await redis.setex(key, ttl, json.dumps(value, ensure_ascii=False)) - except Exception: - pass - - async def get_json_key(self, key: str) -> Optional[dict[str, Any]]: - return await self._get_json(key) - - async def set_json_key(self, key: str, value: dict[str, Any], ttl: int) -> None: - await self._set_json(key, value, ttl) - - async def delete_keys(self, *keys: str) -> None: - valid_keys = [str(key).strip() for key in keys if str(key).strip()] - if not valid_keys: - return - redis = await self._get_redis() - if redis is None: - return - try: - await redis.delete(*valid_keys) - except Exception: - pass - - @staticmethod - def _is_system_snapshot_complete(payload: Optional[dict[str, Any]]) -> bool: - if not isinstance(payload, dict): - return False - return isinstance(payload.get("assistant_profile"), dict) and isinstance(payload.get("platform_profile"), dict) - - async def get_system_snapshot(self, *, db: AsyncSession, tenant_id: int) -> dict[str, Any]: - tid = _safe_int(tenant_id, 1) - key = self._system_key(tid) - cached = await self._get_json(key) - if self._is_system_snapshot_complete(cached): - return cached - - payload = await self._build_system_snapshot(db=db, tenant_id=tid) - await self._set_json(key, payload, SYSTEM_TTL_SECONDS) - return payload - - async def get_user_snapshot( - self, - *, - db: AsyncSession, - tenant_id: int, - user_id: Optional[int], - ) -> dict[str, Any]: - if user_id is None: - return {} - - tid = _safe_int(tenant_id, 1) - uid = _safe_int(user_id, 0) - if uid <= 0: - return {} - - key = self._user_key(tid, uid) - cached = await self._get_json(key) - if cached is not None: - return cached - - payload = await self._build_user_snapshot(db=db, tenant_id=tid, user_id=uid) - await self._set_json(key, payload, USER_TTL_SECONDS) - return payload - - async def _build_system_snapshot(self, *, db: AsyncSession, tenant_id: int) -> dict[str, Any]: - projects = await self._get_project_list_cached(db=db, tenant_id=tenant_id) - team = await self._build_team_members_snapshot(db=db, tenant_id=tenant_id) - pending = await self._get_pending_signal_cached(db=db, tenant_id=tenant_id) - assistant_profile = self._build_assistant_profile() - platform_profile = self._build_platform_profile() - return { - "tenant_id": tenant_id, - "assistant_profile": assistant_profile, - "platform_profile": platform_profile, - "projects": projects.get("items", []), - "team_members": team, - "pending_signals": pending, - "snapshot_at": now_cn_aware().isoformat(), - } - - @staticmethod - def _build_assistant_profile() -> dict[str, Any]: - return { - "name": "PMFlow AI 管家", - "identity": "PMFlow 内置的项目管理与技术协同助手", - "responsibilities": [ - "识别需求并路由到项目/信号", - "结合结构化数据与知识库回答问题", - "辅助任务拆解、分配与优先级判断", - "输出基于证据的系统状态与风险提示", - ], - "principles": [ - "只基于可验证上下文回答", - "证据不足时明确说明不足字段", - "优先使用系统快照中的实时结构化数据", - ], - } - - @staticmethod - def _count_python_files(target: Path) -> int: - if not target.exists() or not target.is_dir(): - return 0 - return sum(1 for p in target.rglob("*.py") if p.is_file()) - - @staticmethod - def _estimate_api_routes(api_dir: Path) -> int: - if not api_dir.exists() or not api_dir.is_dir(): - return 0 - markers = ( - "@router.get(", - "@router.post(", - "@router.put(", - "@router.patch(", - "@router.delete(", - "@router.websocket(", - ) - route_count = 0 - for py_file in api_dir.rglob("*.py"): - if not py_file.is_file() or py_file.name.startswith("__"): - continue - try: - text = py_file.read_text(encoding="utf-8", errors="ignore") - except Exception: - continue - for marker in markers: - route_count += text.count(marker) - return route_count - - @staticmethod - def _detect_database_stack() -> str: - url = str(os.getenv("DATABASE_URL") or "").lower() - if "postgresql" in url: - return "PostgreSQL (pgvector-ready)" - if "mysql" in url: - return "MySQL" - return "Unknown" - - @staticmethod - def _default_feature_catalog_items() -> list[dict[str, Any]]: - return [ - { - "route": "/workloads", - "name": "团队负载", - "summary": "查看团队成员负载、瓶颈与调配建议。", - "page_component": "WorkloadsPage", - "page_file": "frontend/src/pages/WorkloadsPage.tsx", - "apis": ["/workloads/", "/workloads/ai-optimize", "/workloads/ai-optimize/apply"], - "aliases": ["workloads", "负载", "工作量", "capacity", "团队负载"], - }, - { - "route": "/projects", - "name": "项目列表", - "summary": "管理项目清单、状态、入口跳转与总览信息。", - "page_component": "ProjectsPage", - "page_file": "frontend/src/pages/ProjectsPage.tsx", - "apis": ["/projects/", "/projects/?summary=1"], - "aliases": ["projects", "项目", "项目列表", "project list"], - }, - { - "route": "/ai-steward", - "name": "AI 管家", - "summary": "AI 任务协同与治理中心,包含简报、冲突检测、问答历史。", - "page_component": "AIStewardPage", - "page_file": "frontend/src/pages/AIStewardPage.tsx", - "apis": ["/ai-steward/", "/ai-steward/rag/ask", "/rag/qa-sessions", "/rag/qa-records"], - "aliases": ["ai steward", "ai管家", "问答", "rag", "智能问答"], - }, - { - "route": "/ai-config", - "name": "智能配置", - "summary": "配置飞书/企微/GitHub/知识库与模型参数,决定 AI 数据源与行为。", - "page_component": "AIConfigPage", - "page_file": "frontend/src/pages/AIConfigPage.tsx", - "apis": [ - "/ai-steward/config/suggest", - "/feishu/config", - "/wecom/config", - "/rag/knowledge-base/docs", - "/rag/knowledge-base/upload", - "/rag/knowledge-base/urls", - ], - "aliases": [ - "智能配置", - "ai配置", - "配置中心", - "integration", - "集成配置", - "knowledge base", - ], - }, - { - "route": "/skills", - "name": "能力管理", - "summary": "管理能力标签、人员能力画像、任务匹配与推荐。", - "page_component": "SkillsPage", - "page_file": "frontend/src/pages/SkillsPage.tsx", - "apis": ["/skills/tags", "/skills/recommend", "/skills/users/{id}/bindings"], - "aliases": ["skills", "能力", "技能", "ability", "能力管理"], - }, - { - "route": "/users", - "name": "用户管理", - "summary": "管理用户档案、角色、状态与权限入口。", - "page_component": "UsersPage", - "page_file": "frontend/src/pages/UsersPage.tsx", - "apis": ["/users/", "/users/{id}", "/users/bulk-delete", "/users/bulk-assign"], - "aliases": ["users", "成员管理", "用户", "人员管理"], - }, - ] - - @staticmethod - def _extract_router_import_map(router_text: str) -> dict[str, str]: - mapping: dict[str, str] = {} - for component, page_rel in re.findall( - r'import\s+([A-Za-z_][A-Za-z0-9_]*)\s+from\s+"@/pages/([^"]+)";', - str(router_text or ""), - ): - rel = str(page_rel).strip() - if not rel: - continue - if not rel.endswith(".tsx") and not rel.endswith(".ts"): - rel = f"{rel}.tsx" - mapping[str(component).strip()] = f"frontend/src/pages/{rel}" - return mapping - - @staticmethod - def _extract_router_entries(router_text: str) -> list[tuple[str, str]]: - entries: list[tuple[str, str]] = [] - for route, component in re.findall( - r'\{\s*path:\s*"([^"]+)"\s*,\s*element:\s*<([A-Za-z_][A-Za-z0-9_]*)\s*/?>\s*\}', - str(router_text or ""), - ): - clean_route = str(route or "").strip() - if not clean_route: - continue - if not clean_route.startswith("/"): - clean_route = f"/{clean_route}" - entries.append((clean_route, str(component).strip())) - return entries - - @staticmethod - def _extract_sidebar_labels(sidebar_text: str) -> dict[str, str]: - labels: dict[str, str] = {} - for route, label in re.findall( - r'\{\s*to:\s*"([^"]+)"\s*,\s*label:\s*"([^"]+)"', - str(sidebar_text or ""), - ): - clean_route = str(route or "").strip() - if clean_route and not clean_route.startswith("/"): - clean_route = f"/{clean_route}" - if clean_route: - labels[clean_route] = str(label or "").strip() - return labels - - @staticmethod - def _route_feature_summary(route: str) -> tuple[str, list[str], list[str]]: - route_map: dict[str, tuple[str, list[str], list[str]]] = { - "/workloads": ( - "查看团队成员负载、瓶颈与调配建议。", - ["/workloads/", "/workloads/ai-optimize", "/workloads/ai-optimize/apply"], - ["workloads", "负载", "工作量", "capacity"], - ), - "/projects": ( - "管理项目清单、状态、入口跳转与总览信息。", - ["/projects/", "/projects/?summary=1"], - ["projects", "项目", "项目列表", "project list"], - ), - "/ai-steward": ( - "AI 任务协同与治理中心,包含简报、冲突检测、问答历史。", - ["/ai-steward/", "/ai-steward/rag/ask", "/rag/qa-sessions", "/rag/qa-records"], - ["ai steward", "ai管家", "问答", "rag", "智能问答"], - ), - "/ai-config": ( - "配置飞书/企微/GitHub/知识库与模型参数,决定 AI 数据源与行为。", - [ - "/ai-steward/config/suggest", - "/feishu/config", - "/wecom/config", - "/rag/knowledge-base/docs", - "/rag/knowledge-base/upload", - "/rag/knowledge-base/urls", - ], - ["智能配置", "ai配置", "配置中心", "integration", "集成配置", "knowledge base"], - ), - "/skills": ( - "管理能力标签、人员能力画像、任务匹配与推荐。", - ["/skills/tags", "/skills/recommend", "/skills/users/{id}/bindings"], - ["skills", "能力", "技能", "ability", "能力管理"], - ), - "/users": ( - "管理用户档案、角色、状态与权限入口。", - ["/users/", "/users/{id}", "/users/bulk-delete", "/users/bulk-assign"], - ["users", "成员管理", "用户", "人员管理"], - ), - } - if route in route_map: - return route_map[route] - return ( - "系统功能页面入口。", - [], - [route.strip("/"), route.replace("/", "_").strip("_")], - ) - - @staticmethod - def _normalize_aliases(*groups: list[str]) -> list[str]: - merged: list[str] = [] - for group in groups: - for item in group: - alias = str(item or "").strip().lower() - if not alias: - continue - if alias not in merged: - merged.append(alias) - return merged - - def _build_feature_catalog( - self, - *, - backend_root: Path, - ) -> dict[str, Any]: - workspace_root = backend_root.parent - frontend_src = workspace_root / "frontend" / "src" - router_file = frontend_src / "router" / "index.tsx" - sidebar_file = frontend_src / "components" / "layout" / "Sidebar.tsx" - - if not frontend_src.exists(): - fallback_items = self._default_feature_catalog_items() - return { - "items": fallback_items, - "count": len(fallback_items), - "source": "fallback", - } - - router_text = "" - sidebar_text = "" - try: - router_text = router_file.read_text(encoding="utf-8", errors="ignore") - except Exception: - router_text = "" - try: - sidebar_text = sidebar_file.read_text(encoding="utf-8", errors="ignore") - except Exception: - sidebar_text = "" - - import_map = self._extract_router_import_map(router_text) - router_entries = self._extract_router_entries(router_text) - sidebar_labels = self._extract_sidebar_labels(sidebar_text) - - merged: dict[str, dict[str, Any]] = {} - for route, component in router_entries: - page_file = import_map.get(component) or "" - default_name = component.replace("Page", "") - name = sidebar_labels.get(route) or default_name - summary, apis, aliases = self._route_feature_summary(route) - route_aliases = [route.strip("/"), route.replace("/", "_").strip("_"), component] - merged[route] = { - "route": route, - "name": name, - "summary": summary, - "page_component": component, - "page_file": page_file, - "apis": apis, - "aliases": self._normalize_aliases(aliases, route_aliases, [name]), - } - - for route, label in sidebar_labels.items(): - if route not in merged: - summary, apis, aliases = self._route_feature_summary(route) - merged[route] = { - "route": route, - "name": label, - "summary": summary, - "page_component": "", - "page_file": "", - "apis": apis, - "aliases": self._normalize_aliases(aliases, [route.strip("/"), label]), - } - else: - merged[route]["name"] = label - merged[route]["aliases"] = self._normalize_aliases( - list(merged[route].get("aliases") or []), - [label], - ) - - items = sorted(merged.values(), key=lambda x: str(x.get("route") or "")) - if not items: - items = self._default_feature_catalog_items() - source = "fallback" - else: - source = "frontend_scan" - return { - "items": items, - "count": len(items), - "source": source, - } - - def _build_platform_profile(self) -> dict[str, Any]: - backend_root = Path(__file__).resolve().parents[2] - workspace_root = backend_root.parent - app_root = backend_root / "app" - api_dir = app_root / "api" - services_dir = app_root / "services" - models_dir = app_root / "models" - schemas_dir = app_root / "schemas" - dependencies_dir = app_root / "dependencies" - core_dir = app_root / "core" - config_dir = app_root / "config" - - module_counts = { - "api": self._count_python_files(api_dir), - "services": self._count_python_files(services_dir), - "models": self._count_python_files(models_dir), - "schemas": self._count_python_files(schemas_dir), - "dependencies": self._count_python_files(dependencies_dir), - "core": self._count_python_files(core_dir), - "config": self._count_python_files(config_dir), - } - app_python_files = self._count_python_files(app_root) - route_count = self._estimate_api_routes(api_dir) - running_in_docker = Path("/.dockerenv").exists() - redis_enabled = bool(str(os.getenv("REDIS_URL") or "").strip()) - feature_catalog = self._build_feature_catalog(backend_root=backend_root) - frontend_src = workspace_root / "frontend" / "src" - frontend_ts_files = 0 - if frontend_src.exists(): - frontend_ts_files = sum( - 1 - for p in frontend_src.rglob("*") - if p.is_file() and p.suffix.lower() in {".ts", ".tsx", ".js", ".jsx"} - ) - - return { - "runtime": "docker" if running_in_docker else "local", - "backend": { - "framework": "FastAPI", - "orm": "SQLAlchemy 2.0", - "python": "3.11+", - "module_counts": module_counts, - "api_route_count_estimate": route_count, - }, - "infrastructure": { - "database": self._detect_database_stack(), - "cache": "Redis" if redis_enabled else "Disabled/Unknown", - }, - "frontend": { - "framework": "React 18 + TypeScript + Vite", - "source_root": str(frontend_src), - "route_count": int(feature_catalog.get("count") or 0), - "source_file_count": int(frontend_ts_files), - }, - "feature_catalog": feature_catalog.get("items") if isinstance(feature_catalog.get("items"), list) else [], - "source_scan": { - "backend_root": str(backend_root), - "app_python_files": app_python_files, - "feature_catalog_source": str(feature_catalog.get("source") or "unknown"), - "scanned_at": now_cn_aware().isoformat(), - }, - } - - async def _get_project_list_cached(self, *, db: AsyncSession, tenant_id: int) -> dict[str, Any]: - key = self._project_list_key(tenant_id) - cached = await self._get_json(key) - if cached is not None: - return cached - - project_rows = ( - await db.execute( - select( - Project.id, - Project.name, - Project.title, - Project.status, - Project.owner_id, - User.name.label("owner_name"), - ) - .outerjoin( - User, - and_( - User.tenant_id == tenant_id, - User.id == Project.owner_id, - ), - ) - .where(Project.tenant_id == tenant_id) - .order_by(Project.updated_at.desc(), Project.id.desc()) - ) - ).all() - - project_ids = [int(row.id) for row in project_rows] - signal_count_map: dict[int, int] = {} - if project_ids: - signal_rows = ( - await db.execute( - select(ProjectSignalLink.project_id, func.count(ProjectSignalLink.id)) - .where( - and_( - ProjectSignalLink.tenant_id == tenant_id, - ProjectSignalLink.project_id.in_(project_ids), - ) - ) - .group_by(ProjectSignalLink.project_id) - ) - ).all() - signal_count_map = {int(pid): int(cnt or 0) for pid, cnt in signal_rows} - - task_count_map: dict[int, int] = {} - task_done_map: dict[int, int] = {} - if project_ids: - done_case = case((func.lower(Task.status).in_(DONE_TASK_STATUSES), 1), else_=0) - task_rows = ( - await db.execute( - select( - Task.project_id, - func.count(Task.id).label("task_total"), - func.sum(done_case).label("done_count"), - ) - .where( - and_( - Task.tenant_id == tenant_id, - Task.project_id.in_(project_ids), - ) - ) - .group_by(Task.project_id) - ) - ).all() - task_count_map = {int(pid): int(total or 0) for pid, total, _ in task_rows} - task_done_map = {int(pid): int(done or 0) for pid, _, done in task_rows} - - items: list[dict[str, Any]] = [] - for row in project_rows: - pid = int(row.id) - task_total = int(task_count_map.get(pid, 0)) - done_total = int(task_done_map.get(pid, 0)) - completion_rate = round((done_total / task_total) * 100, 2) if task_total > 0 else 0.0 - title = str(row.title or row.name or f"Project#{pid}") - items.append( - { - "id": pid, - "name": title, - "status": str(row.status or "unknown"), - "current_stage": str(row.status or "unknown"), - "owner_id": _safe_int(row.owner_id, 0) or None, - "owner_name": str(row.owner_name or "未知"), - "signal_count": int(signal_count_map.get(pid, 0)), - "task_count": task_total, - "completion_rate": completion_rate, - } - ) - - payload = { - "items": items, - "count": len(items), - "snapshot_at": now_cn_aware().isoformat(), - } - await self._set_json(key, payload, PROJECT_LIST_TTL_SECONDS) - return payload - - async def _build_team_members_snapshot(self, *, db: AsyncSession, tenant_id: int) -> list[dict[str, Any]]: - users = ( - await db.execute( - select(User.id, User.name, User.role, User.workload).where( - and_( - User.tenant_id == tenant_id, - User.is_active.is_(True), - ) - ) - ) - ).all() - if not users: - return [] - - user_ids = [int(u.id) for u in users] - workload_rows = ( - await db.execute( - select(UserWorkload.user_id, UserWorkload.workload_ratio).where( - and_( - UserWorkload.tenant_id == tenant_id, - UserWorkload.user_id.in_(user_ids), - ) - ) - ) - ).all() - workload_map = {int(uid): float(ratio or 0.0) for uid, ratio in workload_rows} - - project_rows = ( - await db.execute( - select(UserProject.user_id, UserProject.project_id).where( - and_( - UserProject.tenant_id == tenant_id, - UserProject.user_id.in_(user_ids), - ) - ) - ) - ).all() - projects_map: dict[int, list[int]] = {} - for uid, pid in project_rows: - projects_map.setdefault(int(uid), []).append(int(pid)) - - result: list[dict[str, Any]] = [] - for row in users: - uid = int(row.id) - ratio = workload_map.get(uid) - if ratio is None: - ratio = round(float(row.workload or 0) / 100.0, 2) - result.append( - { - "id": uid, - "name": str(row.name or f"User#{uid}"), - "role": str(row.role or "unknown"), - "workload_ratio": float(ratio), - "assigned_projects": projects_map.get(uid, []), - } - ) - return result - - async def _get_pending_signal_cached(self, *, db: AsyncSession, tenant_id: int) -> dict[str, Any]: - key = self._signal_pending_key(tenant_id) - cached = await self._get_json(key) - if cached is not None: - return cached - - pending_filter = or_( - Signal.processed.is_(False), - func.lower(Signal.status).in_(PENDING_SIGNAL_STATUSES), - ) - p0_case = case( - ( - func.lower(Signal.priority).in_({"critical", "p0", "urgent", "highest"}), - 1, - ), - else_=0, - ) - p1_case = case((func.lower(Signal.priority).in_({"high", "p1"}), 1), else_=0) - p2_case = case((func.lower(Signal.priority).in_({"medium", "p2", "normal"}), 1), else_=0) - - row = ( - await db.execute( - select( - func.count(Signal.id), - func.sum(p0_case), - func.sum(p1_case), - func.sum(p2_case), - ).where( - and_( - Signal.tenant_id == tenant_id, - pending_filter, - ) - ) - ) - ).first() - total, p0_count, p1_count, p2_count = row or (0, 0, 0, 0) - - payload = { - "total": int(total or 0), - "p0_count": int(p0_count or 0), - "p1_count": int(p1_count or 0), - "p2_count": int(p2_count or 0), - "snapshot_at": now_cn_aware().isoformat(), - } - await self._set_json(key, payload, SIGNAL_PENDING_TTL_SECONDS) - return payload - - async def _build_user_snapshot( - self, - *, - db: AsyncSession, - tenant_id: int, - user_id: int, - ) -> dict[str, Any]: - owned_rows = ( - await db.execute( - select(Project.id).where( - and_( - Project.tenant_id == tenant_id, - Project.owner_id == user_id, - ) - ) - ) - ).scalars().all() - member_rows = ( - await db.execute( - select(UserProject.project_id).where( - and_( - UserProject.tenant_id == tenant_id, - UserProject.user_id == user_id, - ) - ) - ) - ).scalars().all() - project_ids = sorted({_safe_int(pid, 0) for pid in [*owned_rows, *member_rows] if _safe_int(pid, 0) > 0}) - - project_rows: list[Any] = [] - if project_ids: - project_rows = ( - await db.execute( - select(Project.id, Project.name, Project.title, Project.status).where( - and_( - Project.tenant_id == tenant_id, - Project.id.in_(project_ids), - ) - ) - ) - ).all() - - open_tasks = ( - await db.execute( - select(Task.id, Task.project_id, Task.title, Task.status, Task.due_date).where( - and_( - Task.tenant_id == tenant_id, - Task.assignee_id == user_id, - func.lower(Task.status).notin_(DONE_TASK_STATUSES), - ) - ) - ) - ).all() - - pending_signals: list[dict[str, Any]] = [] - if project_ids: - pending_rows = ( - await db.execute( - select(Signal.id, Signal.title, Signal.priority, Signal.status, Signal.matched_project_id).where( - and_( - Signal.tenant_id == tenant_id, - Signal.matched_project_id.in_(project_ids), - or_( - Signal.processed.is_(False), - func.lower(Signal.status).in_(PENDING_SIGNAL_STATUSES), - ), - ) - ).limit(60) - ) - ).all() - pending_signals = [ - { - "id": int(row.id), - "title": str(row.title or ""), - "priority": str(row.priority or ""), - "status": str(row.status or ""), - "project_id": _safe_int(row.matched_project_id, 0) or None, - } - for row in pending_rows - ] - - workload_row = ( - await db.execute( - select(UserWorkload.workload_ratio).where( - and_( - UserWorkload.tenant_id == tenant_id, - UserWorkload.user_id == user_id, - ) - ) - ) - ).scalar_one_or_none() - if workload_row is None: - fallback = ( - await db.execute( - select(User.workload).where( - and_( - User.tenant_id == tenant_id, - User.id == user_id, - ) - ) - ) - ).scalar_one_or_none() - workload_ratio = round(float(fallback or 0) / 100.0, 2) - else: - workload_ratio = float(workload_row or 0.0) - - return { - "user_id": user_id, - "projects": [ - { - "id": int(row.id), - "name": str(row.title or row.name or f"Project#{int(row.id)}"), - "status": str(row.status or "unknown"), - } - for row in project_rows - ], - "assigned_tasks": [ - { - "id": int(row.id), - "project_id": int(row.project_id), - "title": str(row.title or ""), - "status": str(row.status or ""), - "due_date": row.due_date.isoformat() if row.due_date else None, - } - for row in open_tasks - ], - "pending_signals": pending_signals, - "workload_ratio": workload_ratio, - "snapshot_at": now_cn_aware().isoformat(), - } - - async def get_session_messages(self, *, tenant_id: int, session_id: Optional[int]) -> list[dict[str, str]]: - if session_id is None: - return [] - sid = _safe_int(session_id, 0) - if sid <= 0: - return [] - - redis = await self._get_redis() - if redis is None: - return [] - key = self._session_key(_safe_int(tenant_id, 1), sid) - try: - raw_rows = await redis.lrange(key, -SESSION_MAX_MESSAGES, -1) - except Exception: - return [] - messages: list[dict[str, str]] = [] - for raw in raw_rows: - row = _json_loads(raw, None) - if not isinstance(row, dict): - continue - role = str(row.get("role") or "").strip().lower() - content = str(row.get("content") or "").strip() - if role in {"user", "assistant"} and content: - messages.append({"role": role, "content": content}) - return messages - - async def append_session_messages( - self, - *, - tenant_id: int, - session_id: Optional[int], - question: str, - answer: str, - ) -> None: - if session_id is None: - return - sid = _safe_int(session_id, 0) - if sid <= 0: - return - - redis = await self._get_redis() - if redis is None: - return - - key = self._session_key(_safe_int(tenant_id, 1), sid) - user_payload = json.dumps({"role": "user", "content": str(question or "")[:3000]}, ensure_ascii=False) - assistant_payload = json.dumps({"role": "assistant", "content": str(answer or "")[:4000]}, ensure_ascii=False) - try: - pipe = redis.pipeline() - pipe.rpush(key, user_payload, assistant_payload) - pipe.ltrim(key, -SESSION_MAX_MESSAGES, -1) - pipe.expire(key, SESSION_TTL_SECONDS) - await pipe.execute() - except Exception: - return - - async def invalidate_system_context(self, *, tenant_id: int) -> None: - redis = await self._get_redis() - if redis is None: - return - tid = _safe_int(tenant_id, 1) - keys = [ - self._system_key(tid), - self._project_list_key(tid), - self._signal_pending_key(tid), - ] - try: - await redis.delete(*keys) - except Exception: - pass - - async def invalidate_user_context(self, *, tenant_id: int, user_id: int) -> None: - redis = await self._get_redis() - if redis is None: - return - uid = _safe_int(user_id, 0) - if uid <= 0: - return - try: - await redis.delete(self._user_key(_safe_int(tenant_id, 1), uid)) - except Exception: - pass - - @staticmethod - def messages_to_turns(messages: list[dict[str, str]]) -> list[dict[str, str]]: - turns: list[dict[str, str]] = [] - pending_question: Optional[str] = None - for row in messages: - role = str(row.get("role") or "").lower() - content = str(row.get("content") or "") - if role == "user": - if pending_question: - turns.append({"question": pending_question, "answer": ""}) - pending_question = content - continue - if role == "assistant": - if pending_question is None: - continue - turns.append({"question": pending_question, "answer": content}) - pending_question = None - if pending_question: - turns.append({"question": pending_question, "answer": ""}) - return turns - - -class ContextCacheInvalidator: - def __init__(self, redis_url: Optional[str] = None) -> None: - self.redis_url = redis_url or os.getenv("REDIS_URL") - self._redis: Optional[Any] = None - - def _get_redis(self): - if not REDIS_AVAILABLE or not self.redis_url or RedisSync is None: - return None - if self._redis is None: - self._redis = RedisSync.from_url( - self.redis_url, - decode_responses=True, - socket_connect_timeout=1, - socket_timeout=1, - ) - return self._redis - - @staticmethod - def _system_key(tenant_id: int) -> str: - return ContextCacheService._system_key(_safe_int(tenant_id, 1)) - - @staticmethod - def _project_list_key(tenant_id: int) -> str: - return ContextCacheService._project_list_key(_safe_int(tenant_id, 1)) - - @staticmethod - def _signal_pending_key(tenant_id: int) -> str: - return ContextCacheService._signal_pending_key(_safe_int(tenant_id, 1)) - - @staticmethod - def _user_key(tenant_id: int, user_id: int) -> str: - return ContextCacheService._user_key(_safe_int(tenant_id, 1), _safe_int(user_id, 0)) - - def invalidate( - self, - *, - tenant_id: int, - invalidate_system: bool, - invalidate_project_list: bool, - invalidate_signal_pending: bool, - user_ids: Optional[set[int]] = None, - ) -> None: - redis = self._get_redis() - if redis is None: - return - - tid = _safe_int(tenant_id, 1) - keys: list[str] = [] - if invalidate_system: - keys.append(self._system_key(tid)) - if invalidate_project_list: - keys.append(self._project_list_key(tid)) - if invalidate_signal_pending: - keys.append(self._signal_pending_key(tid)) - if user_ids: - for uid in user_ids: - if _safe_int(uid, 0) > 0: - keys.append(self._user_key(tid, uid)) - if not keys: - return - try: - redis.delete(*keys) - except Exception: - return - - -def register_context_cache_invalidation_hooks() -> None: - if getattr(register_context_cache_invalidation_hooks, "_registered", False): - return - - invalidator = ContextCacheInvalidator() - - @event.listens_for(Session, "after_flush") - def _collect_cache_invalidation(session: Session, _flush_context) -> None: # pragma: no cover - payload = session.info.get("_pmflow_cache_invalidation") - if not isinstance(payload, dict): - payload = { - "tenant_ids": set(), - "invalidate_system": False, - "invalidate_project_list": False, - "invalidate_signal_pending": False, - "user_ids": set(), - } - session.info["_pmflow_cache_invalidation"] = payload - - objects = set(session.new).union(session.dirty).union(session.deleted) - for obj in objects: - tenant_id = _safe_int(getattr(obj, "tenant_id", 0), 0) - if tenant_id > 0: - payload["tenant_ids"].add(tenant_id) - - if isinstance(obj, Project): - payload["invalidate_system"] = True - payload["invalidate_project_list"] = True - continue - - if isinstance(obj, ProjectSignalLink): - payload["invalidate_system"] = True - payload["invalidate_project_list"] = True - continue - - if isinstance(obj, Signal): - payload["invalidate_system"] = True - payload["invalidate_signal_pending"] = True - continue - - if isinstance(obj, Task): - payload["invalidate_system"] = True - state = inspect(obj) - assignee_hist = state.attrs.assignee_id.history if hasattr(state.attrs, "assignee_id") else None - if assignee_hist is not None: - for old_value in assignee_hist.deleted: - uid = _safe_int(old_value, 0) - if uid > 0: - payload["user_ids"].add(uid) - for new_value in assignee_hist.added: - uid = _safe_int(new_value, 0) - if uid > 0: - payload["user_ids"].add(uid) - uid = _safe_int(getattr(obj, "assignee_id", 0), 0) - if uid > 0: - payload["user_ids"].add(uid) - continue - - if isinstance(obj, UserProject): - payload["invalidate_system"] = True - payload["invalidate_project_list"] = True - uid = _safe_int(getattr(obj, "user_id", 0), 0) - if uid > 0: - payload["user_ids"].add(uid) - continue - - if isinstance(obj, User): - payload["invalidate_system"] = True - uid = _safe_int(getattr(obj, "id", 0), 0) - if uid > 0: - payload["user_ids"].add(uid) - continue - - if isinstance(obj, UserWorkload): - payload["invalidate_system"] = True - uid = _safe_int(getattr(obj, "user_id", 0), 0) - if uid > 0: - payload["user_ids"].add(uid) - continue - - @event.listens_for(Session, "after_commit") - def _flush_cache_invalidation(session: Session) -> None: # pragma: no cover - payload = session.info.pop("_pmflow_cache_invalidation", None) - if not isinstance(payload, dict): - return - tenant_ids = payload.get("tenant_ids") or set() - if not tenant_ids: - tenant_ids = {_safe_int(os.getenv("DEFAULT_TENANT_ID", "1"), 1)} - for tenant_id in tenant_ids: - invalidator.invalidate( - tenant_id=_safe_int(tenant_id, 1), - invalidate_system=bool(payload.get("invalidate_system")), - invalidate_project_list=bool(payload.get("invalidate_project_list")), - invalidate_signal_pending=bool(payload.get("invalidate_signal_pending")), - user_ids=set(payload.get("user_ids") or set()), - ) - - @event.listens_for(Session, "after_rollback") - def _clear_cache_invalidation(session: Session) -> None: # pragma: no cover - session.info.pop("_pmflow_cache_invalidation", None) - - setattr(register_context_cache_invalidation_hooks, "_registered", True) - - -context_cache_service = ContextCacheService() +__all__ = ["ContextCacheInvalidator", "ContextCacheService", "context_cache_service", "register_context_cache_invalidation_hooks"] diff --git a/backend/app/services/context_cache_service/__init__.py b/backend/app/services/context_cache_service/__init__.py new file mode 100644 index 00000000..6045073c --- /dev/null +++ b/backend/app/services/context_cache_service/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .service import ContextCacheInvalidator, ContextCacheService, context_cache_service, register_context_cache_invalidation_hooks + +__all__ = ["ContextCacheInvalidator", "ContextCacheService", "context_cache_service", "register_context_cache_invalidation_hooks"] diff --git a/backend/app/services/context_cache_service/invalidation.py b/backend/app/services/context_cache_service/invalidation.py new file mode 100644 index 00000000..956dd1c1 --- /dev/null +++ b/backend/app/services/context_cache_service/invalidation.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from .shared import * + +class ContextCacheInvalidator: + def __init__(self, redis_url: Optional[str] = None) -> None: + self.redis_url = redis_url or os.getenv("REDIS_URL") + self._redis: Optional[Any] = None + + def _get_redis(self): + if not REDIS_AVAILABLE or not self.redis_url or RedisSync is None: + return None + if self._redis is None: + self._redis = RedisSync.from_url( + self.redis_url, + decode_responses=True, + socket_connect_timeout=1, + socket_timeout=1, + ) + return self._redis + + @staticmethod + def _system_key(tenant_id: int) -> str: + return ContextCacheService._system_key(_safe_int(tenant_id, 1)) + + @staticmethod + def _project_list_key(tenant_id: int) -> str: + return ContextCacheService._project_list_key(_safe_int(tenant_id, 1)) + + @staticmethod + def _signal_pending_key(tenant_id: int) -> str: + return ContextCacheService._signal_pending_key(_safe_int(tenant_id, 1)) + + @staticmethod + def _user_key(tenant_id: int, user_id: int) -> str: + return ContextCacheService._user_key(_safe_int(tenant_id, 1), _safe_int(user_id, 0)) + + def invalidate( + self, + *, + tenant_id: int, + invalidate_system: bool, + invalidate_project_list: bool, + invalidate_signal_pending: bool, + user_ids: Optional[set[int]] = None, + ) -> None: + redis = self._get_redis() + if redis is None: + return + + tid = _safe_int(tenant_id, 1) + keys: list[str] = [] + if invalidate_system: + keys.append(self._system_key(tid)) + if invalidate_project_list: + keys.append(self._project_list_key(tid)) + if invalidate_signal_pending: + keys.append(self._signal_pending_key(tid)) + if user_ids: + for uid in user_ids: + if _safe_int(uid, 0) > 0: + keys.append(self._user_key(tid, uid)) + if not keys: + return + try: + redis.delete(*keys) + except Exception: + return + +def register_context_cache_invalidation_hooks() -> None: + if getattr(register_context_cache_invalidation_hooks, "_registered", False): + return + + invalidator = ContextCacheInvalidator() + + @event.listens_for(Session, "after_flush") + def _collect_cache_invalidation(session: Session, _flush_context) -> None: # pragma: no cover + payload = session.info.get("_pmflow_cache_invalidation") + if not isinstance(payload, dict): + payload = { + "tenant_ids": set(), + "invalidate_system": False, + "invalidate_project_list": False, + "invalidate_signal_pending": False, + "user_ids": set(), + } + session.info["_pmflow_cache_invalidation"] = payload + + objects = set(session.new).union(session.dirty).union(session.deleted) + for obj in objects: + tenant_id = _safe_int(getattr(obj, "tenant_id", 0), 0) + if tenant_id > 0: + payload["tenant_ids"].add(tenant_id) + + if isinstance(obj, Project): + payload["invalidate_system"] = True + payload["invalidate_project_list"] = True + continue + + if isinstance(obj, ProjectSignalLink): + payload["invalidate_system"] = True + payload["invalidate_project_list"] = True + continue + + if isinstance(obj, Signal): + payload["invalidate_system"] = True + payload["invalidate_signal_pending"] = True + continue + + if isinstance(obj, Task): + payload["invalidate_system"] = True + state = inspect(obj) + assignee_hist = state.attrs.assignee_id.history if hasattr(state.attrs, "assignee_id") else None + if assignee_hist is not None: + for old_value in assignee_hist.deleted: + uid = _safe_int(old_value, 0) + if uid > 0: + payload["user_ids"].add(uid) + for new_value in assignee_hist.added: + uid = _safe_int(new_value, 0) + if uid > 0: + payload["user_ids"].add(uid) + uid = _safe_int(getattr(obj, "assignee_id", 0), 0) + if uid > 0: + payload["user_ids"].add(uid) + continue + + if isinstance(obj, UserProject): + payload["invalidate_system"] = True + payload["invalidate_project_list"] = True + uid = _safe_int(getattr(obj, "user_id", 0), 0) + if uid > 0: + payload["user_ids"].add(uid) + continue + + if isinstance(obj, User): + payload["invalidate_system"] = True + uid = _safe_int(getattr(obj, "id", 0), 0) + if uid > 0: + payload["user_ids"].add(uid) + continue + + if isinstance(obj, UserWorkload): + payload["invalidate_system"] = True + uid = _safe_int(getattr(obj, "user_id", 0), 0) + if uid > 0: + payload["user_ids"].add(uid) + continue + + @event.listens_for(Session, "after_commit") + def _flush_cache_invalidation(session: Session) -> None: # pragma: no cover + payload = session.info.pop("_pmflow_cache_invalidation", None) + if not isinstance(payload, dict): + return + tenant_ids = payload.get("tenant_ids") or set() + if not tenant_ids: + tenant_ids = {_safe_int(os.getenv("DEFAULT_TENANT_ID", "1"), 1)} + for tenant_id in tenant_ids: + invalidator.invalidate( + tenant_id=_safe_int(tenant_id, 1), + invalidate_system=bool(payload.get("invalidate_system")), + invalidate_project_list=bool(payload.get("invalidate_project_list")), + invalidate_signal_pending=bool(payload.get("invalidate_signal_pending")), + user_ids=set(payload.get("user_ids") or set()), + ) + + @event.listens_for(Session, "after_rollback") + def _clear_cache_invalidation(session: Session) -> None: # pragma: no cover + session.info.pop("_pmflow_cache_invalidation", None) + + setattr(register_context_cache_invalidation_hooks, "_registered", True) + diff --git a/backend/app/services/context_cache_service/mixin_cache_core.py b/backend/app/services/context_cache_service/mixin_cache_core.py new file mode 100644 index 00000000..ab98e321 --- /dev/null +++ b/backend/app/services/context_cache_service/mixin_cache_core.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from .shared import * + +class ContextCacheCoreMixin: + def __init__(self, redis_url: Optional[str] = None) -> None: + self.redis_url = redis_url or os.getenv("REDIS_URL") + self._redis: Optional[Any] = None + + @staticmethod + def _system_key(tenant_id: int) -> str: + return f"pmflow:context:system:{tenant_id}" + + @staticmethod + def _user_key(tenant_id: int, user_id: int) -> str: + return f"pmflow:context:user:{tenant_id}:{user_id}" + + @staticmethod + def _session_key(tenant_id: int, session_id: int) -> str: + return f"pmflow:qa:session:{tenant_id}:{session_id}" + + @staticmethod + def _project_list_key(tenant_id: int) -> str: + return f"pmflow:project:list:{tenant_id}" + + @staticmethod + def _signal_pending_key(tenant_id: int) -> str: + return f"pmflow:signal:pending_count:{tenant_id}" + + async def _get_redis(self): + if not REDIS_AVAILABLE or not self.redis_url: + return None + if self._redis is None: + self._redis = redis_async.from_url(self.redis_url, decode_responses=True) + return self._redis + + async def _get_json(self, key: str) -> Optional[dict[str, Any]]: + redis = await self._get_redis() + if redis is None: + return None + try: + raw = await redis.get(key) + except Exception: + return None + if not raw: + return None + payload = _json_loads(raw, None) + return payload if isinstance(payload, dict) else None + + async def _set_json(self, key: str, value: dict[str, Any], ttl: int) -> None: + redis = await self._get_redis() + if redis is None: + return + try: + await redis.setex(key, ttl, json.dumps(value, ensure_ascii=False)) + except Exception: + pass + + async def get_json_key(self, key: str) -> Optional[dict[str, Any]]: + return await self._get_json(key) + + async def set_json_key(self, key: str, value: dict[str, Any], ttl: int) -> None: + await self._set_json(key, value, ttl) + + async def delete_keys(self, *keys: str) -> None: + valid_keys = [str(key).strip() for key in keys if str(key).strip()] + if not valid_keys: + return + redis = await self._get_redis() + if redis is None: + return + try: + await redis.delete(*valid_keys) + except Exception: + pass + + @staticmethod + def _is_system_snapshot_complete(payload: Optional[dict[str, Any]]) -> bool: + if not isinstance(payload, dict): + return False + return isinstance(payload.get("assistant_profile"), dict) and isinstance(payload.get("platform_profile"), dict) + + async def get_system_snapshot(self, *, db: AsyncSession, tenant_id: int) -> dict[str, Any]: + tid = _safe_int(tenant_id, 1) + key = self._system_key(tid) + cached = await self._get_json(key) + if self._is_system_snapshot_complete(cached): + return cached + + payload = await self._build_system_snapshot(db=db, tenant_id=tid) + await self._set_json(key, payload, SYSTEM_TTL_SECONDS) + return payload + + async def get_user_snapshot( + self, + *, + db: AsyncSession, + tenant_id: int, + user_id: Optional[int], + ) -> dict[str, Any]: + if user_id is None: + return {} + + tid = _safe_int(tenant_id, 1) + uid = _safe_int(user_id, 0) + if uid <= 0: + return {} + + key = self._user_key(tid, uid) + cached = await self._get_json(key) + if cached is not None: + return cached + + payload = await self._build_user_snapshot(db=db, tenant_id=tid, user_id=uid) + await self._set_json(key, payload, USER_TTL_SECONDS) + return payload + + async def _build_system_snapshot(self, *, db: AsyncSession, tenant_id: int) -> dict[str, Any]: + projects = await self._get_project_list_cached(db=db, tenant_id=tenant_id) + team = await self._build_team_members_snapshot(db=db, tenant_id=tenant_id) + pending = await self._get_pending_signal_cached(db=db, tenant_id=tenant_id) + assistant_profile = self._build_assistant_profile() + platform_profile = self._build_platform_profile() + return { + "tenant_id": tenant_id, + "assistant_profile": assistant_profile, + "platform_profile": platform_profile, + "projects": projects.get("items", []), + "team_members": team, + "pending_signals": pending, + "snapshot_at": now_cn_aware().isoformat(), + } + diff --git a/backend/app/services/context_cache_service/mixin_profile_basics.py b/backend/app/services/context_cache_service/mixin_profile_basics.py new file mode 100644 index 00000000..6a693811 --- /dev/null +++ b/backend/app/services/context_cache_service/mixin_profile_basics.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from .shared import * + +class ContextCacheProfileBasicsMixin: + @staticmethod + def _build_assistant_profile() -> dict[str, Any]: + return { + "name": "PMFlow AI 管家", + "identity": "PMFlow 内置的项目管理与技术协同助手", + "responsibilities": [ + "识别需求并路由到项目/信号", + "结合结构化数据与知识库回答问题", + "辅助任务拆解、分配与优先级判断", + "输出基于证据的系统状态与风险提示", + ], + "principles": [ + "只基于可验证上下文回答", + "证据不足时明确说明不足字段", + "优先使用系统快照中的实时结构化数据", + ], + } + + @staticmethod + def _count_python_files(target: Path) -> int: + if not target.exists() or not target.is_dir(): + return 0 + return sum(1 for p in target.rglob("*.py") if p.is_file()) + + @staticmethod + def _estimate_api_routes(api_dir: Path) -> int: + if not api_dir.exists() or not api_dir.is_dir(): + return 0 + markers = ( + "@router.get(", + "@router.post(", + "@router.put(", + "@router.patch(", + "@router.delete(", + "@router.websocket(", + ) + route_count = 0 + for py_file in api_dir.rglob("*.py"): + if not py_file.is_file() or py_file.name.startswith("__"): + continue + try: + text = py_file.read_text(encoding="utf-8", errors="ignore") + except Exception: + continue + for marker in markers: + route_count += text.count(marker) + return route_count + + @staticmethod + def _detect_database_stack() -> str: + url = str(os.getenv("DATABASE_URL") or "").lower() + if "postgresql" in url: + return "PostgreSQL (pgvector-ready)" + if "mysql" in url: + return "MySQL" + return "Unknown" + + @staticmethod + def _default_feature_catalog_items() -> list[dict[str, Any]]: + return [ + { + "route": "/workloads", + "name": "团队负载", + "summary": "查看团队成员负载、瓶颈与调配建议。", + "page_component": "WorkloadsPage", + "page_file": "frontend/src/pages/WorkloadsPage.tsx", + "apis": ["/workloads/", "/workloads/ai-optimize", "/workloads/ai-optimize/apply"], + "aliases": ["workloads", "负载", "工作量", "capacity", "团队负载"], + }, + { + "route": "/projects", + "name": "项目列表", + "summary": "管理项目清单、状态、入口跳转与总览信息。", + "page_component": "ProjectsPage", + "page_file": "frontend/src/pages/ProjectsPage.tsx", + "apis": ["/projects/", "/projects/?summary=1"], + "aliases": ["projects", "项目", "项目列表", "project list"], + }, + { + "route": "/ai-steward", + "name": "AI 管家", + "summary": "AI 任务协同与治理中心,包含简报、冲突检测、问答历史。", + "page_component": "AIStewardPage", + "page_file": "frontend/src/pages/AIStewardPage.tsx", + "apis": ["/ai-steward/", "/ai-steward/rag/ask", "/rag/qa-sessions", "/rag/qa-records"], + "aliases": ["ai steward", "ai管家", "问答", "rag", "智能问答"], + }, + { + "route": "/ai-config", + "name": "智能配置", + "summary": "配置飞书/企微/GitHub/知识库与模型参数,决定 AI 数据源与行为。", + "page_component": "AIConfigPage", + "page_file": "frontend/src/pages/AIConfigPage.tsx", + "apis": [ + "/ai-steward/config/suggest", + "/feishu/config", + "/wecom/config", + "/rag/knowledge-base/docs", + "/rag/knowledge-base/upload", + "/rag/knowledge-base/urls", + ], + "aliases": [ + "智能配置", + "ai配置", + "配置中心", + "integration", + "集成配置", + "knowledge base", + ], + }, + { + "route": "/skills", + "name": "能力管理", + "summary": "管理能力标签、人员能力画像、任务匹配与推荐。", + "page_component": "SkillsPage", + "page_file": "frontend/src/pages/SkillsPage.tsx", + "apis": ["/skills/tags", "/skills/recommend", "/skills/users/{id}/bindings"], + "aliases": ["skills", "能力", "技能", "ability", "能力管理"], + }, + { + "route": "/users", + "name": "用户管理", + "summary": "管理用户档案、角色、状态与权限入口。", + "page_component": "UsersPage", + "page_file": "frontend/src/pages/UsersPage.tsx", + "apis": ["/users/", "/users/{id}", "/users/bulk-delete", "/users/bulk-assign"], + "aliases": ["users", "成员管理", "用户", "人员管理"], + }, + ] + diff --git a/backend/app/services/context_cache_service/mixin_profile_route_parser.py b/backend/app/services/context_cache_service/mixin_profile_route_parser.py new file mode 100644 index 00000000..3006b5c6 --- /dev/null +++ b/backend/app/services/context_cache_service/mixin_profile_route_parser.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +from .shared import * + +class ContextCacheProfileRouteParserMixin: + @staticmethod + def _extract_router_import_map(router_text: str) -> dict[str, str]: + mapping: dict[str, str] = {} + for component, page_rel in re.findall( + r'import\s+([A-Za-z_][A-Za-z0-9_]*)\s+from\s+"@/pages/([^"]+)";', + str(router_text or ""), + ): + rel = str(page_rel).strip() + if not rel: + continue + if not rel.endswith(".tsx") and not rel.endswith(".ts"): + rel = f"{rel}.tsx" + mapping[str(component).strip()] = f"frontend/src/pages/{rel}" + return mapping + + @staticmethod + def _extract_router_entries(router_text: str) -> list[tuple[str, str]]: + entries: list[tuple[str, str]] = [] + for route, component in re.findall( + r'\{\s*path:\s*"([^"]+)"\s*,\s*element:\s*<([A-Za-z_][A-Za-z0-9_]*)\s*/?>\s*\}', + str(router_text or ""), + ): + clean_route = str(route or "").strip() + if not clean_route: + continue + if not clean_route.startswith("/"): + clean_route = f"/{clean_route}" + entries.append((clean_route, str(component).strip())) + return entries + + @staticmethod + def _extract_sidebar_labels(sidebar_text: str) -> dict[str, str]: + labels: dict[str, str] = {} + for route, label in re.findall( + r'\{\s*to:\s*"([^"]+)"\s*,\s*label:\s*"([^"]+)"', + str(sidebar_text or ""), + ): + clean_route = str(route or "").strip() + if clean_route and not clean_route.startswith("/"): + clean_route = f"/{clean_route}" + if clean_route: + labels[clean_route] = str(label or "").strip() + return labels + + @staticmethod + def _route_feature_summary(route: str) -> tuple[str, list[str], list[str]]: + route_map: dict[str, tuple[str, list[str], list[str]]] = { + "/workloads": ( + "查看团队成员负载、瓶颈与调配建议。", + ["/workloads/", "/workloads/ai-optimize", "/workloads/ai-optimize/apply"], + ["workloads", "负载", "工作量", "capacity"], + ), + "/projects": ( + "管理项目清单、状态、入口跳转与总览信息。", + ["/projects/", "/projects/?summary=1"], + ["projects", "项目", "项目列表", "project list"], + ), + "/ai-steward": ( + "AI 任务协同与治理中心,包含简报、冲突检测、问答历史。", + ["/ai-steward/", "/ai-steward/rag/ask", "/rag/qa-sessions", "/rag/qa-records"], + ["ai steward", "ai管家", "问答", "rag", "智能问答"], + ), + "/ai-config": ( + "配置飞书/企微/GitHub/知识库与模型参数,决定 AI 数据源与行为。", + [ + "/ai-steward/config/suggest", + "/feishu/config", + "/wecom/config", + "/rag/knowledge-base/docs", + "/rag/knowledge-base/upload", + "/rag/knowledge-base/urls", + ], + ["智能配置", "ai配置", "配置中心", "integration", "集成配置", "knowledge base"], + ), + "/skills": ( + "管理能力标签、人员能力画像、任务匹配与推荐。", + ["/skills/tags", "/skills/recommend", "/skills/users/{id}/bindings"], + ["skills", "能力", "技能", "ability", "能力管理"], + ), + "/users": ( + "管理用户档案、角色、状态与权限入口。", + ["/users/", "/users/{id}", "/users/bulk-delete", "/users/bulk-assign"], + ["users", "成员管理", "用户", "人员管理"], + ), + } + if route in route_map: + return route_map[route] + return ( + "系统功能页面入口。", + [], + [route.strip("/"), route.replace("/", "_").strip("_")], + ) + + @staticmethod + def _normalize_aliases(*groups: list[str]) -> list[str]: + merged: list[str] = [] + for group in groups: + for item in group: + alias = str(item or "").strip().lower() + if not alias: + continue + if alias not in merged: + merged.append(alias) + return merged + + def _build_feature_catalog( + self, + *, + backend_root: Path, + ) -> dict[str, Any]: + workspace_root = backend_root.parent + frontend_src = workspace_root / "frontend" / "src" + router_file = frontend_src / "router" / "index.tsx" + sidebar_file = frontend_src / "components" / "layout" / "Sidebar.tsx" + + if not frontend_src.exists(): + fallback_items = self._default_feature_catalog_items() + return { + "items": fallback_items, + "count": len(fallback_items), + "source": "fallback", + } + + router_text = "" + sidebar_text = "" + try: + router_text = router_file.read_text(encoding="utf-8", errors="ignore") + except Exception: + router_text = "" + try: + sidebar_text = sidebar_file.read_text(encoding="utf-8", errors="ignore") + except Exception: + sidebar_text = "" + + import_map = self._extract_router_import_map(router_text) + router_entries = self._extract_router_entries(router_text) + sidebar_labels = self._extract_sidebar_labels(sidebar_text) + + merged: dict[str, dict[str, Any]] = {} + for route, component in router_entries: + page_file = import_map.get(component) or "" + default_name = component.replace("Page", "") + name = sidebar_labels.get(route) or default_name + summary, apis, aliases = self._route_feature_summary(route) + route_aliases = [route.strip("/"), route.replace("/", "_").strip("_"), component] + merged[route] = { + "route": route, + "name": name, + "summary": summary, + "page_component": component, + "page_file": page_file, + "apis": apis, + "aliases": self._normalize_aliases(aliases, route_aliases, [name]), + } + + for route, label in sidebar_labels.items(): + if route not in merged: + summary, apis, aliases = self._route_feature_summary(route) + merged[route] = { + "route": route, + "name": label, + "summary": summary, + "page_component": "", + "page_file": "", + "apis": apis, + "aliases": self._normalize_aliases(aliases, [route.strip("/"), label]), + } + else: + merged[route]["name"] = label + merged[route]["aliases"] = self._normalize_aliases( + list(merged[route].get("aliases") or []), + [label], + ) + + items = sorted(merged.values(), key=lambda x: str(x.get("route") or "")) + if not items: + items = self._default_feature_catalog_items() + source = "fallback" + else: + source = "frontend_scan" + return { + "items": items, + "count": len(items), + "source": source, + } + + def _build_platform_profile(self) -> dict[str, Any]: + backend_root = Path(__file__).resolve().parents[2] + workspace_root = backend_root.parent + app_root = backend_root / "app" + api_dir = app_root / "api" + services_dir = app_root / "services" + models_dir = app_root / "models" + schemas_dir = app_root / "schemas" + dependencies_dir = app_root / "dependencies" + core_dir = app_root / "core" + config_dir = app_root / "config" + + module_counts = { + "api": self._count_python_files(api_dir), + "services": self._count_python_files(services_dir), + "models": self._count_python_files(models_dir), + "schemas": self._count_python_files(schemas_dir), + "dependencies": self._count_python_files(dependencies_dir), + "core": self._count_python_files(core_dir), + "config": self._count_python_files(config_dir), + } + app_python_files = self._count_python_files(app_root) + route_count = self._estimate_api_routes(api_dir) + running_in_docker = Path("/.dockerenv").exists() + redis_enabled = bool(str(os.getenv("REDIS_URL") or "").strip()) + feature_catalog = self._build_feature_catalog(backend_root=backend_root) + frontend_src = workspace_root / "frontend" / "src" + frontend_ts_files = 0 + if frontend_src.exists(): + frontend_ts_files = sum( + 1 + for p in frontend_src.rglob("*") + if p.is_file() and p.suffix.lower() in {".ts", ".tsx", ".js", ".jsx"} + ) + + return { + "runtime": "docker" if running_in_docker else "local", + "backend": { + "framework": "FastAPI", + "orm": "SQLAlchemy 2.0", + "python": "3.11+", + "module_counts": module_counts, + "api_route_count_estimate": route_count, + }, + "infrastructure": { + "database": self._detect_database_stack(), + "cache": "Redis" if redis_enabled else "Disabled/Unknown", + }, + "frontend": { + "framework": "React 18 + TypeScript + Vite", + "source_root": str(frontend_src), + "route_count": int(feature_catalog.get("count") or 0), + "source_file_count": int(frontend_ts_files), + }, + "feature_catalog": feature_catalog.get("items") if isinstance(feature_catalog.get("items"), list) else [], + "source_scan": { + "backend_root": str(backend_root), + "app_python_files": app_python_files, + "feature_catalog_source": str(feature_catalog.get("source") or "unknown"), + "scanned_at": now_cn_aware().isoformat(), + }, + } + diff --git a/backend/app/services/context_cache_service/mixin_project_team.py b/backend/app/services/context_cache_service/mixin_project_team.py new file mode 100644 index 00000000..3f57732d --- /dev/null +++ b/backend/app/services/context_cache_service/mixin_project_team.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +from .shared import * + +class ContextCacheProjectTeamMixin: + async def _get_project_list_cached(self, *, db: AsyncSession, tenant_id: int) -> dict[str, Any]: + key = self._project_list_key(tenant_id) + cached = await self._get_json(key) + if cached is not None: + return cached + + project_rows = ( + await db.execute( + select( + Project.id, + Project.name, + Project.title, + Project.status, + Project.owner_id, + User.name.label("owner_name"), + ) + .outerjoin( + User, + and_( + User.tenant_id == tenant_id, + User.id == Project.owner_id, + ), + ) + .where(Project.tenant_id == tenant_id) + .order_by(Project.updated_at.desc(), Project.id.desc()) + ) + ).all() + + project_ids = [int(row.id) for row in project_rows] + signal_count_map: dict[int, int] = {} + if project_ids: + signal_rows = ( + await db.execute( + select(ProjectSignalLink.project_id, func.count(ProjectSignalLink.id)) + .where( + and_( + ProjectSignalLink.tenant_id == tenant_id, + ProjectSignalLink.project_id.in_(project_ids), + ) + ) + .group_by(ProjectSignalLink.project_id) + ) + ).all() + signal_count_map = {int(pid): int(cnt or 0) for pid, cnt in signal_rows} + + task_count_map: dict[int, int] = {} + task_done_map: dict[int, int] = {} + if project_ids: + done_case = case((func.lower(Task.status).in_(DONE_TASK_STATUSES), 1), else_=0) + task_rows = ( + await db.execute( + select( + Task.project_id, + func.count(Task.id).label("task_total"), + func.sum(done_case).label("done_count"), + ) + .where( + and_( + Task.tenant_id == tenant_id, + Task.project_id.in_(project_ids), + ) + ) + .group_by(Task.project_id) + ) + ).all() + task_count_map = {int(pid): int(total or 0) for pid, total, _ in task_rows} + task_done_map = {int(pid): int(done or 0) for pid, _, done in task_rows} + + items: list[dict[str, Any]] = [] + for row in project_rows: + pid = int(row.id) + task_total = int(task_count_map.get(pid, 0)) + done_total = int(task_done_map.get(pid, 0)) + completion_rate = round((done_total / task_total) * 100, 2) if task_total > 0 else 0.0 + title = str(row.title or row.name or f"Project#{pid}") + items.append( + { + "id": pid, + "name": title, + "status": str(row.status or "unknown"), + "current_stage": str(row.status or "unknown"), + "owner_id": _safe_int(row.owner_id, 0) or None, + "owner_name": str(row.owner_name or "未知"), + "signal_count": int(signal_count_map.get(pid, 0)), + "task_count": task_total, + "completion_rate": completion_rate, + } + ) + + payload = { + "items": items, + "count": len(items), + "snapshot_at": now_cn_aware().isoformat(), + } + await self._set_json(key, payload, PROJECT_LIST_TTL_SECONDS) + return payload + + async def _build_team_members_snapshot(self, *, db: AsyncSession, tenant_id: int) -> list[dict[str, Any]]: + users = ( + await db.execute( + select(User.id, User.name, User.role, User.workload).where( + and_( + User.tenant_id == tenant_id, + User.is_active.is_(True), + ) + ) + ) + ).all() + if not users: + return [] + + user_ids = [int(u.id) for u in users] + workload_rows = ( + await db.execute( + select(UserWorkload.user_id, UserWorkload.workload_ratio).where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id.in_(user_ids), + ) + ) + ) + ).all() + workload_map = {int(uid): float(ratio or 0.0) for uid, ratio in workload_rows} + + project_rows = ( + await db.execute( + select(UserProject.user_id, UserProject.project_id).where( + and_( + UserProject.tenant_id == tenant_id, + UserProject.user_id.in_(user_ids), + ) + ) + ) + ).all() + projects_map: dict[int, list[int]] = {} + for uid, pid in project_rows: + projects_map.setdefault(int(uid), []).append(int(pid)) + + result: list[dict[str, Any]] = [] + for row in users: + uid = int(row.id) + ratio = workload_map.get(uid) + if ratio is None: + ratio = round(float(row.workload or 0) / 100.0, 2) + result.append( + { + "id": uid, + "name": str(row.name or f"User#{uid}"), + "role": str(row.role or "unknown"), + "workload_ratio": float(ratio), + "assigned_projects": projects_map.get(uid, []), + } + ) + return result + + async def _get_pending_signal_cached(self, *, db: AsyncSession, tenant_id: int) -> dict[str, Any]: + key = self._signal_pending_key(tenant_id) + cached = await self._get_json(key) + if cached is not None: + return cached + + pending_filter = or_( + Signal.processed.is_(False), + func.lower(Signal.status).in_(PENDING_SIGNAL_STATUSES), + ) + p0_case = case( + ( + func.lower(Signal.priority).in_({"critical", "p0", "urgent", "highest"}), + 1, + ), + else_=0, + ) + p1_case = case((func.lower(Signal.priority).in_({"high", "p1"}), 1), else_=0) + p2_case = case((func.lower(Signal.priority).in_({"medium", "p2", "normal"}), 1), else_=0) + + row = ( + await db.execute( + select( + func.count(Signal.id), + func.sum(p0_case), + func.sum(p1_case), + func.sum(p2_case), + ).where( + and_( + Signal.tenant_id == tenant_id, + pending_filter, + ) + ) + ) + ).first() + total, p0_count, p1_count, p2_count = row or (0, 0, 0, 0) + + payload = { + "total": int(total or 0), + "p0_count": int(p0_count or 0), + "p1_count": int(p1_count or 0), + "p2_count": int(p2_count or 0), + "snapshot_at": now_cn_aware().isoformat(), + } + await self._set_json(key, payload, SIGNAL_PENDING_TTL_SECONDS) + return payload + diff --git a/backend/app/services/context_cache_service/mixin_session_invalidate.py b/backend/app/services/context_cache_service/mixin_session_invalidate.py new file mode 100644 index 00000000..69b2aa9a --- /dev/null +++ b/backend/app/services/context_cache_service/mixin_session_invalidate.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from .shared import * + +class ContextCacheSessionInvalidateMixin: + async def get_session_messages(self, *, tenant_id: int, session_id: Optional[int]) -> list[dict[str, str]]: + if session_id is None: + return [] + sid = _safe_int(session_id, 0) + if sid <= 0: + return [] + + redis = await self._get_redis() + if redis is None: + return [] + key = self._session_key(_safe_int(tenant_id, 1), sid) + try: + raw_rows = await redis.lrange(key, -SESSION_MAX_MESSAGES, -1) + except Exception: + return [] + messages: list[dict[str, str]] = [] + for raw in raw_rows: + row = _json_loads(raw, None) + if not isinstance(row, dict): + continue + role = str(row.get("role") or "").strip().lower() + content = str(row.get("content") or "").strip() + if role in {"user", "assistant"} and content: + messages.append({"role": role, "content": content}) + return messages + + async def append_session_messages( + self, + *, + tenant_id: int, + session_id: Optional[int], + question: str, + answer: str, + ) -> None: + if session_id is None: + return + sid = _safe_int(session_id, 0) + if sid <= 0: + return + + redis = await self._get_redis() + if redis is None: + return + + key = self._session_key(_safe_int(tenant_id, 1), sid) + user_payload = json.dumps({"role": "user", "content": str(question or "")[:3000]}, ensure_ascii=False) + assistant_payload = json.dumps({"role": "assistant", "content": str(answer or "")[:4000]}, ensure_ascii=False) + try: + pipe = redis.pipeline() + pipe.rpush(key, user_payload, assistant_payload) + pipe.ltrim(key, -SESSION_MAX_MESSAGES, -1) + pipe.expire(key, SESSION_TTL_SECONDS) + await pipe.execute() + except Exception: + return + + async def invalidate_system_context(self, *, tenant_id: int) -> None: + redis = await self._get_redis() + if redis is None: + return + tid = _safe_int(tenant_id, 1) + keys = [ + self._system_key(tid), + self._project_list_key(tid), + self._signal_pending_key(tid), + ] + try: + await redis.delete(*keys) + except Exception: + pass + + async def invalidate_user_context(self, *, tenant_id: int, user_id: int) -> None: + redis = await self._get_redis() + if redis is None: + return + uid = _safe_int(user_id, 0) + if uid <= 0: + return + try: + await redis.delete(self._user_key(_safe_int(tenant_id, 1), uid)) + except Exception: + pass + + @staticmethod + def messages_to_turns(messages: list[dict[str, str]]) -> list[dict[str, str]]: + turns: list[dict[str, str]] = [] + pending_question: Optional[str] = None + for row in messages: + role = str(row.get("role") or "").lower() + content = str(row.get("content") or "") + if role == "user": + if pending_question: + turns.append({"question": pending_question, "answer": ""}) + pending_question = content + continue + if role == "assistant": + if pending_question is None: + continue + turns.append({"question": pending_question, "answer": content}) + pending_question = None + if pending_question: + turns.append({"question": pending_question, "answer": ""}) + return turns + diff --git a/backend/app/services/context_cache_service/mixin_user_snapshot.py b/backend/app/services/context_cache_service/mixin_user_snapshot.py new file mode 100644 index 00000000..636eb0e6 --- /dev/null +++ b/backend/app/services/context_cache_service/mixin_user_snapshot.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from .shared import * + +class ContextCacheUserSnapshotMixin: + async def _build_user_snapshot( + self, + *, + db: AsyncSession, + tenant_id: int, + user_id: int, + ) -> dict[str, Any]: + owned_rows = ( + await db.execute( + select(Project.id).where( + and_( + Project.tenant_id == tenant_id, + Project.owner_id == user_id, + ) + ) + ) + ).scalars().all() + member_rows = ( + await db.execute( + select(UserProject.project_id).where( + and_( + UserProject.tenant_id == tenant_id, + UserProject.user_id == user_id, + ) + ) + ) + ).scalars().all() + project_ids = sorted({_safe_int(pid, 0) for pid in [*owned_rows, *member_rows] if _safe_int(pid, 0) > 0}) + + project_rows: list[Any] = [] + if project_ids: + project_rows = ( + await db.execute( + select(Project.id, Project.name, Project.title, Project.status).where( + and_( + Project.tenant_id == tenant_id, + Project.id.in_(project_ids), + ) + ) + ) + ).all() + + open_tasks = ( + await db.execute( + select(Task.id, Task.project_id, Task.title, Task.status, Task.due_date).where( + and_( + Task.tenant_id == tenant_id, + Task.assignee_id == user_id, + func.lower(Task.status).notin_(DONE_TASK_STATUSES), + ) + ) + ) + ).all() + + pending_signals: list[dict[str, Any]] = [] + if project_ids: + pending_rows = ( + await db.execute( + select(Signal.id, Signal.title, Signal.priority, Signal.status, Signal.matched_project_id).where( + and_( + Signal.tenant_id == tenant_id, + Signal.matched_project_id.in_(project_ids), + or_( + Signal.processed.is_(False), + func.lower(Signal.status).in_(PENDING_SIGNAL_STATUSES), + ), + ) + ).limit(60) + ) + ).all() + pending_signals = [ + { + "id": int(row.id), + "title": str(row.title or ""), + "priority": str(row.priority or ""), + "status": str(row.status or ""), + "project_id": _safe_int(row.matched_project_id, 0) or None, + } + for row in pending_rows + ] + + workload_row = ( + await db.execute( + select(UserWorkload.workload_ratio).where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id == user_id, + ) + ) + ) + ).scalar_one_or_none() + if workload_row is None: + fallback = ( + await db.execute( + select(User.workload).where( + and_( + User.tenant_id == tenant_id, + User.id == user_id, + ) + ) + ) + ).scalar_one_or_none() + workload_ratio = round(float(fallback or 0) / 100.0, 2) + else: + workload_ratio = float(workload_row or 0.0) + + return { + "user_id": user_id, + "projects": [ + { + "id": int(row.id), + "name": str(row.title or row.name or f"Project#{int(row.id)}"), + "status": str(row.status or "unknown"), + } + for row in project_rows + ], + "assigned_tasks": [ + { + "id": int(row.id), + "project_id": int(row.project_id), + "title": str(row.title or ""), + "status": str(row.status or ""), + "due_date": row.due_date.isoformat() if row.due_date else None, + } + for row in open_tasks + ], + "pending_signals": pending_signals, + "workload_ratio": workload_ratio, + "snapshot_at": now_cn_aware().isoformat(), + } + diff --git a/backend/app/services/context_cache_service/service.py b/backend/app/services/context_cache_service/service.py new file mode 100644 index 00000000..973518a7 --- /dev/null +++ b/backend/app/services/context_cache_service/service.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from .invalidation import ContextCacheInvalidator, register_context_cache_invalidation_hooks +from .mixin_cache_core import ContextCacheCoreMixin +from .mixin_profile_basics import ContextCacheProfileBasicsMixin +from .mixin_profile_route_parser import ContextCacheProfileRouteParserMixin +from .mixin_project_team import ContextCacheProjectTeamMixin +from .mixin_session_invalidate import ContextCacheSessionInvalidateMixin +from .mixin_user_snapshot import ContextCacheUserSnapshotMixin + + +class ContextCacheService( + ContextCacheCoreMixin, + ContextCacheProfileBasicsMixin, + ContextCacheProfileRouteParserMixin, + ContextCacheProjectTeamMixin, + ContextCacheUserSnapshotMixin, + ContextCacheSessionInvalidateMixin, +): + pass + + +context_cache_service = ContextCacheService() + +__all__ = [ + "ContextCacheInvalidator", + "ContextCacheService", + "context_cache_service", + "register_context_cache_invalidation_hooks", +] diff --git a/backend/app/services/context_cache_service/shared.py b/backend/app/services/context_cache_service/shared.py new file mode 100644 index 00000000..5fbb98ba --- /dev/null +++ b/backend/app/services/context_cache_service/shared.py @@ -0,0 +1,54 @@ +"""AI Steward 上下文缓存服务(Redis)与失效钩子。""" + +from __future__ import annotations + +import json +import os +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +from sqlalchemy import and_, case, event, func, inspect, or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import Session + +from ...models import Project, ProjectSignalLink, Signal, Task, User, UserProject, UserWorkload + +from ...utils.timezone import now_cn_aware +try: + import redis.asyncio as redis_async + from redis import Redis as RedisSync + + REDIS_AVAILABLE = True +except Exception: # pragma: no cover + REDIS_AVAILABLE = False + redis_async = None + RedisSync = None + + +SYSTEM_TTL_SECONDS = 30 * 60 +USER_TTL_SECONDS = 5 * 60 +SESSION_TTL_SECONDS = 60 * 60 +PROJECT_LIST_TTL_SECONDS = 10 * 60 +SIGNAL_PENDING_TTL_SECONDS = 5 * 60 +SESSION_MAX_MESSAGES = 20 +DONE_TASK_STATUSES = {"done", "completed", "closed", "resolved", "archived"} +PENDING_SIGNAL_STATUSES = {"pending", "todo", "open", "待处理"} + + +def _safe_int(value: Any, fallback: int = 0) -> int: + try: + return int(value) + except Exception: + return fallback + + +def _json_loads(raw: Optional[str], default: Any) -> Any: + if not raw: + return default + try: + return json.loads(raw) + except Exception: + return default + diff --git a/backend/app/services/daily_briefing.py b/backend/app/services/daily_briefing.py index 4fb1aa07..a64cf5e3 100644 --- a/backend/app/services/daily_briefing.py +++ b/backend/app/services/daily_briefing.py @@ -1,481 +1,5 @@ -""" -Daily Progress Briefing Service - 每日进度简报服务 +"""Compatibility exports for daily briefing service.""" -核心功能: -1. 为每个角色生成个性化简报 -2. 智能过滤无关信息 -3. 根据角色优先级排序内容 -4. 替代传统日报/周报 +from .daily_briefing_core import DailyBriefingService, get_daily_briefing_service -个性化规则: -- AI工程师:关注模型更新、技术信号、实验结果 -- 后端开发:关注API变更、任务阻塞、依赖更新 -- 前端开发:关注UI需求、设计变更、接口更新 -- BOSS/ADMIN:关注整体进度、风险、决策点 -""" -import asyncio -import json -from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Optional -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select -from sqlalchemy import text, func, and_ - -from ..models import ( - User, Project, Task, Signal, CognitiveConflict, - TruthNode, UserRole, ProjectSignalLink -) -from .llm import LLMService - - -from ..utils.timezone import now_cn_aware -def _parse_involved_user_ids(raw_value: Any) -> List[int]: - """兼容 involved_user_ids 的 list / json-string / 单值字符串。""" - if raw_value is None: - return [] - data = raw_value - if isinstance(data, (bytes, bytearray)): - data = data.decode("utf-8", errors="ignore") - if isinstance(data, str): - text = data.strip() - if not text: - return [] - try: - data = json.loads(text) - except (TypeError, ValueError, json.JSONDecodeError): - data = [v.strip() for v in text.split(",") if v.strip()] - if not isinstance(data, list): - data = [data] - - parsed: List[int] = [] - for item in data: - if item is None or isinstance(item, bool): - continue - try: - parsed.append(int(item)) - except (TypeError, ValueError): - continue - return parsed - - -class DailyBriefingService: - """每日进度简报服务""" - - def __init__(self, db: AsyncSession): - self.db = db - self.llm = LLMService() - - async def generate_personalized_briefing( - self, - user_id: int, - tenant_id: int = 1 - ) -> Dict[str, Any]: - """ - 生成个性化每日简报 - - 简报内容根据用户角色自动过滤: - - 只显示相关项目的内容 - - 按优先级排序 - - 突出风险和需要行动的事项 - """ - # 获取用户信息 - user_result = await self.db.execute( - select(User).where(User.id == user_id) - ) - user = user_result.scalar_one_or_none() - if not user: - raise ValueError(f"User {user_id} not found") - - # 计算时间范围 - now = now_cn_aware() - yesterday = now - timedelta(days=1) - - # 根据角色收集相关信息 - relevant_data = await self._collect_relevant_data( - user=user, - since=yesterday, - tenant_id=tenant_id - ) - - # AI 生成个性化摘要 - summary = await self._generate_ai_summary( - user=user, - data=relevant_data, - tenant_id=tenant_id, - ) - - return { - 'user_id': user_id, - 'user_role': self._role_code(user.role), - 'date': now.strftime('%Y-%m-%d'), - 'summary': summary, - 'highlights': relevant_data.get('highlights', []), - 'your_tasks': relevant_data.get('your_tasks', []), - 'risks': relevant_data.get('risks', []), - 'signals': relevant_data.get('signals', []), - 'conflicts': relevant_data.get('conflicts', []), - 'action_items': relevant_data.get('action_items', []), - 'team_updates': relevant_data.get('team_updates', []) - } - - async def _collect_relevant_data( - self, - user: User, - since: datetime, - tenant_id: int - ) -> Dict[str, Any]: - """收集与用户相关的数据""" - - data = { - 'highlights': [], - 'your_tasks': [], - 'risks': [], - 'signals': [], - 'conflicts': [], - 'action_items': [], - 'team_updates': [] - } - - # 获取用户相关的项目 - user_projects = await self._get_user_projects(user.id, tenant_id) - - if not user_projects: - return data - - project_ids = [p.id for p in user_projects] - role_code = self._role_code(user.role) - - # 1. 获取用户的任务更新 - task_result = await self.db.execute( - select(Task, Project).join( - Project, - and_(Project.id == Task.project_id, Project.tenant_id == tenant_id), - ).where( - Task.tenant_id == tenant_id, - Task.project_id.in_(project_ids), - Task.updated_at >= since - ) - ) - task_rows = task_result.all() - - for task, project in task_rows: - # 我的任务 - if task.assignee_id == user.id: - data['your_tasks'].append({ - 'id': task.id, - 'title': task.title, - 'status': task.status, - 'priority': task.priority, - 'project': project.title, - 'updated_at': task.updated_at.isoformat() if hasattr(task, 'updated_at') else None - }) - - # 高优先级或阻塞的任务加入 highlights - if task.priority == 'HIGH' or task.status == 'blocked': - data['highlights'].append({ - 'type': 'task', - 'priority': 'high', - 'content': f'任务"{task.title}"状态: {task.status}', - 'project': project.title - }) - - # 2. 获取风险(阻塞的任务) - for task, project in task_rows: - if task.status == 'blocked': - data['risks'].append({ - 'type': 'task_blocked', - 'task': task.title, - 'project': project.title, - 'severity': 'high' - }) - - # 3. 获取新信号(根据角色过滤) - signal_result = await self.db.execute( - select(Signal).join( - ProjectSignalLink, - ProjectSignalLink.signal_id == Signal.id - ).where( - Signal.tenant_id == tenant_id, - ProjectSignalLink.project_id.in_(project_ids), - Signal.created_at >= since, - Signal.impact_score >= 0.7 # 只显示高影响信号 - ).order_by( - Signal.impact_score.desc() - ).limit(10) - ) - signals = signal_result.scalars().all() - - for signal in signals: - # 根据角色过滤信号内容 - if self._is_signal_relevant_to_role(signal, role_code): - data['signals'].append({ - 'id': signal.id, - 'source': signal.source, - 'content': signal.content[:200], - 'impact_score': signal.impact_score, - 'priority': signal.priority - }) - - # 高影响信号加入 highlights - if signal.impact_score >= 0.9: - data['highlights'].append({ - 'type': 'signal', - 'priority': 'high', - 'content': f'高影响信号: {signal.content[:100]}', - 'source': signal.source - }) - - # 4. 获取认知冲突 - conflict_result = await self.db.execute( - select(CognitiveConflict).join( - TruthNode, - TruthNode.id == CognitiveConflict.truth_node_id - ).where( - CognitiveConflict.tenant_id == tenant_id, - TruthNode.project_id.in_(project_ids), - CognitiveConflict.status == 'active', - CognitiveConflict.created_at >= since - ) - ) - conflicts = conflict_result.scalars().all() - - for conflict in conflicts: - involved_users = _parse_involved_user_ids(conflict.involved_user_ids) - # 如果用户参与了冲突,或是管理员 - if user.id in involved_users or role_code in [UserRole.ADMIN.value, UserRole.BOSS.value]: - data['conflicts'].append({ - 'id': conflict.id, - 'type': conflict.conflict_type, - 'description': conflict.description[:200], - 'severity': 'high' if 'critical' in conflict.description.lower() else 'medium' - }) - - # 冲突加入 highlights - data['highlights'].append({ - 'type': 'conflict', - 'priority': 'high', - 'content': f'认知冲突: {conflict.conflict_type}', - 'project_id': conflict.truth_node_id - }) - - # 5. 生成行动项 - if data['your_tasks']: - for task in data['your_tasks'][:3]: - if task['status'] in ['pending', 'in_progress']: - data['action_items'].append({ - 'type': 'task', - 'content': f'继续推进任务: {task["title"]}', - 'priority': task['priority'] - }) - - if data['conflicts']: - data['action_items'].append({ - 'type': 'conflict', - 'content': f'解决 {len(data["conflicts"])} 个认知冲突', - 'priority': 'high' - }) - - # 6. 团队更新(其他用户的相关任务) - team_task_result = await self.db.execute( - select(Task, Project).join( - Project, - and_(Project.id == Task.project_id, Project.tenant_id == tenant_id), - ).where( - Task.tenant_id == tenant_id, - Task.project_id.in_(project_ids), - Task.assignee_id != user.id, - Task.updated_at >= since - ) - ) - team_tasks = team_task_result.all() - - for task, project in team_tasks[:5]: - data['team_updates'].append({ - 'assignee_id': task.assignee_id, - 'task': task.title, - 'status': task.status, - 'project': project.title - }) - - return data - - async def _get_user_projects( - self, - user_id: int, - tenant_id: int - ) -> List[Project]: - """获取用户相关的项目""" - # 获取用户参与的所有项目 - result = await self.db.execute( - select(Project).where( - Project.tenant_id == tenant_id, - Project.status == 'active' - ) - ) - return result.scalars().all() - - def _is_signal_relevant_to_role( - self, - signal: Signal, - user_role: Any - ) -> bool: - """判断信号是否与用户角色相关""" - content = signal.content.lower() - role_code = self._role_code(user_role) - _ = signal.source.lower() - - # AI工程师关注的内容 - if role_code == UserRole.AI.value: - ai_keywords = ['model', 'llm', 'gpt', 'embedding', 'vector', '训练', '推理', 'ollama', 'langchain'] - if any(kw in content for kw in ai_keywords): - return True - - # 后端开发关注的内容 - elif role_code in {UserRole.BE.value, "BACKEND"}: - backend_keywords = ['api', 'backend', 'server', 'database', 'sql', '后端', '接口', '服务'] - if any(kw in content for kw in backend_keywords): - return True - - # 前端开发关注的内容 - elif role_code in {UserRole.FE.value, "FRONTEND"}: - frontend_keywords = ['ui', 'frontend', 'react', 'vue', 'component', '前端', '界面', '交互'] - if any(kw in content for kw in frontend_keywords): - return True - - # 管理员看到所有高影响信号 - elif role_code in {UserRole.ADMIN.value, UserRole.BOSS.value}: - return signal.impact_score >= 0.8 - - # 默认返回中等影响以上的信号 - return signal.impact_score >= 0.7 - - async def _generate_ai_summary( - self, - user: User, - data: Dict[str, Any], - tenant_id: int, - ) -> str: - """AI 生成个性化摘要""" - role_code = self._role_code(user.role) - prompt = f"""为以下用户生成每日工作摘要: - -用户角色:{role_code} -用户名:{user.username} - -今日概况: -- 你的任务数:{len(data.get('your_tasks', []))} -- 新信号数:{len(data.get('signals', []))} -- 活跃冲突数:{len(data.get('conflicts', []))} -- 风险数:{len(data.get('risks', []))} - -重点事项: -{json.dumps(data.get('highlights', []), ensure_ascii=False, indent=2)[:500]} - -请生成一段简洁的每日摘要(100字以内),突出最重要的信息和行动项。""" - - try: - summary = await self.llm.generate( - prompt, - db=self.db, - tenant_id=tenant_id, - ) - return summary.strip() - except: - # 降级:生成简单摘要 - parts = [] - if data.get('your_tasks'): - parts.append(f"你有 {len(data['your_tasks'])} 个任务需要关注") - if data.get('risks'): - parts.append(f"注意 {len(data['risks'])} 个风险点") - if data.get('conflicts'): - parts.append(f"需要解决 {len(data['conflicts'])} 个认知冲突") - return ";".join(parts) if parts else "今日无重要更新" - - @staticmethod - def _role_code(raw_role: Any) -> str: - if raw_role is None: - return "" - value = getattr(raw_role, "value", raw_role) - return str(value or "").strip().upper() - - async def generate_team_briefing( - self, - project_id: int, - tenant_id: int = 1 - ) -> Dict[str, Any]: - """ - 生成团队简报(给管理者) - - 包含: - - 整体进度 - - 每个成员的工作量 - - 风险和阻塞 - - 需要决策的事项 - """ - # 获取项目信息 - project_result = await self.db.execute( - select(Project).where( - Project.id == project_id, - Project.tenant_id == tenant_id - ) - ) - project = project_result.scalar_one_or_none() - if not project: - raise ValueError(f"Project {project_id} not found") - - # 获取项目所有任务 - task_result = await self.db.execute( - select(Task).where( - Task.project_id == project_id, - Task.tenant_id == tenant_id - ) - ) - tasks = task_result.scalars().all() - - # 统计任务状态 - status_counts = {} - for task in tasks: - status_counts[task.status] = status_counts.get(task.status, 0) + 1 - - # 获取阻塞的任务 - blocked_tasks = [t for t in tasks if t.status == 'blocked'] - - # 获取项目相关信号 - signal_result = await self.db.execute( - select(Signal).join( - ProjectSignalLink, - ProjectSignalLink.signal_id == Signal.id - ).where( - Signal.tenant_id == tenant_id, - ProjectSignalLink.project_id == project_id - ).order_by( - Signal.created_at.desc() - ).limit(10) - ) - signals = signal_result.scalars().all() - - return { - 'project_id': project_id, - 'project_name': project.title, - 'generated_at': now_cn_aware().isoformat(), - 'task_summary': { - 'total': len(tasks), - 'by_status': status_counts, - 'completion_rate': status_counts.get('done', 0) / len(tasks) if tasks else 0 - }, - 'blocked_tasks': [ - {'id': t.id, 'title': t.title, 'assignee_id': t.assignee_id} - for t in blocked_tasks - ], - 'recent_signals': [ - {'id': s.id, 'content': s.content[:100], 'impact_score': s.impact_score} - for s in signals[:5] - ], - 'needs_attention': len(blocked_tasks) > 0 - } - - -# 全局服务实例 -def get_daily_briefing_service(db: AsyncSession) -> DailyBriefingService: - """获取每日简报服务实例""" - return DailyBriefingService(db) +__all__ = ["DailyBriefingService", "get_daily_briefing_service"] diff --git a/backend/app/services/daily_briefing_core/__init__.py b/backend/app/services/daily_briefing_core/__init__.py new file mode 100644 index 00000000..fec4f074 --- /dev/null +++ b/backend/app/services/daily_briefing_core/__init__.py @@ -0,0 +1,6 @@ +"""Daily briefing exports.""" + +from .registry import get_daily_briefing_service +from .service import DailyBriefingService + +__all__ = ["DailyBriefingService", "get_daily_briefing_service"] diff --git a/backend/app/services/daily_briefing_core/mixin_helpers_summary.py b/backend/app/services/daily_briefing_core/mixin_helpers_summary.py new file mode 100644 index 00000000..7bf92427 --- /dev/null +++ b/backend/app/services/daily_briefing_core/mixin_helpers_summary.py @@ -0,0 +1,116 @@ +"""Helper and summary generation methods for daily briefing service.""" + +from __future__ import annotations + +from .shared import Any, Dict, Project, Signal, User, UserRole, json, select + + +class DailyBriefingHelpersSummaryMixin: + """Owns role filtering and AI summary generation helpers.""" + + async def _get_user_projects(self, user_id: int, tenant_id: int): + """获取用户相关的项目。""" + result = await self.db.execute( + select(Project).where( + Project.tenant_id == tenant_id, + Project.status == "active", + ) + ) + return result.scalars().all() + + def _is_signal_relevant_to_role(self, signal: Signal, user_role: Any) -> bool: + """判断信号是否与用户角色相关。""" + content = signal.content.lower() + role_code = self._role_code(user_role) + _ = signal.source.lower() + + if role_code == UserRole.AI.value: + ai_keywords = [ + "model", + "llm", + "gpt", + "embedding", + "vector", + "训练", + "推理", + "ollama", + "langchain", + ] + if any(kw in content for kw in ai_keywords): + return True + + elif role_code in {UserRole.BE.value, "BACKEND"}: + backend_keywords = [ + "api", + "backend", + "server", + "database", + "sql", + "后端", + "接口", + "服务", + ] + if any(kw in content for kw in backend_keywords): + return True + + elif role_code in {UserRole.FE.value, "FRONTEND"}: + frontend_keywords = [ + "ui", + "frontend", + "react", + "vue", + "component", + "前端", + "界面", + "交互", + ] + if any(kw in content for kw in frontend_keywords): + return True + + elif role_code in {UserRole.ADMIN.value, UserRole.BOSS.value}: + return signal.impact_score >= 0.8 + + return signal.impact_score >= 0.7 + + async def _generate_ai_summary(self, user: User, data: Dict[str, Any], tenant_id: int) -> str: + """AI 生成个性化摘要。""" + role_code = self._role_code(user.role) + prompt = f"""为以下用户生成每日工作摘要: + +用户角色:{role_code} +用户名:{user.username} + +今日概况: +- 你的任务数:{len(data.get('your_tasks', []))} +- 新信号数:{len(data.get('signals', []))} +- 活跃冲突数:{len(data.get('conflicts', []))} +- 风险数:{len(data.get('risks', []))} + +重点事项: +{json.dumps(data.get('highlights', []), ensure_ascii=False, indent=2)[:500]} + +请生成一段简洁的每日摘要(100字以内),突出最重要的信息和行动项。""" + + try: + summary = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + ) + return summary.strip() + except Exception: + parts = [] + if data.get("your_tasks"): + parts.append(f"你有 {len(data['your_tasks'])} 个任务需要关注") + if data.get("risks"): + parts.append(f"注意 {len(data['risks'])} 个风险点") + if data.get("conflicts"): + parts.append(f"需要解决 {len(data['conflicts'])} 个认知冲突") + return ";".join(parts) if parts else "今日无重要更新" + + @staticmethod + def _role_code(raw_role: Any) -> str: + if raw_role is None: + return "" + value = getattr(raw_role, "value", raw_role) + return str(value or "").strip().upper() diff --git a/backend/app/services/daily_briefing_core/mixin_personal.py b/backend/app/services/daily_briefing_core/mixin_personal.py new file mode 100644 index 00000000..268186f9 --- /dev/null +++ b/backend/app/services/daily_briefing_core/mixin_personal.py @@ -0,0 +1,251 @@ +"""Personalized briefing generation flow.""" + +from __future__ import annotations + +from .parsing import parse_involved_user_ids +from .shared import ( + Any, + CognitiveConflict, + Dict, + Project, + ProjectSignalLink, + Signal, + Task, + TruthNode, + User, + UserRole, + and_, + select, + timedelta, + now_cn_aware, +) + + +class DailyBriefingPersonalMixin: + """Build user-personalized daily briefing payload.""" + + async def generate_personalized_briefing( + self, + user_id: int, + tenant_id: int = 1, + ) -> Dict[str, Any]: + """Generate personalized daily briefing.""" + user_result = await self.db.execute(select(User).where(User.id == user_id)) + user = user_result.scalar_one_or_none() + if not user: + raise ValueError(f"User {user_id} not found") + + now = now_cn_aware() + yesterday = now - timedelta(days=1) + + relevant_data = await self._collect_relevant_data( + user=user, + since=yesterday, + tenant_id=tenant_id, + ) + + summary = await self._generate_ai_summary( + user=user, + data=relevant_data, + tenant_id=tenant_id, + ) + + return { + "user_id": user_id, + "user_role": self._role_code(user.role), + "date": now.strftime("%Y-%m-%d"), + "summary": summary, + "highlights": relevant_data.get("highlights", []), + "your_tasks": relevant_data.get("your_tasks", []), + "risks": relevant_data.get("risks", []), + "signals": relevant_data.get("signals", []), + "conflicts": relevant_data.get("conflicts", []), + "action_items": relevant_data.get("action_items", []), + "team_updates": relevant_data.get("team_updates", []), + } + + async def _collect_relevant_data( + self, + user: User, + since, + tenant_id: int, + ) -> Dict[str, Any]: + """Collect role-relevant data for the user.""" + data = { + "highlights": [], + "your_tasks": [], + "risks": [], + "signals": [], + "conflicts": [], + "action_items": [], + "team_updates": [], + } + + user_projects = await self._get_user_projects(user.id, tenant_id) + if not user_projects: + return data + + project_ids = [p.id for p in user_projects] + role_code = self._role_code(user.role) + + task_result = await self.db.execute( + select(Task, Project) + .join(Project, and_(Project.id == Task.project_id, Project.tenant_id == tenant_id)) + .where( + Task.tenant_id == tenant_id, + Task.project_id.in_(project_ids), + Task.updated_at >= since, + ) + ) + task_rows = task_result.all() + + for task, project in task_rows: + if task.assignee_id == user.id: + data["your_tasks"].append( + { + "id": task.id, + "title": task.title, + "status": task.status, + "priority": task.priority, + "project": project.title, + "updated_at": task.updated_at.isoformat() + if hasattr(task, "updated_at") + else None, + } + ) + + if task.priority == "HIGH" or task.status == "blocked": + data["highlights"].append( + { + "type": "task", + "priority": "high", + "content": f'任务"{task.title}"状态: {task.status}', + "project": project.title, + } + ) + + for task, project in task_rows: + if task.status == "blocked": + data["risks"].append( + { + "type": "task_blocked", + "task": task.title, + "project": project.title, + "severity": "high", + } + ) + + signal_result = await self.db.execute( + select(Signal) + .join(ProjectSignalLink, ProjectSignalLink.signal_id == Signal.id) + .where( + Signal.tenant_id == tenant_id, + ProjectSignalLink.project_id.in_(project_ids), + Signal.created_at >= since, + Signal.impact_score >= 0.7, + ) + .order_by(Signal.impact_score.desc()) + .limit(10) + ) + signals = signal_result.scalars().all() + + for signal in signals: + if self._is_signal_relevant_to_role(signal, role_code): + data["signals"].append( + { + "id": signal.id, + "source": signal.source, + "content": signal.content[:200], + "impact_score": signal.impact_score, + "priority": signal.priority, + } + ) + + if signal.impact_score >= 0.9: + data["highlights"].append( + { + "type": "signal", + "priority": "high", + "content": f"高影响信号: {signal.content[:100]}", + "source": signal.source, + } + ) + + conflict_result = await self.db.execute( + select(CognitiveConflict) + .join(TruthNode, TruthNode.id == CognitiveConflict.truth_node_id) + .where( + CognitiveConflict.tenant_id == tenant_id, + TruthNode.project_id.in_(project_ids), + CognitiveConflict.status == "active", + CognitiveConflict.created_at >= since, + ) + ) + conflicts = conflict_result.scalars().all() + + for conflict in conflicts: + involved_users = parse_involved_user_ids(conflict.involved_user_ids) + if user.id in involved_users or role_code in [UserRole.ADMIN.value, UserRole.BOSS.value]: + data["conflicts"].append( + { + "id": conflict.id, + "type": conflict.conflict_type, + "description": conflict.description[:200], + "severity": "high" + if "critical" in conflict.description.lower() + else "medium", + } + ) + + data["highlights"].append( + { + "type": "conflict", + "priority": "high", + "content": f"认知冲突: {conflict.conflict_type}", + "project_id": conflict.truth_node_id, + } + ) + + if data["your_tasks"]: + for task in data["your_tasks"][:3]: + if task["status"] in ["pending", "in_progress"]: + data["action_items"].append( + { + "type": "task", + "content": f'继续推进任务: {task["title"]}', + "priority": task["priority"], + } + ) + + if data["conflicts"]: + data["action_items"].append( + { + "type": "conflict", + "content": f'解决 {len(data["conflicts"])} 个认知冲突', + "priority": "high", + } + ) + + team_task_result = await self.db.execute( + select(Task, Project) + .join(Project, and_(Project.id == Task.project_id, Project.tenant_id == tenant_id)) + .where( + Task.tenant_id == tenant_id, + Task.project_id.in_(project_ids), + Task.assignee_id != user.id, + Task.updated_at >= since, + ) + ) + team_tasks = team_task_result.all() + + for task, project in team_tasks[:5]: + data["team_updates"].append( + { + "assignee_id": task.assignee_id, + "task": task.title, + "status": task.status, + "project": project.title, + } + ) + + return data diff --git a/backend/app/services/daily_briefing_core/mixin_team.py b/backend/app/services/daily_briefing_core/mixin_team.py new file mode 100644 index 00000000..5c3a7394 --- /dev/null +++ b/backend/app/services/daily_briefing_core/mixin_team.py @@ -0,0 +1,67 @@ +"""Team briefing generation flow.""" + +from __future__ import annotations + +from .shared import Dict, Project, ProjectSignalLink, Signal, Task, select, now_cn_aware + + +class DailyBriefingTeamMixin: + """Build manager-facing team briefing payload.""" + + async def generate_team_briefing(self, project_id: int, tenant_id: int = 1) -> Dict[str, object]: + """生成团队简报(给管理者)。""" + project_result = await self.db.execute( + select(Project).where( + Project.id == project_id, + Project.tenant_id == tenant_id, + ) + ) + project = project_result.scalar_one_or_none() + if not project: + raise ValueError(f"Project {project_id} not found") + + task_result = await self.db.execute( + select(Task).where( + Task.project_id == project_id, + Task.tenant_id == tenant_id, + ) + ) + tasks = task_result.scalars().all() + + status_counts = {} + for task in tasks: + status_counts[task.status] = status_counts.get(task.status, 0) + 1 + + blocked_tasks = [t for t in tasks if t.status == "blocked"] + + signal_result = await self.db.execute( + select(Signal) + .join(ProjectSignalLink, ProjectSignalLink.signal_id == Signal.id) + .where( + Signal.tenant_id == tenant_id, + ProjectSignalLink.project_id == project_id, + ) + .order_by(Signal.created_at.desc()) + .limit(10) + ) + signals = signal_result.scalars().all() + + return { + "project_id": project_id, + "project_name": project.title, + "generated_at": now_cn_aware().isoformat(), + "task_summary": { + "total": len(tasks), + "by_status": status_counts, + "completion_rate": status_counts.get("done", 0) / len(tasks) if tasks else 0, + }, + "blocked_tasks": [ + {"id": t.id, "title": t.title, "assignee_id": t.assignee_id} + for t in blocked_tasks + ], + "recent_signals": [ + {"id": s.id, "content": s.content[:100], "impact_score": s.impact_score} + for s in signals[:5] + ], + "needs_attention": len(blocked_tasks) > 0, + } diff --git a/backend/app/services/daily_briefing_core/parsing.py b/backend/app/services/daily_briefing_core/parsing.py new file mode 100644 index 00000000..db66d959 --- /dev/null +++ b/backend/app/services/daily_briefing_core/parsing.py @@ -0,0 +1,34 @@ +"""Parsing helpers for daily briefing service.""" + +from __future__ import annotations + +from .shared import Any, List, json + + +def parse_involved_user_ids(raw_value: Any) -> List[int]: + """兼容 involved_user_ids 的 list / json-string / 单值字符串。""" + if raw_value is None: + return [] + data = raw_value + if isinstance(data, (bytes, bytearray)): + data = data.decode("utf-8", errors="ignore") + if isinstance(data, str): + text = data.strip() + if not text: + return [] + try: + data = json.loads(text) + except (TypeError, ValueError, json.JSONDecodeError): + data = [v.strip() for v in text.split(",") if v.strip()] + if not isinstance(data, list): + data = [data] + + parsed: List[int] = [] + for item in data: + if item is None or isinstance(item, bool): + continue + try: + parsed.append(int(item)) + except (TypeError, ValueError): + continue + return parsed diff --git a/backend/app/services/daily_briefing_core/registry.py b/backend/app/services/daily_briefing_core/registry.py new file mode 100644 index 00000000..ab0ea774 --- /dev/null +++ b/backend/app/services/daily_briefing_core/registry.py @@ -0,0 +1,11 @@ +"""Factory for daily briefing service.""" + +from __future__ import annotations + +from .service import DailyBriefingService +from .shared import AsyncSession + + +def get_daily_briefing_service(db: AsyncSession) -> DailyBriefingService: + """获取每日简报服务实例。""" + return DailyBriefingService(db) diff --git a/backend/app/services/daily_briefing_core/service.py b/backend/app/services/daily_briefing_core/service.py new file mode 100644 index 00000000..b95a68c9 --- /dev/null +++ b/backend/app/services/daily_briefing_core/service.py @@ -0,0 +1,20 @@ +"""Daily briefing service composition.""" + +from __future__ import annotations + +from .mixin_helpers_summary import DailyBriefingHelpersSummaryMixin +from .mixin_personal import DailyBriefingPersonalMixin +from .mixin_team import DailyBriefingTeamMixin +from .shared import AsyncSession, LLMService + + +class DailyBriefingService( + DailyBriefingHelpersSummaryMixin, + DailyBriefingPersonalMixin, + DailyBriefingTeamMixin, +): + """每日进度简报服务。""" + + def __init__(self, db: AsyncSession): + self.db = db + self.llm = LLMService() diff --git a/backend/app/services/daily_briefing_core/shared.py b/backend/app/services/daily_briefing_core/shared.py new file mode 100644 index 00000000..b77b3faf --- /dev/null +++ b/backend/app/services/daily_briefing_core/shared.py @@ -0,0 +1,52 @@ +"""Shared imports for daily briefing service.""" + +from __future__ import annotations + +import asyncio +import json +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional + +from sqlalchemy import and_, func, text +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from ...models import ( + CognitiveConflict, + Project, + ProjectSignalLink, + Signal, + Task, + TruthNode, + User, + UserRole, +) +from ...utils.timezone import now_cn_aware +from ..llm import LLMService + +__all__ = [ + "Any", + "AsyncSession", + "CognitiveConflict", + "Dict", + "LLMService", + "List", + "Optional", + "Project", + "ProjectSignalLink", + "Signal", + "Task", + "TruthNode", + "User", + "UserRole", + "and_", + "asyncio", + "datetime", + "func", + "json", + "now_cn_aware", + "select", + "text", + "timedelta", + "timezone", +] diff --git a/backend/app/services/domain.py b/backend/app/services/domain.py index 6ceef0ff..97afa758 100644 --- a/backend/app/services/domain.py +++ b/backend/app/services/domain.py @@ -1,449 +1,21 @@ -""" -Neural Sync 2.0 - Project Domain Service - -项目域服务 - 管理动态项目域 -每个域有独立的信号源、知识库和配置 -""" - -import asyncio -import logging -from typing import Optional, Dict, List, Any -from pydantic import BaseModel, Field -from datetime import datetime -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from ..utils.timezone import now_cn_naive -logger = logging.getLogger(__name__) - - -class DomainConfig(BaseModel): - """项目域配置""" - signal_sources: List[str] = Field(default_factory=lambda: ["hackernews"]) - ai_config: Dict[str, Any] = Field(default_factory=dict) - audit_threshold: float = 98.0 - auto_merge_enabled: bool = False - webhook_urls: Dict[str, str] = Field(default_factory=dict) - - -class ProjectDomainCreate(BaseModel): - """创建项目域请求""" - name: str - slug: str - description: Optional[str] = None - config: Optional[DomainConfig] = None - is_default: bool = False - - -class ProjectDomainUpdate(BaseModel): - """更新项目域请求""" - name: Optional[str] = None - description: Optional[str] = None - config: Optional[DomainConfig] = None - is_active: Optional[bool] = None - is_default: Optional[bool] = None - - -class ProjectDomainResponse(BaseModel): - """项目域响应""" - id: str - name: str - slug: str - description: Optional[str] - config: DomainConfig - is_active: bool - is_default: bool - signal_count: int - context_node_count: int - experiment_count: int - created_at: datetime - updated_at: datetime - last_synced_at: Optional[datetime] - - -class DomainSyncProgress(BaseModel): - """域同步进度""" - domain_id: str - phase: str # connecting, loading_knowledge, syncing_signals, complete - progress: float # 0.0 - 1.0 - message: str - - -class ProjectDomainService: - """ - 项目域服务 - - 功能: - - CRUD 操作 - - 域切换和知识同步 - - 统计信息更新 - - 默认域管理 - """ - - def __init__(self, db_session: AsyncSession, event_bus=None): - self.db = db_session - self.event_bus = event_bus - self._active_domain_id: Optional[str] = None - - async def create_domain(self, request: ProjectDomainCreate) -> ProjectDomainResponse: - """创建新项目域""" - from ..models import ProjectDomain - import uuid - - # 检查 slug 唯一性 - existing = await self.db.execute( - select(ProjectDomain).where(ProjectDomain.slug == request.slug) - ) - if existing.scalar_one_or_none(): - raise ValueError(f"Slug '{request.slug}' already exists") - - # 如果设置为默认域,取消其他域的默认状态 - if request.is_default: - await self._clear_default_domains() - - # 序列化配置 - config_dict = request.config.model_dump() if request.config else {} - - domain = ProjectDomain( - name=request.name, - slug=request.slug, - description=request.description, - config=config_dict, - is_default=request.is_default, - ) - - self.db.add(domain) - await self.db.commit() - await self.db.refresh(domain) - - # 发布域创建事件 - if self.event_bus: - from ..events import EventType, Event - await self.event_bus.publish( - Event( - type=EventType.DOMAIN_CREATED, - data={ - "domain_id": str(domain.id), - "name": domain.name, - "slug": domain.slug, - }, - ) - ) - - return self._to_response(domain) - - async def get_domain(self, domain_id: str) -> Optional[ProjectDomainResponse]: - """获取单个域""" - from ..models import ProjectDomain - import uuid - - try: - domain_uuid = uuid.UUID(domain_id) - except ValueError: - return None - - result = await self.db.execute( - select(ProjectDomain).where(ProjectDomain.id == domain_uuid) - ) - domain = result.scalar_one_or_none() - - if not domain: - return None - - return self._to_response(domain) - - async def get_domain_by_slug(self, slug: str) -> Optional[ProjectDomainResponse]: - """通过 slug 获取域""" - from ..models import ProjectDomain - - result = await self.db.execute( - select(ProjectDomain).where(ProjectDomain.slug == slug) - ) - domain = result.scalar_one_or_none() - - if not domain: - return None - - return self._to_response(domain) - - async def list_domains( - self, include_inactive: bool = False - ) -> List[ProjectDomainResponse]: - """列出所有域""" - from ..models import ProjectDomain - - query = select(ProjectDomain) - if not include_inactive: - query = query.where(ProjectDomain.is_active == True) - - query = query.order_by(ProjectDomain.is_default.desc(), ProjectDomain.created_at.asc()) - - result = await self.db.execute(query) - domains = result.scalars().all() - - return [self._to_response(d) for d in domains] - - async def update_domain( - self, domain_id: str, request: ProjectDomainUpdate - ) -> Optional[ProjectDomainResponse]: - """更新域""" - from ..models import ProjectDomain - import uuid - - try: - domain_uuid = uuid.UUID(domain_id) - except ValueError: - return None - - result = await self.db.execute( - select(ProjectDomain).where(ProjectDomain.id == domain_uuid) - ) - domain = result.scalar_one_or_none() - - if not domain: - return None - - # 更新字段 - if request.name is not None: - domain.name = request.name - if request.description is not None: - domain.description = request.description - if request.config is not None: - domain.config = request.config.model_dump() - if request.is_active is not None: - domain.is_active = request.is_active - if request.is_default is not None and request.is_default != domain.is_default: - if request.is_default: - await self._clear_default_domains() - domain.is_default = request.is_default - - domain.updated_at = now_cn_naive() - - await self.db.commit() - await self.db.refresh(domain) - - # 发布域更新事件 - if self.event_bus: - from ..events import EventType, Event - await self.event_bus.publish( - Event( - type=EventType.DOMAIN_UPDATED, - data={ - "domain_id": str(domain.id), - "name": domain.name, - "slug": domain.slug, - }, - ) - ) - - return self._to_response(domain) - - async def delete_domain(self, domain_id: str) -> bool: - """删除域""" - from ..models import ProjectDomain - import uuid - - try: - domain_uuid = uuid.UUID(domain_id) - except ValueError: - return False - - result = await self.db.execute( - select(ProjectDomain).where(ProjectDomain.id == domain_uuid) - ) - domain = result.scalar_one_or_none() - - if not domain: - return False - - # 不允许删除默认域 - if domain.is_default: - raise ValueError("Cannot delete default domain") - - await self.db.delete(domain) - await self.db.commit() - - # 发布域删除事件 - if self.event_bus: - from ..events import EventType, Event - await self.event_bus.publish( - Event( - type=EventType.DOMAIN_DELETED, - data={ - "domain_id": str(domain.id), - "name": domain.name, - "slug": domain.slug, - }, - ) - ) - - return True - - async def switch_domain(self, domain_id: str) -> DomainSyncProgress: - """ - 切换活动域 - - 流程: - 1. 验证域存在且激活 - 2. 开始知识同步(1.5s 动画) - 3. 更新活动域 ID - 4. 返回同步进度 - """ - from ..models import ProjectDomain - import uuid - - try: - domain_uuid = uuid.UUID(domain_id) - except ValueError: - raise ValueError("Invalid domain ID") - - result = await self.db.execute( - select(ProjectDomain).where(ProjectDomain.id == domain_uuid) - ) - domain = result.scalar_one_or_none() - - if not domain: - raise ValueError("Domain not found") - - if not domain.is_active: - raise ValueError("Domain is not active") - - # 更新最后同步时间 - domain.last_synced_at = now_cn_naive() - await self.db.commit() - - # 设置活动域 - self._active_domain_id = str(domain.id) - - # 发布域切换事件 - if self.event_bus: - from ..events import EventType, Event - await self.event_bus.publish( - Event( - type=EventType.DOMAIN_SWITCHED, - data={ - "domain_id": str(domain.id), - "name": domain.name, - "slug": domain.slug, - }, - ) - ) - - # 模拟同步进度(1.5s) - return DomainSyncProgress( - domain_id=str(domain.id), - phase="complete", - progress=1.0, - message=f"已切换到 {domain.name}", - ) - - async def get_default_domain(self) -> Optional[ProjectDomainResponse]: - """获取默认域""" - from ..models import ProjectDomain - - result = await self.db.execute( - select(ProjectDomain).where(ProjectDomain.is_default == True) - ) - domain = result.scalar_one_or_none() - - if not domain: - # 如果没有默认域,返回第一个激活域 - result = await self.db.execute( - select(ProjectDomain) - .where(ProjectDomain.is_active == True) - .order_by(ProjectDomain.created_at.asc()) - .limit(1) - ) - domain = result.scalar_one_or_none() - - if not domain: - return None - - return self._to_response(domain) - - async def update_domain_stats(self, domain_id: str) -> None: - """更新域统计信息""" - from ..models import ProjectDomain, Signal, ContextNode, Experiment - import uuid - - try: - domain_uuid = uuid.UUID(domain_id) - except ValueError: - return - - result = await self.db.execute( - select(ProjectDomain).where(ProjectDomain.id == domain_uuid) - ) - domain = result.scalar_one_or_none() - - if not domain: - return - - # TODO: 需要添加域关联到 Signal, ContextNode, Experiment - # 当前使用全局计数 - signal_count = await self.db.execute(select(Signal)) - domain.signal_count = len(signal_count.scalars().all()) - - context_count = await self.db.execute(select(ContextNode)) - domain.context_node_count = len(context_count.scalars().all()) - - experiment_count = await self.db.execute(select(Experiment)) - domain.experiment_count = len(experiment_count.scalars().all()) - - await self.db.commit() - - async def get_active_domain_id(self) -> Optional[str]: - """获取当前活动域 ID""" - if self._active_domain_id: - return self._active_domain_id - - # 如果没有设置,返回默认域 - default = await self.get_default_domain() - if default: - self._active_domain_id = default.id - return default.id - - return None - - async def _clear_default_domains(self) -> None: - """清除所有域的默认状态""" - from ..models import ProjectDomain - - result = await self.db.execute( - select(ProjectDomain).where(ProjectDomain.is_default == True) - ) - domains = result.scalars().all() - - for domain in domains: - domain.is_default = False - - await self.db.commit() - - def _to_response(self, domain) -> ProjectDomainResponse: - """转换为响应模型""" - config = DomainConfig(**domain.config) if domain.config else DomainConfig() - - return ProjectDomainResponse( - id=str(domain.id), - name=domain.name, - slug=domain.slug, - description=domain.description, - config=config, - is_active=domain.is_active, - is_default=domain.is_default, - signal_count=domain.signal_count, - context_node_count=domain.context_node_count, - experiment_count=domain.experiment_count, - created_at=domain.created_at, - updated_at=domain.updated_at, - last_synced_at=getattr(domain, 'last_synced_at', None), - ) - - -# 全局服务实例 -_domain_service = None - - -def get_domain_service(db_session: AsyncSession, event_bus=None) -> ProjectDomainService: - """获取项目域服务实例""" - return ProjectDomainService(db_session, event_bus) +"""Compatibility exports for project domain service.""" + +from .domain_core import ( + DomainConfig, + DomainSyncProgress, + ProjectDomainCreate, + ProjectDomainResponse, + ProjectDomainService, + ProjectDomainUpdate, + get_domain_service, +) + +__all__ = [ + "DomainConfig", + "ProjectDomainCreate", + "ProjectDomainUpdate", + "ProjectDomainResponse", + "DomainSyncProgress", + "ProjectDomainService", + "get_domain_service", +] diff --git a/backend/app/services/domain_core/__init__.py b/backend/app/services/domain_core/__init__.py new file mode 100644 index 00000000..b33fbaff --- /dev/null +++ b/backend/app/services/domain_core/__init__.py @@ -0,0 +1,21 @@ +"""Project domain service exports.""" + +from .registry import get_domain_service +from .schemas import ( + DomainConfig, + DomainSyncProgress, + ProjectDomainCreate, + ProjectDomainResponse, + ProjectDomainUpdate, +) +from .service import ProjectDomainService + +__all__ = [ + "DomainConfig", + "ProjectDomainCreate", + "ProjectDomainUpdate", + "ProjectDomainResponse", + "DomainSyncProgress", + "ProjectDomainService", + "get_domain_service", +] diff --git a/backend/app/services/domain_core/mixin_crud.py b/backend/app/services/domain_core/mixin_crud.py new file mode 100644 index 00000000..c9d93407 --- /dev/null +++ b/backend/app/services/domain_core/mixin_crud.py @@ -0,0 +1,188 @@ +"""CRUD operations for project domain service.""" + +from __future__ import annotations + +from .schemas import ProjectDomainCreate, ProjectDomainResponse, ProjectDomainUpdate +from .shared import List, Optional, now_cn_naive, select + + +class DomainCrudMixin: + """Create/read/update/delete domain operations.""" + + async def create_domain(self, request: ProjectDomainCreate) -> ProjectDomainResponse: + """创建新项目域。""" + from ...models import ProjectDomain + + existing = await self.db.execute(select(ProjectDomain).where(ProjectDomain.slug == request.slug)) + if existing.scalar_one_or_none(): + raise ValueError(f"Slug '{request.slug}' already exists") + + if request.is_default: + await self._clear_default_domains() + + config_dict = request.config.model_dump() if request.config else {} + + domain = ProjectDomain( + name=request.name, + slug=request.slug, + description=request.description, + config=config_dict, + is_default=request.is_default, + ) + + self.db.add(domain) + await self.db.commit() + await self.db.refresh(domain) + + if self.event_bus: + from ...events import Event, EventType + + await self.event_bus.publish( + Event( + type=EventType.DOMAIN_CREATED, + data={ + "domain_id": str(domain.id), + "name": domain.name, + "slug": domain.slug, + }, + ) + ) + + return self._to_response(domain) + + async def get_domain(self, domain_id: str) -> Optional[ProjectDomainResponse]: + """获取单个域。""" + from ...models import ProjectDomain + import uuid + + try: + domain_uuid = uuid.UUID(domain_id) + except ValueError: + return None + + result = await self.db.execute(select(ProjectDomain).where(ProjectDomain.id == domain_uuid)) + domain = result.scalar_one_or_none() + + if not domain: + return None + + return self._to_response(domain) + + async def get_domain_by_slug(self, slug: str) -> Optional[ProjectDomainResponse]: + """通过 slug 获取域。""" + from ...models import ProjectDomain + + result = await self.db.execute(select(ProjectDomain).where(ProjectDomain.slug == slug)) + domain = result.scalar_one_or_none() + + if not domain: + return None + + return self._to_response(domain) + + async def list_domains(self, include_inactive: bool = False) -> List[ProjectDomainResponse]: + """列出所有域。""" + from ...models import ProjectDomain + + query = select(ProjectDomain) + if not include_inactive: + query = query.where(ProjectDomain.is_active == True) + + query = query.order_by(ProjectDomain.is_default.desc(), ProjectDomain.created_at.asc()) + + result = await self.db.execute(query) + domains = result.scalars().all() + + return [self._to_response(d) for d in domains] + + async def update_domain( + self, + domain_id: str, + request: ProjectDomainUpdate, + ) -> Optional[ProjectDomainResponse]: + """更新域。""" + from ...models import ProjectDomain + import uuid + + try: + domain_uuid = uuid.UUID(domain_id) + except ValueError: + return None + + result = await self.db.execute(select(ProjectDomain).where(ProjectDomain.id == domain_uuid)) + domain = result.scalar_one_or_none() + + if not domain: + return None + + if request.name is not None: + domain.name = request.name + if request.description is not None: + domain.description = request.description + if request.config is not None: + domain.config = request.config.model_dump() + if request.is_active is not None: + domain.is_active = request.is_active + if request.is_default is not None and request.is_default != domain.is_default: + if request.is_default: + await self._clear_default_domains() + domain.is_default = request.is_default + + domain.updated_at = now_cn_naive() + + await self.db.commit() + await self.db.refresh(domain) + + if self.event_bus: + from ...events import Event, EventType + + await self.event_bus.publish( + Event( + type=EventType.DOMAIN_UPDATED, + data={ + "domain_id": str(domain.id), + "name": domain.name, + "slug": domain.slug, + }, + ) + ) + + return self._to_response(domain) + + async def delete_domain(self, domain_id: str) -> bool: + """删除域。""" + from ...models import ProjectDomain + import uuid + + try: + domain_uuid = uuid.UUID(domain_id) + except ValueError: + return False + + result = await self.db.execute(select(ProjectDomain).where(ProjectDomain.id == domain_uuid)) + domain = result.scalar_one_or_none() + + if not domain: + return False + + if domain.is_default: + raise ValueError("Cannot delete default domain") + + await self.db.delete(domain) + await self.db.commit() + + if self.event_bus: + from ...events import Event, EventType + + await self.event_bus.publish( + Event( + type=EventType.DOMAIN_DELETED, + data={ + "domain_id": str(domain.id), + "name": domain.name, + "slug": domain.slug, + }, + ) + ) + + return True diff --git a/backend/app/services/domain_core/mixin_runtime_stats.py b/backend/app/services/domain_core/mixin_runtime_stats.py new file mode 100644 index 00000000..4bb74813 --- /dev/null +++ b/backend/app/services/domain_core/mixin_runtime_stats.py @@ -0,0 +1,147 @@ +"""Domain switch/statistics/runtime helpers.""" + +from __future__ import annotations + +from .schemas import DomainConfig, DomainSyncProgress, ProjectDomainResponse +from .shared import Optional, now_cn_naive, select + + +class DomainRuntimeStatsMixin: + """Switch active domain, maintain stats, and map responses.""" + + async def switch_domain(self, domain_id: str) -> DomainSyncProgress: + """切换活动域。""" + from ...models import ProjectDomain + import uuid + + try: + domain_uuid = uuid.UUID(domain_id) + except ValueError: + raise ValueError("Invalid domain ID") + + result = await self.db.execute(select(ProjectDomain).where(ProjectDomain.id == domain_uuid)) + domain = result.scalar_one_or_none() + + if not domain: + raise ValueError("Domain not found") + + if not domain.is_active: + raise ValueError("Domain is not active") + + domain.last_synced_at = now_cn_naive() + await self.db.commit() + + self._active_domain_id = str(domain.id) + + if self.event_bus: + from ...events import Event, EventType + + await self.event_bus.publish( + Event( + type=EventType.DOMAIN_SWITCHED, + data={ + "domain_id": str(domain.id), + "name": domain.name, + "slug": domain.slug, + }, + ) + ) + + return DomainSyncProgress( + domain_id=str(domain.id), + phase="complete", + progress=1.0, + message=f"已切换到 {domain.name}", + ) + + async def get_default_domain(self) -> Optional[ProjectDomainResponse]: + """获取默认域。""" + from ...models import ProjectDomain + + result = await self.db.execute(select(ProjectDomain).where(ProjectDomain.is_default == True)) + domain = result.scalar_one_or_none() + + if not domain: + result = await self.db.execute( + select(ProjectDomain) + .where(ProjectDomain.is_active == True) + .order_by(ProjectDomain.created_at.asc()) + .limit(1) + ) + domain = result.scalar_one_or_none() + + if not domain: + return None + + return self._to_response(domain) + + async def update_domain_stats(self, domain_id: str) -> None: + """更新域统计信息。""" + from ...models import ContextNode, Experiment, ProjectDomain, Signal + import uuid + + try: + domain_uuid = uuid.UUID(domain_id) + except ValueError: + return + + result = await self.db.execute(select(ProjectDomain).where(ProjectDomain.id == domain_uuid)) + domain = result.scalar_one_or_none() + + if not domain: + return + + signal_count = await self.db.execute(select(Signal)) + domain.signal_count = len(signal_count.scalars().all()) + + context_count = await self.db.execute(select(ContextNode)) + domain.context_node_count = len(context_count.scalars().all()) + + experiment_count = await self.db.execute(select(Experiment)) + domain.experiment_count = len(experiment_count.scalars().all()) + + await self.db.commit() + + async def get_active_domain_id(self) -> Optional[str]: + """获取当前活动域 ID。""" + if self._active_domain_id: + return self._active_domain_id + + default = await self.get_default_domain() + if default: + self._active_domain_id = default.id + return default.id + + return None + + async def _clear_default_domains(self) -> None: + """清除所有域的默认状态。""" + from ...models import ProjectDomain + + result = await self.db.execute(select(ProjectDomain).where(ProjectDomain.is_default == True)) + domains = result.scalars().all() + + for domain in domains: + domain.is_default = False + + await self.db.commit() + + def _to_response(self, domain) -> ProjectDomainResponse: + """转换为响应模型。""" + config = DomainConfig(**domain.config) if domain.config else DomainConfig() + + return ProjectDomainResponse( + id=str(domain.id), + name=domain.name, + slug=domain.slug, + description=domain.description, + config=config, + is_active=domain.is_active, + is_default=domain.is_default, + signal_count=domain.signal_count, + context_node_count=domain.context_node_count, + experiment_count=domain.experiment_count, + created_at=domain.created_at, + updated_at=domain.updated_at, + last_synced_at=getattr(domain, "last_synced_at", None), + ) diff --git a/backend/app/services/domain_core/registry.py b/backend/app/services/domain_core/registry.py new file mode 100644 index 00000000..f2276c04 --- /dev/null +++ b/backend/app/services/domain_core/registry.py @@ -0,0 +1,11 @@ +"""Factory for project domain service.""" + +from __future__ import annotations + +from .service import ProjectDomainService +from .shared import AsyncSession + + +def get_domain_service(db_session: AsyncSession, event_bus=None) -> ProjectDomainService: + """获取项目域服务实例。""" + return ProjectDomainService(db_session, event_bus) diff --git a/backend/app/services/domain_core/schemas.py b/backend/app/services/domain_core/schemas.py new file mode 100644 index 00000000..e4529dc8 --- /dev/null +++ b/backend/app/services/domain_core/schemas.py @@ -0,0 +1,71 @@ +"""Pydantic models for project domain service.""" + +from __future__ import annotations + +from .shared import Any, BaseModel, Dict, Field, List, Optional, datetime + + +class DomainConfig(BaseModel): + """项目域配置。""" + + signal_sources: List[str] = Field(default_factory=lambda: ["hackernews"]) + ai_config: Dict[str, Any] = Field(default_factory=dict) + audit_threshold: float = 98.0 + auto_merge_enabled: bool = False + webhook_urls: Dict[str, str] = Field(default_factory=dict) + + +class ProjectDomainCreate(BaseModel): + """创建项目域请求。""" + + name: str + slug: str + description: Optional[str] = None + config: Optional[DomainConfig] = None + is_default: bool = False + + +class ProjectDomainUpdate(BaseModel): + """更新项目域请求。""" + + name: Optional[str] = None + description: Optional[str] = None + config: Optional[DomainConfig] = None + is_active: Optional[bool] = None + is_default: Optional[bool] = None + + +class ProjectDomainResponse(BaseModel): + """项目域响应。""" + + id: str + name: str + slug: str + description: Optional[str] + config: DomainConfig + is_active: bool + is_default: bool + signal_count: int + context_node_count: int + experiment_count: int + created_at: datetime + updated_at: datetime + last_synced_at: Optional[datetime] + + +class DomainSyncProgress(BaseModel): + """域同步进度。""" + + domain_id: str + phase: str + progress: float + message: str + + +__all__ = [ + "DomainConfig", + "ProjectDomainCreate", + "ProjectDomainUpdate", + "ProjectDomainResponse", + "DomainSyncProgress", +] diff --git a/backend/app/services/domain_core/service.py b/backend/app/services/domain_core/service.py new file mode 100644 index 00000000..4fcaf6f8 --- /dev/null +++ b/backend/app/services/domain_core/service.py @@ -0,0 +1,16 @@ +"""Project domain service composition.""" + +from __future__ import annotations + +from .mixin_crud import DomainCrudMixin +from .mixin_runtime_stats import DomainRuntimeStatsMixin +from .shared import AsyncSession, Optional + + +class ProjectDomainService(DomainCrudMixin, DomainRuntimeStatsMixin): + """项目域服务。""" + + def __init__(self, db_session: AsyncSession, event_bus=None): + self.db = db_session + self.event_bus = event_bus + self._active_domain_id: Optional[str] = None diff --git a/backend/app/services/domain_core/shared.py b/backend/app/services/domain_core/shared.py new file mode 100644 index 00000000..090f584b --- /dev/null +++ b/backend/app/services/domain_core/shared.py @@ -0,0 +1,31 @@ +"""Shared imports for project domain service.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime +import logging +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...utils.timezone import now_cn_naive + +logger = logging.getLogger(__name__) + +__all__ = [ + "Any", + "AsyncSession", + "BaseModel", + "Dict", + "Field", + "List", + "Optional", + "asyncio", + "datetime", + "logger", + "now_cn_naive", + "select", +] diff --git a/backend/app/services/event_workflow.py b/backend/app/services/event_workflow.py index 74894891..2c650366 100644 --- a/backend/app/services/event_workflow.py +++ b/backend/app/services/event_workflow.py @@ -1,615 +1,3 @@ -""" -Event-Driven Workflow Engine - 事件驱动工作流引擎 +from __future__ import annotations -核心功能: -1. 替代固定 Sprint 迭代,实现实时事件驱动迭代 -2. 市场变化可在10分钟内反映到项目优先级 -3. 自动触发任务重排、依赖更新 -4. 推送实时通知给相关角色 - -工作流事件: -- 高影响信号到达 → 自动重新评估项目优先级 -- 阻塞检测 → 触发替代方案生成 -- 认知冲突 → 触发对齐流程 -- 技术栈变更 → 自动更新任务依赖 -- 竞品动态 → 触发战略调整 -""" -import asyncio -import json -from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Optional, Callable -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select -from sqlalchemy import text, update - -from ..models import ( - User, Project, Task, Signal, CognitiveConflict, - TruthNode, ProjectSignalLink, UserRole -) -from .llm import LLMService -from ..events import EventType, Event, get_event_bus - - -from ..utils.timezone import now_cn_aware -class WorkflowEventType: - """工作流事件类型""" - HIGH_IMPACT_SIGNAL = "high_impact_signal" - BLOCKER_DETECTED = "blocker_detected" - COGNITIVE_CONFLICT = "cognitive_conflict" - TECH_STACK_CHANGE = "tech_stack_change" - COMPETITOR_UPDATE = "competitor_update" - TASK_COMPLETED = "task_completed" - TASK_BLOCKED = "task_blocked" - PROJECT_RISK = "project_risk" - - -class EventDrivenWorkflowEngine: - """事件驱动工作流引擎""" - - def __init__(self, db: AsyncSession): - self.db = db - self.llm = LLMService() - self.event_bus = get_event_bus() - - # 注册工作流处理器 - self._handlers = { - WorkflowEventType.HIGH_IMPACT_SIGNAL: self._handle_high_impact_signal, - WorkflowEventType.BLOCKER_DETECTED: self._handle_blocker_detected, - WorkflowEventType.COGNITIVE_CONFLICT: self._handle_cognitive_conflict, - WorkflowEventType.TECH_STACK_CHANGE: self._handle_tech_stack_change, - WorkflowEventType.COMPETITOR_UPDATE: self._handle_competitor_update, - WorkflowEventType.TASK_COMPLETED: self._handle_task_completed, - WorkflowEventType.TASK_BLOCKED: self._handle_task_blocked, - WorkflowEventType.PROJECT_RISK: self._handle_project_risk, - } - - async def trigger_workflow( - self, - event_type: str, - event_data: Dict[str, Any], - tenant_id: int = 1 - ) -> Dict[str, Any]: - """ - 触发工作流 - - Args: - event_type: 事件类型 - event_data: 事件数据 - tenant_id: 租户ID - - Returns: - 处理结果 - """ - handler = self._handlers.get(event_type) - if not handler: - return {'status': 'ignored', 'reason': 'Unknown event type'} - - try: - result = await handler(event_data, tenant_id) - - # 发布到事件总线 - await self.event_bus.publish( - EventType.WORKFLOW_TRIGGERED, - { - 'workflow_type': event_type, - 'result': result, - 'timestamp': now_cn_aware().isoformat() - } - ) - - return { - 'status': 'processed', - 'event_type': event_type, - 'result': result - } - - except Exception as e: - return { - 'status': 'error', - 'event_type': event_type, - 'error': str(e) - } - - async def _handle_high_impact_signal( - self, - event_data: Dict[str, Any], - tenant_id: int - ) -> Dict[str, Any]: - """ - 处理高影响信号 - - 动作: - 1. 评估对关联项目的影响 - 2. 调整项目优先级 - 3. 创建响应任务 - 4. 通知相关角色 - """ - signal_id = event_data.get('signal_id') - impact_threshold = 0.85 - - # 获取信号 - signal_result = await self.db.execute( - select(Signal).where( - Signal.id == signal_id, - Signal.tenant_id == tenant_id - ) - ) - signal = signal_result.scalar_one_or_none() - if not signal: - raise ValueError(f"Signal {signal_id} not found") - - # 获取关联的项目 - link_result = await self.db.execute( - select(ProjectSignalLink).where( - ProjectSignalLink.signal_id == signal_id, - ProjectSignalLink.tenant_id == tenant_id - ) - ) - links = link_result.scalars().all() - - affected_projects = [] - created_tasks = [] - - for link in links: - project_result = await self.db.execute( - select(Project).where(Project.id == link.project_id) - ) - project = project_result.scalar_one_or_none() - if not project: - continue - - # AI 评估影响 - impact = await self._assess_signal_impact_on_project(signal, project) - - if impact.get('should_adjust_priority', False): - # 调整项目优先级 - old_priority = project.priority if hasattr(project, 'priority') else 'medium' - new_priority = impact.get('suggested_priority', 'high') - - if hasattr(project, 'priority'): - project.priority = new_priority - await self.db.commit() - - affected_projects.append({ - 'project_id': project.id, - 'project_name': project.title, - 'old_priority': old_priority, - 'new_priority': new_priority, - 'reason': impact.get('reason', '') - }) - - # 创建响应任务 - if impact.get('needs_response_task', False): - task = await self._create_response_task( - project=project, - signal=signal, - impact=impact, - tenant_id=tenant_id - ) - created_tasks.append({ - 'task_id': task.id, - 'title': task.title, - 'priority': task.priority - }) - - return { - 'affected_projects': affected_projects, - 'created_tasks': created_tasks, - 'message': f'处理了 {len(affected_projects)} 个受影响的项目' - } - - async def _handle_blocker_detected( - self, - event_data: Dict[str, Any], - tenant_id: int - ) -> Dict[str, Any]: - """ - 处理阻塞检测 - - 动作: - 1. AI 生成替代方案 - 2. 创建绕过任务 - 3. 通知相关人员 - """ - blocker_id = event_data.get('blocker_id') - task_id = event_data.get('task_id') - - # 获取阻塞信息 - conflict_result = await self.db.execute( - select(CognitiveConflict).where( - CognitiveConflict.id == blocker_id, - CognitiveConflict.tenant_id == tenant_id - ) - ) - blocker = conflict_result.scalar_one_or_none() - - if not blocker: - raise ValueError(f"Blocker {blocker_id} not found") - - # AI 生成解决方案 - prompt = f"""为以下阻塞生成绕过方案: - -阻塞描述:{blocker.description} - -请提供: -1. 问题根本原因分析 -2. 3个可行的绕过方案 -3. 每个方案的预估工作量 -4. 推荐的最优方案 - -以 JSON 格式返回。""" - - try: - response = await self.llm.generate( - prompt, - db=self.db, - tenant_id=tenant_id, - ) - # 解析 AI 响应(简化处理) - return { - 'blocker_id': blocker_id, - 'alternatives_generated': True, - 'ai_response': response - } - except: - return { - 'blocker_id': blocker_id, - 'alternatives_generated': False, - 'message': 'AI 方案生成失败' - } - - async def _handle_cognitive_conflict( - self, - event_data: Dict[str, Any], - tenant_id: int - ) -> Dict[str, Any]: - """ - 处理认知冲突 - - 动作: - 1. 识别涉及的用户 - 2. 发送对齐邀请 - 3. 调度异步对齐会话 - """ - conflict_id = event_data.get('conflict_id') - - conflict_result = await self.db.execute( - select(CognitiveConflict).where( - CognitiveConflict.id == conflict_id, - CognitiveConflict.tenant_id == tenant_id - ) - ) - conflict = conflict_result.scalar_one_or_none() - - if not conflict: - raise ValueError(f"Conflict {conflict_id} not found") - - # 获取涉及的用户 - involved_user_ids = json.loads(conflict.involved_user_ids) if conflict.involved_user_ids else [] - - return { - 'conflict_id': conflict_id, - 'involved_users': involved_user_ids, - 'alignment_session_created': len(involved_user_ids) >= 2, - 'message': '对齐邀请已发送' - } - - async def _handle_tech_stack_change( - self, - event_data: Dict[str, Any], - tenant_id: int - ) -> Dict[str, Any]: - """ - 处理技术栈变更 - - 动作: - 1. 识别受影响的任务 - 2. 更新任务描述和依赖 - 3. 重新估算工时 - """ - project_id = event_data.get('project_id') - new_tech = event_data.get('new_tech', '') - - # 获取项目的所有任务 - task_result = await self.db.execute( - select(Task).where( - Task.project_id == project_id, - Task.tenant_id == tenant_id, - Task.status.in_(['pending', 'in_progress']) - ) - ) - tasks = task_result.scalars().all() - - affected_tasks = [] - for task in tasks: - # AI 判断任务是否受影响 - is_affected = await self._is_task_affected_by_tech_change(task, new_tech) - if is_affected: - affected_tasks.append({ - 'task_id': task.id, - 'task_title': task.title, - 'impact': 'high' - }) - - return { - 'project_id': project_id, - 'new_tech': new_tech, - 'affected_tasks': affected_tasks, - 'message': f'识别出 {len(affected_tasks)} 个受影响的任务' - } - - async def _handle_competitor_update( - self, - event_data: Dict[str, Any], - tenant_id: int - ) -> Dict[str, Any]: - """ - 处理竞品动态 - - 动作: - 1. 评估战略影响 - 2. 调整项目方向 - 3. 创建应对任务 - """ - signal_content = event_data.get('signal_content', '') - competitor_name = event_data.get('competitor_name', '竞品') - - # AI 分析影响 - prompt = f"""分析以下竞品动态的战略影响: - -竞品:{competitor_name} -动态:{signal_content} - -请评估: -1. 对我们的威胁等级(high/medium/low) -2. 建议的应对措施 -3. 是否需要调整项目方向 - -以 JSON 格式返回。""" - - try: - response = await self.llm.generate( - prompt, - db=self.db, - tenant_id=tenant_id, - ) - return { - 'competitor': competitor_name, - 'threat_assessment': response, - 'strategic_adjustment_recommended': True - } - except: - return { - 'competitor': competitor_name, - 'threat_assessment': 'medium', - 'message': 'AI 评估失败' - } - - async def _handle_task_completed( - self, - event_data: Dict[str, Any], - tenant_id: int - ) -> Dict[str, Any]: - """ - 处理任务完成 - - 动作: - 1. 检查依赖任务,解锁可开始的 - 2. 更新项目进度 - 3. 触发后续任务 - """ - task_id = event_data.get('task_id') - - # 获取任务 - task_result = await self.db.execute( - select(Task).where( - Task.id == task_id, - Task.tenant_id == tenant_id - ) - ) - task = task_result.scalar_one_or_none() - - if not task: - raise ValueError(f"Task {task_id} not found") - - # 查找依赖此任务的其他任务 - # 这里简化处理:查找同项目的待办任务 - dependent_result = await self.db.execute( - select(Task).where( - Task.project_id == task.project_id, - Task.tenant_id == tenant_id, - Task.status == 'pending', - Task.id != task_id - ).limit(5) - ) - dependents = dependent_result.scalars().all() - - return { - 'completed_task_id': task_id, - 'project_id': task.project_id, - 'unlocked_tasks': [ - {'id': t.id, 'title': t.title} - for t in dependents - ], - 'message': f'解锁了 {len(dependents)} 个待办任务' - } - - async def _handle_task_blocked( - self, - event_data: Dict[str, Any], - tenant_id: int - ) -> Dict[str, Any]: - """ - 处理任务阻塞 - - 动作: - 1. 标记任务状态 - 2. 触发阻塞检测工作流 - 3. 通知项目负责人 - """ - task_id = event_data.get('task_id') - blocker_reason = event_data.get('reason', 'Unknown') - - # 更新任务状态 - await self.db.execute( - update(Task) - .where(Task.id == task_id, Task.tenant_id == tenant_id) - .values(status='blocked') - ) - await self.db.commit() - - return { - 'task_id': task_id, - 'blocker_reason': blocker_reason, - 'status': 'blocked', - 'workflow_triggered': True - } - - async def _handle_project_risk( - self, - event_data: Dict[str, Any], - tenant_id: int - ) -> Dict[str, Any]: - """ - 处理项目风险 - - 动作: - 1. 评估风险等级 - 2. 创建缓解任务 - 3. 通知管理层 - """ - project_id = event_data.get('project_id') - risk_type = event_data.get('risk_type', 'general') - risk_description = event_data.get('description', '') - - # 获取项目 - project_result = await self.db.execute( - select(Project).where( - Project.id == project_id, - Project.tenant_id == tenant_id - ) - ) - project = project_result.scalar_one_or_none() - - if not project: - raise ValueError(f"Project {project_id} not found") - - return { - 'project_id': project_id, - 'project_name': project.title if project else 'Unknown', - 'risk_type': risk_type, - 'risk_level': 'high', - 'mitigation_required': True, - 'message': '风险已记录,等待处理' - } - - # ==================== Helper Methods ==================== - - async def _assess_signal_impact_on_project( - self, - signal: Signal, - project: Project - ) -> Dict[str, Any]: - """AI 评估信号对项目的影响""" - prompt = f"""评估以下信号对项目的影响: - -项目:{project.title} -描述:{project.description} - -信号: -来源:{signal.source} -内容:{signal.content} -影响分数:{signal.impact_score} - -请判断: -1. 是否需要调整项目优先级? -2. 建议的优先级是什么? -3. 是否需要创建响应任务? -4. 原因是什么? - -以 JSON 格式返回: -{{ - "should_adjust_priority": boolean, - "suggested_priority": "high/medium/low", - "needs_response_task": boolean, - "reason": "原因说明" -}}""" - - try: - response = await self.llm.generate( - prompt, - db=self.db, - tenant_id=signal.tenant_id if getattr(signal, "tenant_id", None) is not None else None, - project_id=project.id if project else None, - ) - response = response.strip() - if response.startswith('```'): - response = response.split('```')[1] - if response.startswith('json'): - response = response[4:] - return json.loads(response.strip()) - except: - return { - 'should_adjust_priority': signal.impact_score > 0.9, - 'suggested_priority': 'high' if signal.impact_score > 0.9 else 'medium', - 'needs_response_task': signal.impact_score > 0.85, - 'reason': f'信号影响分数 {signal.impact_score}' - } - - async def _create_response_task( - self, - project: Project, - signal: Signal, - impact: Dict[str, Any], - tenant_id: int - ): - """创建响应任务""" - from sqlalchemy import text - - next_task_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM tasks"))).scalar() or 1 - - task = Task( - id=next_task_id, - truth_node_id=None, - project_id=project.id, - assignee_id=None, - title=f'响应信号: {signal.content[:50]}', - description=f"""自动生成的响应任务 - -触发信号: -来源:{signal.source} -内容:{signal.content} - -影响评估:{impact.get('reason', '')} - -建议行动: -请根据信号内容制定具体的响应方案。""", - required_skill_tag_ids=[], - status='pending', - priority='HIGH', - estimated_hours=8, - actual_hours=0, - quality_score=0, - tenant_id=tenant_id - ) - self.db.add(task) - await self.db.commit() - await self.db.refresh(task) - return task - - async def _is_task_affected_by_tech_change( - self, - task: Task, - new_tech: str - ) -> bool: - """判断任务是否受技术变更影响""" - # 简化处理:检查任务描述中是否包含相关技术关键词 - task_keywords = task.title.lower() + ' ' + task.description.lower() - # 这里可以更复杂,使用 AI 判断 - return new_tech.lower() in task_keywords - - -# 全局服务实例 -def get_event_workflow_engine(db: AsyncSession) -> EventDrivenWorkflowEngine: - """获取事件驱动工作流引擎实例""" - return EventDrivenWorkflowEngine(db) +from .event_workflow_core import * diff --git a/backend/app/services/event_workflow_core/__init__.py b/backend/app/services/event_workflow_core/__init__.py new file mode 100644 index 00000000..bf7820f3 --- /dev/null +++ b/backend/app/services/event_workflow_core/__init__.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +from .service import * diff --git a/backend/app/services/event_workflow_core/mixin_core.py b/backend/app/services/event_workflow_core/mixin_core.py new file mode 100644 index 00000000..8c5189d7 --- /dev/null +++ b/backend/app/services/event_workflow_core/mixin_core.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from .shared import * + +class WorkflowCoreMixin: + def __init__(self, db: AsyncSession): + self.db = db + self.llm = LLMService() + self.event_bus = get_event_bus() + + # 注册工作流处理器 + self._handlers = { + WorkflowEventType.HIGH_IMPACT_SIGNAL: self._handle_high_impact_signal, + WorkflowEventType.BLOCKER_DETECTED: self._handle_blocker_detected, + WorkflowEventType.COGNITIVE_CONFLICT: self._handle_cognitive_conflict, + WorkflowEventType.TECH_STACK_CHANGE: self._handle_tech_stack_change, + WorkflowEventType.COMPETITOR_UPDATE: self._handle_competitor_update, + WorkflowEventType.TASK_COMPLETED: self._handle_task_completed, + WorkflowEventType.TASK_BLOCKED: self._handle_task_blocked, + WorkflowEventType.PROJECT_RISK: self._handle_project_risk, + } + + async def trigger_workflow( + self, + event_type: str, + event_data: Dict[str, Any], + tenant_id: int = 1 + ) -> Dict[str, Any]: + """ + 触发工作流 + + Args: + event_type: 事件类型 + event_data: 事件数据 + tenant_id: 租户ID + + Returns: + 处理结果 + """ + handler = self._handlers.get(event_type) + if not handler: + return {'status': 'ignored', 'reason': 'Unknown event type'} + + try: + result = await handler(event_data, tenant_id) + + # 发布到事件总线 + await self.event_bus.publish( + EventType.WORKFLOW_TRIGGERED, + { + 'workflow_type': event_type, + 'result': result, + 'timestamp': now_cn_aware().isoformat() + } + ) + + return { + 'status': 'processed', + 'event_type': event_type, + 'result': result + } + + except Exception as e: + return { + 'status': 'error', + 'event_type': event_type, + 'error': str(e) + } + diff --git a/backend/app/services/event_workflow_core/mixin_handlers_primary.py b/backend/app/services/event_workflow_core/mixin_handlers_primary.py new file mode 100644 index 00000000..42c09bf7 --- /dev/null +++ b/backend/app/services/event_workflow_core/mixin_handlers_primary.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from .shared import * + +class WorkflowHandlersPrimaryMixin: + async def _handle_high_impact_signal( + self, + event_data: Dict[str, Any], + tenant_id: int + ) -> Dict[str, Any]: + """ + 处理高影响信号 + + 动作: + 1. 评估对关联项目的影响 + 2. 调整项目优先级 + 3. 创建响应任务 + 4. 通知相关角色 + """ + signal_id = event_data.get('signal_id') + impact_threshold = 0.85 + + # 获取信号 + signal_result = await self.db.execute( + select(Signal).where( + Signal.id == signal_id, + Signal.tenant_id == tenant_id + ) + ) + signal = signal_result.scalar_one_or_none() + if not signal: + raise ValueError(f"Signal {signal_id} not found") + + # 获取关联的项目 + link_result = await self.db.execute( + select(ProjectSignalLink).where( + ProjectSignalLink.signal_id == signal_id, + ProjectSignalLink.tenant_id == tenant_id + ) + ) + links = link_result.scalars().all() + + affected_projects = [] + created_tasks = [] + + for link in links: + project_result = await self.db.execute( + select(Project).where(Project.id == link.project_id) + ) + project = project_result.scalar_one_or_none() + if not project: + continue + + # AI 评估影响 + impact = await self._assess_signal_impact_on_project(signal, project) + + if impact.get('should_adjust_priority', False): + # 调整项目优先级 + old_priority = project.priority if hasattr(project, 'priority') else 'medium' + new_priority = impact.get('suggested_priority', 'high') + + if hasattr(project, 'priority'): + project.priority = new_priority + await self.db.commit() + + affected_projects.append({ + 'project_id': project.id, + 'project_name': project.title, + 'old_priority': old_priority, + 'new_priority': new_priority, + 'reason': impact.get('reason', '') + }) + + # 创建响应任务 + if impact.get('needs_response_task', False): + task = await self._create_response_task( + project=project, + signal=signal, + impact=impact, + tenant_id=tenant_id + ) + created_tasks.append({ + 'task_id': task.id, + 'title': task.title, + 'priority': task.priority + }) + + return { + 'affected_projects': affected_projects, + 'created_tasks': created_tasks, + 'message': f'处理了 {len(affected_projects)} 个受影响的项目' + } + + async def _handle_blocker_detected( + self, + event_data: Dict[str, Any], + tenant_id: int + ) -> Dict[str, Any]: + """ + 处理阻塞检测 + + 动作: + 1. AI 生成替代方案 + 2. 创建绕过任务 + 3. 通知相关人员 + """ + blocker_id = event_data.get('blocker_id') + task_id = event_data.get('task_id') + + # 获取阻塞信息 + conflict_result = await self.db.execute( + select(CognitiveConflict).where( + CognitiveConflict.id == blocker_id, + CognitiveConflict.tenant_id == tenant_id + ) + ) + blocker = conflict_result.scalar_one_or_none() + + if not blocker: + raise ValueError(f"Blocker {blocker_id} not found") + + # AI 生成解决方案 + prompt = f"""为以下阻塞生成绕过方案: + +阻塞描述:{blocker.description} + +请提供: +1. 问题根本原因分析 +2. 3个可行的绕过方案 +3. 每个方案的预估工作量 +4. 推荐的最优方案 + +以 JSON 格式返回。""" + + try: + response = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + ) + # 解析 AI 响应(简化处理) + return { + 'blocker_id': blocker_id, + 'alternatives_generated': True, + 'ai_response': response + } + except: + return { + 'blocker_id': blocker_id, + 'alternatives_generated': False, + 'message': 'AI 方案生成失败' + } + + async def _handle_cognitive_conflict( + self, + event_data: Dict[str, Any], + tenant_id: int + ) -> Dict[str, Any]: + """ + 处理认知冲突 + + 动作: + 1. 识别涉及的用户 + 2. 发送对齐邀请 + 3. 调度异步对齐会话 + """ + conflict_id = event_data.get('conflict_id') + + conflict_result = await self.db.execute( + select(CognitiveConflict).where( + CognitiveConflict.id == conflict_id, + CognitiveConflict.tenant_id == tenant_id + ) + ) + conflict = conflict_result.scalar_one_or_none() + + if not conflict: + raise ValueError(f"Conflict {conflict_id} not found") + + # 获取涉及的用户 + involved_user_ids = json.loads(conflict.involved_user_ids) if conflict.involved_user_ids else [] + + return { + 'conflict_id': conflict_id, + 'involved_users': involved_user_ids, + 'alignment_session_created': len(involved_user_ids) >= 2, + 'message': '对齐邀请已发送' + } + diff --git a/backend/app/services/event_workflow_core/mixin_handlers_secondary.py b/backend/app/services/event_workflow_core/mixin_handlers_secondary.py new file mode 100644 index 00000000..8f07ad2a --- /dev/null +++ b/backend/app/services/event_workflow_core/mixin_handlers_secondary.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +from .shared import * + +class WorkflowHandlersSecondaryMixin: + async def _handle_tech_stack_change( + self, + event_data: Dict[str, Any], + tenant_id: int + ) -> Dict[str, Any]: + """ + 处理技术栈变更 + + 动作: + 1. 识别受影响的任务 + 2. 更新任务描述和依赖 + 3. 重新估算工时 + """ + project_id = event_data.get('project_id') + new_tech = event_data.get('new_tech', '') + + # 获取项目的所有任务 + task_result = await self.db.execute( + select(Task).where( + Task.project_id == project_id, + Task.tenant_id == tenant_id, + Task.status.in_(['pending', 'in_progress']) + ) + ) + tasks = task_result.scalars().all() + + affected_tasks = [] + for task in tasks: + # AI 判断任务是否受影响 + is_affected = await self._is_task_affected_by_tech_change(task, new_tech) + if is_affected: + affected_tasks.append({ + 'task_id': task.id, + 'task_title': task.title, + 'impact': 'high' + }) + + return { + 'project_id': project_id, + 'new_tech': new_tech, + 'affected_tasks': affected_tasks, + 'message': f'识别出 {len(affected_tasks)} 个受影响的任务' + } + + async def _handle_competitor_update( + self, + event_data: Dict[str, Any], + tenant_id: int + ) -> Dict[str, Any]: + """ + 处理竞品动态 + + 动作: + 1. 评估战略影响 + 2. 调整项目方向 + 3. 创建应对任务 + """ + signal_content = event_data.get('signal_content', '') + competitor_name = event_data.get('competitor_name', '竞品') + + # AI 分析影响 + prompt = f"""分析以下竞品动态的战略影响: + +竞品:{competitor_name} +动态:{signal_content} + +请评估: +1. 对我们的威胁等级(high/medium/low) +2. 建议的应对措施 +3. 是否需要调整项目方向 + +以 JSON 格式返回。""" + + try: + response = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + ) + return { + 'competitor': competitor_name, + 'threat_assessment': response, + 'strategic_adjustment_recommended': True + } + except: + return { + 'competitor': competitor_name, + 'threat_assessment': 'medium', + 'message': 'AI 评估失败' + } + + async def _handle_task_completed( + self, + event_data: Dict[str, Any], + tenant_id: int + ) -> Dict[str, Any]: + """ + 处理任务完成 + + 动作: + 1. 检查依赖任务,解锁可开始的 + 2. 更新项目进度 + 3. 触发后续任务 + """ + task_id = event_data.get('task_id') + + # 获取任务 + task_result = await self.db.execute( + select(Task).where( + Task.id == task_id, + Task.tenant_id == tenant_id + ) + ) + task = task_result.scalar_one_or_none() + + if not task: + raise ValueError(f"Task {task_id} not found") + + # 查找依赖此任务的其他任务 + # 这里简化处理:查找同项目的待办任务 + dependent_result = await self.db.execute( + select(Task).where( + Task.project_id == task.project_id, + Task.tenant_id == tenant_id, + Task.status == 'pending', + Task.id != task_id + ).limit(5) + ) + dependents = dependent_result.scalars().all() + + return { + 'completed_task_id': task_id, + 'project_id': task.project_id, + 'unlocked_tasks': [ + {'id': t.id, 'title': t.title} + for t in dependents + ], + 'message': f'解锁了 {len(dependents)} 个待办任务' + } + + async def _handle_task_blocked( + self, + event_data: Dict[str, Any], + tenant_id: int + ) -> Dict[str, Any]: + """ + 处理任务阻塞 + + 动作: + 1. 标记任务状态 + 2. 触发阻塞检测工作流 + 3. 通知项目负责人 + """ + task_id = event_data.get('task_id') + blocker_reason = event_data.get('reason', 'Unknown') + + # 更新任务状态 + await self.db.execute( + update(Task) + .where(Task.id == task_id, Task.tenant_id == tenant_id) + .values(status='blocked') + ) + await self.db.commit() + + return { + 'task_id': task_id, + 'blocker_reason': blocker_reason, + 'status': 'blocked', + 'workflow_triggered': True + } + + async def _handle_project_risk( + self, + event_data: Dict[str, Any], + tenant_id: int + ) -> Dict[str, Any]: + """ + 处理项目风险 + + 动作: + 1. 评估风险等级 + 2. 创建缓解任务 + 3. 通知管理层 + """ + project_id = event_data.get('project_id') + risk_type = event_data.get('risk_type', 'general') + risk_description = event_data.get('description', '') + + # 获取项目 + project_result = await self.db.execute( + select(Project).where( + Project.id == project_id, + Project.tenant_id == tenant_id + ) + ) + project = project_result.scalar_one_or_none() + + if not project: + raise ValueError(f"Project {project_id} not found") + + return { + 'project_id': project_id, + 'project_name': project.title if project else 'Unknown', + 'risk_type': risk_type, + 'risk_level': 'high', + 'mitigation_required': True, + 'message': '风险已记录,等待处理' + } + diff --git a/backend/app/services/event_workflow_core/mixin_helpers.py b/backend/app/services/event_workflow_core/mixin_helpers.py new file mode 100644 index 00000000..6c45da15 --- /dev/null +++ b/backend/app/services/event_workflow_core/mixin_helpers.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from .shared import * + +class WorkflowHelpersMixin: + async def _assess_signal_impact_on_project( + self, + signal: Signal, + project: Project + ) -> Dict[str, Any]: + """AI 评估信号对项目的影响""" + prompt = f"""评估以下信号对项目的影响: + +项目:{project.title} +描述:{project.description} + +信号: +来源:{signal.source} +内容:{signal.content} +影响分数:{signal.impact_score} + +请判断: +1. 是否需要调整项目优先级? +2. 建议的优先级是什么? +3. 是否需要创建响应任务? +4. 原因是什么? + +以 JSON 格式返回: +{{ + "should_adjust_priority": boolean, + "suggested_priority": "high/medium/low", + "needs_response_task": boolean, + "reason": "原因说明" +}}""" + + try: + response = await self.llm.generate( + prompt, + db=self.db, + tenant_id=signal.tenant_id if getattr(signal, "tenant_id", None) is not None else None, + project_id=project.id if project else None, + ) + response = response.strip() + if response.startswith('```'): + response = response.split('```')[1] + if response.startswith('json'): + response = response[4:] + return json.loads(response.strip()) + except: + return { + 'should_adjust_priority': signal.impact_score > 0.9, + 'suggested_priority': 'high' if signal.impact_score > 0.9 else 'medium', + 'needs_response_task': signal.impact_score > 0.85, + 'reason': f'信号影响分数 {signal.impact_score}' + } + + async def _create_response_task( + self, + project: Project, + signal: Signal, + impact: Dict[str, Any], + tenant_id: int + ): + """创建响应任务""" + from sqlalchemy import text + + next_task_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM tasks"))).scalar() or 1 + + task = Task( + id=next_task_id, + truth_node_id=None, + project_id=project.id, + assignee_id=None, + title=f'响应信号: {signal.content[:50]}', + description=f"""自动生成的响应任务 + +触发信号: +来源:{signal.source} +内容:{signal.content} + +影响评估:{impact.get('reason', '')} + +建议行动: +请根据信号内容制定具体的响应方案。""", + required_skill_tag_ids=[], + status='pending', + priority='HIGH', + estimated_hours=8, + actual_hours=0, + quality_score=0, + tenant_id=tenant_id + ) + self.db.add(task) + await self.db.commit() + await self.db.refresh(task) + return task + + async def _is_task_affected_by_tech_change( + self, + task: Task, + new_tech: str + ) -> bool: + """判断任务是否受技术变更影响""" + # 简化处理:检查任务描述中是否包含相关技术关键词 + task_keywords = task.title.lower() + ' ' + task.description.lower() + # 这里可以更复杂,使用 AI 判断 + return new_tech.lower() in task_keywords + diff --git a/backend/app/services/event_workflow_core/service.py b/backend/app/services/event_workflow_core/service.py new file mode 100644 index 00000000..5071b6e2 --- /dev/null +++ b/backend/app/services/event_workflow_core/service.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from .mixin_core import WorkflowCoreMixin +from .mixin_handlers_primary import WorkflowHandlersPrimaryMixin +from .mixin_handlers_secondary import WorkflowHandlersSecondaryMixin +from .mixin_helpers import WorkflowHelpersMixin +from .shared import AsyncSession, WorkflowEventType + + +class EventDrivenWorkflowEngine( + WorkflowCoreMixin, + WorkflowHandlersPrimaryMixin, + WorkflowHandlersSecondaryMixin, + WorkflowHelpersMixin, +): + """事件驱动工作流引擎。""" + + +def get_event_workflow_engine(db: AsyncSession) -> EventDrivenWorkflowEngine: + """获取事件驱动工作流引擎实例""" + return EventDrivenWorkflowEngine(db) + + +__all__ = ["EventDrivenWorkflowEngine", "WorkflowEventType", "get_event_workflow_engine"] diff --git a/backend/app/services/event_workflow_core/shared.py b/backend/app/services/event_workflow_core/shared.py new file mode 100644 index 00000000..13b761e6 --- /dev/null +++ b/backend/app/services/event_workflow_core/shared.py @@ -0,0 +1,45 @@ +""" +Event-Driven Workflow Engine - 事件驱动工作流引擎 + +核心功能: +1. 替代固定 Sprint 迭代,实现实时事件驱动迭代 +2. 市场变化可在10分钟内反映到项目优先级 +3. 自动触发任务重排、依赖更新 +4. 推送实时通知给相关角色 + +工作流事件: +- 高影响信号到达 → 自动重新评估项目优先级 +- 阻塞检测 → 触发替代方案生成 +- 认知冲突 → 触发对齐流程 +- 技术栈变更 → 自动更新任务依赖 +- 竞品动态 → 触发战略调整 +""" +import asyncio +import json +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional, Callable +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy import text, update + +from ...models import ( + User, Project, Task, Signal, CognitiveConflict, + TruthNode, ProjectSignalLink, UserRole +) +from ..llm import LLMService +from ...events import EventType, Event, get_event_bus + + +from ...utils.timezone import now_cn_aware +class WorkflowEventType: + """工作流事件类型""" + HIGH_IMPACT_SIGNAL = "high_impact_signal" + BLOCKER_DETECTED = "blocker_detected" + COGNITIVE_CONFLICT = "cognitive_conflict" + TECH_STACK_CHANGE = "tech_stack_change" + COMPETITOR_UPDATE = "competitor_update" + TASK_COMPLETED = "task_completed" + TASK_BLOCKED = "task_blocked" + PROJECT_RISK = "project_risk" + + diff --git a/backend/app/services/experiment_sync/__init__.py b/backend/app/services/experiment_sync/__init__.py new file mode 100644 index 00000000..f0a5383a --- /dev/null +++ b/backend/app/services/experiment_sync/__init__.py @@ -0,0 +1,5 @@ +"""Experiment sync exports.""" + +from .service import ExperimentSyncService + +__all__ = ["ExperimentSyncService"] diff --git a/backend/app/services/experiment_sync/mixin_helpers.py b/backend/app/services/experiment_sync/mixin_helpers.py new file mode 100644 index 00000000..0416827c --- /dev/null +++ b/backend/app/services/experiment_sync/mixin_helpers.py @@ -0,0 +1,77 @@ +"""Low-level helper methods for experiment sync service.""" + +from __future__ import annotations + +from .shared import Any, Dict, Experiment, ExperimentTruthNodeSync, Optional, TruthNode, and_, now_cn_aware, select + + +class ExperimentSyncHelpersMixin: + """Fetchers and sync-data mutation helpers.""" + + async def _get_experiment(self, experiment_id: int, tenant_id: int) -> Optional[Experiment]: + result = await self.db.execute( + select(Experiment).where( + and_(Experiment.id == experiment_id, Experiment.tenant_id == tenant_id) + ) + ) + return result.scalar_one_or_none() + + async def _get_truth_node(self, truth_node_id: int, tenant_id: int) -> Optional[TruthNode]: + result = await self.db.execute( + select(TruthNode).where( + and_(TruthNode.id == truth_node_id, TruthNode.tenant_id == tenant_id) + ) + ) + return result.scalar_one_or_none() + + async def _get_sync_record(self, sync_id: int, tenant_id: int) -> Optional[ExperimentTruthNodeSync]: + result = await self.db.execute( + select(ExperimentTruthNodeSync).where( + and_(ExperimentTruthNodeSync.id == sync_id, ExperimentTruthNodeSync.tenant_id == tenant_id) + ) + ) + return result.scalar_one_or_none() + + def _calc_confidence(self, current_score: Optional[float]) -> float: + score = float(current_score or 0.0) + if score > 1: + score /= 100.0 + return round(max(0.0, min(1.0, score)), 2) + + def _build_sync_data( + self, + experiment: Experiment, + truth_node: TruthNode, + sync_type: str, + ) -> Dict[str, Any]: + return { + "sync_type": sync_type, + "experiment_summary": { + "id": experiment.id, + "title": experiment.title, + "status": experiment.status, + "target_metric": experiment.target_metric, + "current_score": experiment.current_score, + }, + "previous_truth_content": truth_node.content, + } + + async def _apply_sync_to_truth_node( + self, + record: ExperimentTruthNodeSync, + truth_node: TruthNode, + ) -> None: + content = truth_node.content if isinstance(truth_node.content, dict) else {} + if not isinstance(content, dict): + content = {"raw": str(truth_node.content)} + sync_section = content.get("experiment_sync", {}) + sync_section["last_sync"] = { + "sync_id": record.id, + "sync_type": record.sync_type, + "confidence_score": record.confidence_score, + "experiment_summary": (record.sync_data or {}).get("experiment_summary", {}), + "applied_at": now_cn_aware().isoformat(), + } + content["experiment_sync"] = sync_section + truth_node.content = content + truth_node.version = int(truth_node.version or 1) + 1 diff --git a/backend/app/services/experiment_sync/mixin_review.py b/backend/app/services/experiment_sync/mixin_review.py new file mode 100644 index 00000000..4f757e09 --- /dev/null +++ b/backend/app/services/experiment_sync/mixin_review.py @@ -0,0 +1,109 @@ +"""Review, rollback, and listing flows for experiment sync.""" + +from __future__ import annotations + +from .shared import Any, Dict, ExperimentTruthNodeSync, Optional, now_cn_naive, select + + +class ExperimentSyncReviewMixin: + """Review/rollback/list sync records.""" + + async def review_sync( + self, + sync_id: int, + tenant_id: int, + reviewer_id: int, + action: str, + review_notes: Optional[str] = None, + ) -> Dict[str, Any]: + record = await self._get_sync_record(sync_id, tenant_id) + if not record: + raise ValueError("同步记录不存在") + if record.apply_status == "applied" and action == "approve": + return {"sync_id": record.id, "apply_status": record.apply_status} + + record.reviewed_by = reviewer_id + record.reviewed_at = now_cn_naive() + record.review_notes = review_notes + + if action == "approve": + truth_node = await self._get_truth_node(record.truth_node_id, tenant_id) + if truth_node: + await self._apply_sync_to_truth_node(record, truth_node) + record.apply_status = "applied" + else: + record.apply_status = "rejected" + + await self.db.commit() + await self.db.refresh(record) + return { + "sync_id": record.id, + "apply_status": record.apply_status, + "reviewed_at": record.reviewed_at.isoformat() if record.reviewed_at else None, + } + + async def rollback_sync( + self, + sync_id: int, + tenant_id: int, + reviewer_id: int, + rollback_notes: Optional[str] = None, + ) -> Dict[str, Any]: + record = await self._get_sync_record(sync_id, tenant_id) + if not record: + raise ValueError("同步记录不存在") + if record.apply_status != "applied": + raise ValueError("仅已应用记录支持回滚") + + truth_node = await self._get_truth_node(record.truth_node_id, tenant_id) + if not truth_node: + raise ValueError("关联宪章不存在") + + snapshot = (record.sync_data or {}).get("previous_truth_content") + if snapshot is None: + raise ValueError("缺少回滚快照,无法回滚") + + truth_node.content = snapshot + truth_node.version = int(truth_node.version or 1) + 1 + + record.apply_status = "requires_review" + record.reviewed_by = reviewer_id + record.reviewed_at = now_cn_naive() + record.review_notes = rollback_notes or "manual rollback" + + await self.db.commit() + await self.db.refresh(record) + return { + "sync_id": record.id, + "apply_status": record.apply_status, + "rolled_back": True, + } + + async def list_sync_records( + self, + tenant_id: int, + truth_node_id: Optional[int] = None, + limit: int = 50, + ) -> Dict[str, Any]: + query = select(ExperimentTruthNodeSync).where(ExperimentTruthNodeSync.tenant_id == tenant_id) + if truth_node_id is not None: + query = query.where(ExperimentTruthNodeSync.truth_node_id == truth_node_id) + query = query.order_by(ExperimentTruthNodeSync.created_at.desc()).limit(limit) + + result = await self.db.execute(query) + items = result.scalars().all() + return { + "items": [ + { + "id": i.id, + "experiment_id": i.experiment_id, + "truth_node_id": i.truth_node_id, + "sync_type": i.sync_type, + "confidence_score": i.confidence_score, + "auto_applied": i.auto_applied, + "apply_status": i.apply_status, + "created_at": i.created_at.isoformat() if i.created_at else None, + } + for i in items + ] + } diff --git a/backend/app/services/experiment_sync/mixin_sync.py b/backend/app/services/experiment_sync/mixin_sync.py new file mode 100644 index 00000000..98cfe3eb --- /dev/null +++ b/backend/app/services/experiment_sync/mixin_sync.py @@ -0,0 +1,113 @@ +"""Auto-sync and project validation flows.""" + +from __future__ import annotations + +from .shared import Any, Dict, Experiment, ExperimentTruthNodeSync, Optional, Set, TruthNode, and_, select + + +class ExperimentSyncFlowMixin: + """Auto sync experiment results to charter truth nodes.""" + + async def auto_sync_experiment_result( + self, + experiment_id: int, + truth_node_id: int, + tenant_id: int, + project_id: Optional[int] = None, + sync_type: str = "result_sync", + auto_apply_threshold: float = 0.8, + ) -> Dict[str, Any]: + experiment = await self._get_experiment(experiment_id, tenant_id) + truth_node = await self._get_truth_node(truth_node_id, tenant_id) + if not experiment or not truth_node: + raise ValueError("实验或宪章不存在") + if project_id is not None: + await self.validate_experiment_truth_same_project( + experiment=experiment, + truth_node=truth_node, + tenant_id=tenant_id, + project_id=project_id, + ) + + confidence = self._calc_confidence(experiment.current_score) + sync_data = self._build_sync_data(experiment, truth_node, sync_type) + auto_apply = confidence >= auto_apply_threshold + + record = ExperimentTruthNodeSync( + tenant_id=tenant_id, + experiment_id=experiment.id, + experiment_external_id=str(experiment.id), + truth_node_id=truth_node.id, + sync_type=sync_type, + sync_data=sync_data, + confidence_score=confidence, + auto_applied=auto_apply, + apply_status="pending", + ) + + if auto_apply: + await self._apply_sync_to_truth_node(record, truth_node) + record.apply_status = "applied" + + self.db.add(record) + await self.db.commit() + await self.db.refresh(record) + + return { + "sync_id": record.id, + "confidence_score": record.confidence_score, + "auto_applied": record.auto_applied, + "apply_status": record.apply_status, + "sync_data": record.sync_data, + } + + async def validate_experiment_truth_same_project( + self, + experiment: Experiment, + truth_node: TruthNode, + tenant_id: int, + project_id: int, + ) -> None: + """交叉校验实验与宪章均属于指定项目。""" + if truth_node.project_id != project_id: + raise ValueError(f"宪章 {truth_node.id} 不属于项目 {project_id},拒绝跨项目同步") + + if experiment.project_id is not None: + if int(experiment.project_id) != int(project_id): + raise ValueError(f"实验 {experiment.id} 不属于项目 {project_id},拒绝跨项目同步") + return + + project_truth_nodes = await self._get_project_truth_node_ids(project_id, tenant_id) + if not project_truth_nodes: + raise ValueError(f"项目 {project_id} 下无可用宪章,无法校验实验归属") + + linked = experiment.linked_context_nodes or [] + linked_ids = self._normalize_ids(linked) + if not linked_ids: + raise ValueError("实验未关联任何上下文节点(linked_context_nodes 为空),无法通过项目归属校验") + + if linked_ids.isdisjoint(project_truth_nodes): + raise ValueError(f"实验 {experiment.id} 未关联项目 {project_id} 下宪章,拒绝跨项目同步") + + experiment.project_id = project_id + + async def _get_project_truth_node_ids(self, project_id: int, tenant_id: int) -> Set[int]: + result = await self.db.execute( + select(TruthNode.id).where( + and_( + TruthNode.tenant_id == tenant_id, + TruthNode.project_id == project_id, + ) + ) + ) + return {int(row[0]) for row in result.all()} + + def _normalize_ids(self, raw: Any) -> Set[int]: + ids: Set[int] = set() + if isinstance(raw, list): + for item in raw: + try: + ids.add(int(item)) + except (TypeError, ValueError): + continue + return ids diff --git a/backend/app/services/experiment_sync/service.py b/backend/app/services/experiment_sync/service.py new file mode 100644 index 00000000..22399fd8 --- /dev/null +++ b/backend/app/services/experiment_sync/service.py @@ -0,0 +1,19 @@ +"""Experiment sync service composition.""" + +from __future__ import annotations + +from .mixin_helpers import ExperimentSyncHelpersMixin +from .mixin_review import ExperimentSyncReviewMixin +from .mixin_sync import ExperimentSyncFlowMixin +from .shared import AsyncSession + + +class ExperimentSyncService( + ExperimentSyncHelpersMixin, + ExperimentSyncFlowMixin, + ExperimentSyncReviewMixin, +): + """实验结果 -> 宪章同步服务。""" + + def __init__(self, db: AsyncSession): + self.db = db diff --git a/backend/app/services/experiment_sync/shared.py b/backend/app/services/experiment_sync/shared.py new file mode 100644 index 00000000..9996600a --- /dev/null +++ b/backend/app/services/experiment_sync/shared.py @@ -0,0 +1,29 @@ +"""Shared imports for experiment sync service.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Dict, Optional, Set + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...models import Experiment, ExperimentTruthNodeSync, TruthNode +from ...utils.timezone import now_cn_aware, now_cn_naive + +__all__ = [ + "Any", + "AsyncSession", + "Dict", + "Experiment", + "ExperimentTruthNodeSync", + "Optional", + "Set", + "TruthNode", + "and_", + "datetime", + "now_cn_aware", + "now_cn_naive", + "select", + "timezone", +] diff --git a/backend/app/services/experiment_sync_service.py b/backend/app/services/experiment_sync_service.py index 4e1e3ef0..f002e63b 100644 --- a/backend/app/services/experiment_sync_service.py +++ b/backend/app/services/experiment_sync_service.py @@ -1,301 +1,5 @@ -"""实验结果同步服务(P1)""" +"""Compatibility exports for experiment sync service.""" -from datetime import datetime, timezone -from typing import Any, Dict, Optional, Set +from .experiment_sync import ExperimentSyncService -from sqlalchemy import and_, select -from sqlalchemy.ext.asyncio import AsyncSession - -from ..models import Experiment, ExperimentTruthNodeSync, TruthNode - - -from ..utils.timezone import now_cn_aware, now_cn_naive -class ExperimentSyncService: - """实验结果 -> 宪章同步,支持自动应用、人工审核与回滚。""" - - def __init__(self, db: AsyncSession): - self.db = db - - async def auto_sync_experiment_result( - self, - experiment_id: int, - truth_node_id: int, - tenant_id: int, - project_id: Optional[int] = None, - sync_type: str = "result_sync", - auto_apply_threshold: float = 0.8, - ) -> Dict[str, Any]: - experiment = await self._get_experiment(experiment_id, tenant_id) - truth_node = await self._get_truth_node(truth_node_id, tenant_id) - if not experiment or not truth_node: - raise ValueError("实验或宪章不存在") - if project_id is not None: - await self.validate_experiment_truth_same_project( - experiment=experiment, - truth_node=truth_node, - tenant_id=tenant_id, - project_id=project_id, - ) - - confidence = self._calc_confidence(experiment.current_score) - sync_data = self._build_sync_data(experiment, truth_node, sync_type) - auto_apply = confidence >= auto_apply_threshold - - record = ExperimentTruthNodeSync( - tenant_id=tenant_id, - experiment_id=experiment.id, - experiment_external_id=str(experiment.id), - truth_node_id=truth_node.id, - sync_type=sync_type, - sync_data=sync_data, - confidence_score=confidence, - auto_applied=auto_apply, - apply_status="pending", - ) - - if auto_apply: - await self._apply_sync_to_truth_node(record, truth_node) - record.apply_status = "applied" - - self.db.add(record) - await self.db.commit() - await self.db.refresh(record) - - return { - "sync_id": record.id, - "confidence_score": record.confidence_score, - "auto_applied": record.auto_applied, - "apply_status": record.apply_status, - "sync_data": record.sync_data, - } - - async def validate_experiment_truth_same_project( - self, - experiment: Experiment, - truth_node: TruthNode, - tenant_id: int, - project_id: int, - ) -> None: - """交叉校验实验与宪章均属于指定项目。 - - 注意:Experiment 当前无 project_id 字段,使用 linked_context_nodes 与项目宪章集合交叉验证。 - """ - if truth_node.project_id != project_id: - raise ValueError( - f"宪章 {truth_node.id} 不属于项目 {project_id},拒绝跨项目同步" - ) - - # 强约束优先:Experiment.project_id 命中则直接使用。 - if experiment.project_id is not None: - if int(experiment.project_id) != int(project_id): - raise ValueError( - f"实验 {experiment.id} 不属于项目 {project_id},拒绝跨项目同步" - ) - return - - project_truth_nodes = await self._get_project_truth_node_ids(project_id, tenant_id) - if not project_truth_nodes: - raise ValueError(f"项目 {project_id} 下无可用宪章,无法校验实验归属") - - linked = experiment.linked_context_nodes or [] - linked_ids = self._normalize_ids(linked) - if not linked_ids: - raise ValueError( - "实验未关联任何上下文节点(linked_context_nodes 为空),无法通过项目归属校验" - ) - - if linked_ids.isdisjoint(project_truth_nodes): - raise ValueError( - f"实验 {experiment.id} 未关联项目 {project_id} 下宪章,拒绝跨项目同步" - ) - - # 迁移期兼容:可推断归属时补写 project_id,减少后续重复推断。 - experiment.project_id = project_id - - async def _get_project_truth_node_ids(self, project_id: int, tenant_id: int) -> Set[int]: - result = await self.db.execute( - select(TruthNode.id).where( - and_( - TruthNode.tenant_id == tenant_id, - TruthNode.project_id == project_id, - ) - ) - ) - return {int(row[0]) for row in result.all()} - - def _normalize_ids(self, raw: Any) -> Set[int]: - ids: Set[int] = set() - if isinstance(raw, list): - for item in raw: - try: - ids.add(int(item)) - except (TypeError, ValueError): - continue - return ids - - async def review_sync( - self, - sync_id: int, - tenant_id: int, - reviewer_id: int, - action: str, - review_notes: Optional[str] = None, - ) -> Dict[str, Any]: - record = await self._get_sync_record(sync_id, tenant_id) - if not record: - raise ValueError("同步记录不存在") - if record.apply_status == "applied" and action == "approve": - return {"sync_id": record.id, "apply_status": record.apply_status} - - record.reviewed_by = reviewer_id - record.reviewed_at = now_cn_naive() - record.review_notes = review_notes - - if action == "approve": - truth_node = await self._get_truth_node(record.truth_node_id, tenant_id) - if truth_node: - await self._apply_sync_to_truth_node(record, truth_node) - record.apply_status = "applied" - else: - record.apply_status = "rejected" - - await self.db.commit() - await self.db.refresh(record) - return { - "sync_id": record.id, - "apply_status": record.apply_status, - "reviewed_at": record.reviewed_at.isoformat() if record.reviewed_at else None, - } - - async def rollback_sync( - self, - sync_id: int, - tenant_id: int, - reviewer_id: int, - rollback_notes: Optional[str] = None, - ) -> Dict[str, Any]: - record = await self._get_sync_record(sync_id, tenant_id) - if not record: - raise ValueError("同步记录不存在") - if record.apply_status != "applied": - raise ValueError("仅已应用记录支持回滚") - - truth_node = await self._get_truth_node(record.truth_node_id, tenant_id) - if not truth_node: - raise ValueError("关联宪章不存在") - - snapshot = (record.sync_data or {}).get("previous_truth_content") - if snapshot is None: - raise ValueError("缺少回滚快照,无法回滚") - - truth_node.content = snapshot - truth_node.version = int(truth_node.version or 1) + 1 - - record.apply_status = "requires_review" - record.reviewed_by = reviewer_id - record.reviewed_at = now_cn_naive() - record.review_notes = rollback_notes or "manual rollback" - - await self.db.commit() - await self.db.refresh(record) - return { - "sync_id": record.id, - "apply_status": record.apply_status, - "rolled_back": True, - } - - async def list_sync_records( - self, - tenant_id: int, - truth_node_id: Optional[int] = None, - limit: int = 50, - ) -> Dict[str, Any]: - query = select(ExperimentTruthNodeSync).where( - ExperimentTruthNodeSync.tenant_id == tenant_id - ) - if truth_node_id is not None: - query = query.where(ExperimentTruthNodeSync.truth_node_id == truth_node_id) - query = query.order_by(ExperimentTruthNodeSync.created_at.desc()).limit(limit) - - result = await self.db.execute(query) - items = result.scalars().all() - return { - "items": [ - { - "id": i.id, - "experiment_id": i.experiment_id, - "truth_node_id": i.truth_node_id, - "sync_type": i.sync_type, - "confidence_score": i.confidence_score, - "auto_applied": i.auto_applied, - "apply_status": i.apply_status, - "created_at": i.created_at.isoformat() if i.created_at else None, - } - for i in items - ] - } - - async def _get_experiment(self, experiment_id: int, tenant_id: int) -> Optional[Experiment]: - result = await self.db.execute( - select(Experiment).where( - and_(Experiment.id == experiment_id, Experiment.tenant_id == tenant_id) - ) - ) - return result.scalar_one_or_none() - - async def _get_truth_node(self, truth_node_id: int, tenant_id: int) -> Optional[TruthNode]: - result = await self.db.execute( - select(TruthNode).where( - and_(TruthNode.id == truth_node_id, TruthNode.tenant_id == tenant_id) - ) - ) - return result.scalar_one_or_none() - - async def _get_sync_record( - self, sync_id: int, tenant_id: int - ) -> Optional[ExperimentTruthNodeSync]: - result = await self.db.execute( - select(ExperimentTruthNodeSync).where( - and_(ExperimentTruthNodeSync.id == sync_id, ExperimentTruthNodeSync.tenant_id == tenant_id) - ) - ) - return result.scalar_one_or_none() - - def _calc_confidence(self, current_score: Optional[float]) -> float: - score = float(current_score or 0.0) - if score > 1: - score /= 100.0 - return round(max(0.0, min(1.0, score)), 2) - - def _build_sync_data( - self, experiment: Experiment, truth_node: TruthNode, sync_type: str - ) -> Dict[str, Any]: - return { - "sync_type": sync_type, - "experiment_summary": { - "id": experiment.id, - "title": experiment.title, - "status": experiment.status, - "target_metric": experiment.target_metric, - "current_score": experiment.current_score, - }, - "previous_truth_content": truth_node.content, - } - - async def _apply_sync_to_truth_node( - self, record: ExperimentTruthNodeSync, truth_node: TruthNode - ) -> None: - content = truth_node.content if isinstance(truth_node.content, dict) else {} - if not isinstance(content, dict): - content = {"raw": str(truth_node.content)} - sync_section = content.get("experiment_sync", {}) - sync_section["last_sync"] = { - "sync_id": record.id, - "sync_type": record.sync_type, - "confidence_score": record.confidence_score, - "experiment_summary": (record.sync_data or {}).get("experiment_summary", {}), - "applied_at": now_cn_aware().isoformat(), - } - content["experiment_sync"] = sync_section - truth_node.content = content - truth_node.version = int(truth_node.version or 1) + 1 +__all__ = ["ExperimentSyncService"] diff --git a/backend/app/services/feishu_integration.py b/backend/app/services/feishu_integration.py index 89a4f67f..7c0629d3 100644 --- a/backend/app/services/feishu_integration.py +++ b/backend/app/services/feishu_integration.py @@ -1,2119 +1,5 @@ -""" -Feishu Integration Service - 飞书集成服务 +from __future__ import annotations -功能: -1. 接收飞书群聊消息事件 -2. AI 判断消息是否为有价值的信号 -3. 自动创建信号并关联到项目 -4. 推送通知给相关角色 +from .feishu_integration_service import FeishuIntegrationService, dispatch_pending_feishu_replies, get_feishu_service -飞书开放平台配置: -- App ID: 从飞书开放平台获取 -- App Secret: 从飞书开放平台获取 -- 事件接收: 统一使用长连接模式 -""" -import asyncio -import json -import hashlib -import re -import time -from uuid import uuid4 -from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Optional -import httpx -from sqlalchemy import and_, or_, text, update -from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select - -from ..models import ( - User, - Project, - Signal, - ProjectSignalLink, - Integration, - FeishuReplyOutbox, - AIProcessingLog, -) -from ..database import AsyncSessionLocal -from ..config.ai_prompts import PROMPT_FEISHU_HUMANIZED_REPLY -from .notification_service import NotificationService -from .signal_capture import ProactiveSignalCaptureService -from .llm import LLMService - -from ..utils.timezone import now_cn_naive -CN_TZ = timezone(timedelta(hours=8)) -WEEKDAY_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] -OUTBOX_MAX_ATTEMPTS = 3 -OUTBOX_STALE_SENDING_SECONDS = 120 -_OUTBOX_PERF_T0_BY_ID: dict[int, float] = {} - - -class FeishuIntegrationService: - """飞书集成服务""" - - def __init__(self, db: AsyncSession): - self.db = db - - # 从数据库获取飞书配置 - self.app_id = None - self.app_secret = None - self.encrypt_key = None - self.verification_token = None - self.event_mode = "long_connection" - self.tenant_id = 1 - - @staticmethod - def _normalize_event_mode(_: Optional[str] = None) -> str: - return "long_connection" - - @staticmethod - def _is_postgresql_session(db: AsyncSession) -> bool: - try: - bind = db.get_bind() - except Exception: - return False - dialect_name = str(getattr(getattr(bind, "dialect", None), "name", "")).lower() - return dialect_name.startswith("postgres") - - @staticmethod - async def _notify_outbox_ready(db: AsyncSession, *, outbox_id: Optional[int]) -> None: - """仅在 PostgreSQL 下发送 outbox ready 事件。""" - if not FeishuIntegrationService._is_postgresql_session(db): - return - try: - payload = str(int(outbox_id))[:64] if outbox_id is not None else "" - await db.execute( - text("SELECT pg_notify('feishu_outbox_ready', :payload)"), - {"payload": payload}, - ) - except Exception as exc: - print(f"[Feishu] outbox notify failed: outbox_id={outbox_id}, error={exc}") - - async def load_config(self, tenant_id: int = 1): - """从数据库加载飞书配置""" - self.tenant_id = tenant_id - - result = await self.db.execute( - select(Integration).where( - Integration.tenant_id == tenant_id, - Integration.type == 'feishu', - Integration.is_active.is_(True), - ) - ) - integration = result.scalar_one_or_none() - - if integration: - if isinstance(integration.config, str): - config = json.loads(integration.config) if integration.config else {} - else: - config = integration.config or {} - self.app_id = config.get('app_id') - self.app_secret = config.get('app_secret') - self.encrypt_key = None - self.verification_token = None - self.event_mode = self._normalize_event_mode(config.get('event_mode')) - else: - self.app_id = None - self.app_secret = None - self.encrypt_key = None - self.verification_token = None - self.event_mode = 'long_connection' - - def is_configured(self) -> bool: - """检查是否已配置""" - return bool(self.app_id and self.app_secret) - - async def save_config( - self, - app_id: str, - app_secret: str, - encrypt_key: str, - verification_token: str, - event_mode: str = "long_connection", - tenant_id: int = 1 - ): - """保存飞书配置到数据库""" - mode = self._normalize_event_mode(event_mode) - config = { - 'app_id': app_id, - 'app_secret': app_secret, - 'encrypt_key': "", - 'verification_token': "", - 'event_mode': mode, - } - - # 查找或创建集成记录 - result = await self.db.execute( - select(Integration).where( - Integration.tenant_id == tenant_id, - Integration.type == 'feishu' - ) - ) - integration = result.scalar_one_or_none() - - if integration: - integration.config = config - integration.is_active = True - else: - integration = Integration( - type='feishu', - config=config, - is_active=True, - tenant_id=tenant_id - ) - self.db.add(integration) - - await self.db.commit() - - # 更新内存配置 - self.app_id = app_id - self.app_secret = app_secret - self.encrypt_key = None - self.verification_token = None - self.event_mode = mode - self.tenant_id = tenant_id - - async def handle_message_event( - self, - event_data: Dict[str, Any] - ) -> Dict[str, Any]: - """ - 处理飞书消息事件 - - 支持的事件类型: - - message.receive_v1: 收到群聊消息 - - im.message.receive_v1: 收到群聊消息(schema 2.0) - """ - try: - event_type = (event_data.get('type') or '').strip() - - # 飞书 schema 2.0 会返回 im.message.receive_v1 - if event_type in {'message.receive_v1', 'im.message.receive_v1'}: - return await self._handle_message_receive(event_data) - - return { - 'code': 0, - 'msg': f'Event type {event_type} not handled' - } - - except Exception as e: - print(f"[Feishu] Handle event error: {e}") - return { - 'code': 1, - 'msg': str(e) - } - - async def _handle_message_receive( - self, - event: Dict[str, Any] - ) -> Dict[str, Any]: - """处理收到的消息事件""" - state_trace_id = f"feishu-sm-{uuid4().hex[:12]}" - ingress_source = str(event.get("_ingress_source") or "feishu").strip() or "feishu" - message = event.get('message', {}) if isinstance(event.get('message', {}), dict) else {} - chat = event.get('chat', {}) if isinstance(event.get('chat', {}), dict) else {} - sender = event.get('sender', {}) if isinstance(event.get('sender', {}), dict) else {} - message_id = (message.get("message_id") or "").strip() or "unknown" - perf_t0_raw = event.get("_perf_t0") - perf_t0: Optional[float] = None - if isinstance(perf_t0_raw, (int, float)): - perf_t0 = float(perf_t0_raw) - else: - try: - perf_t0 = float(str(perf_t0_raw).strip()) - except Exception: - perf_t0 = None - self._emit_state_transition( - trace_id=state_trace_id, - state="RECEIVED", - message_id=message_id, - message_type=str(message.get("message_type") or "unknown"), - ) - sender_type = str(sender.get("sender_type") or "").strip().lower() - if sender_type in {"app", "bot"}: - return {'code': 0, 'msg': 'Ignore bot/app message'} - - mentioned_bot_names = self._extract_mentioned_bot_names(message) - - # 解析消息内容与附件元数据 - message_payload = self._extract_message_payload(event) - message_content = str(message_payload.get("text") or "").strip() or None - message_attachments = ( - message_payload.get("attachments") - if isinstance(message_payload.get("attachments"), list) - else [] - ) - await self._record_ingress_trace( - ingress_source=ingress_source, - trace_id=state_trace_id, - message_id=message_id, - event=event, - message_content=message_content or "", - ) - if not message_content: - print( - f"[Feishu] message.receive_v1 ignored: no text content, " - f"message_type={message.get('message_type')}, keys={list(message.keys())}" - ) - chat_id = chat.get('chat_id') or message.get('chat_id') or '' - if mentioned_bot_names and chat_id: - await self._safe_reply_text( - chat_id=chat_id, - mention_open_id=(sender.get('sender_id', {}) or {}).get('open_id'), - mention_name=(sender.get('sender_name') or sender.get('name') or '你'), - text="我已收到你的消息,但目前无法直接解析纯图片内容。请补充 1-2 句文字需求,我会立即给出分派建议。", - trace_id=state_trace_id, - source_message_id=message_id, - perf_t0=perf_t0, - ) - self._emit_state_transition( - trace_id=state_trace_id, - state="REPLIED", - route="empty_text_prompt", - ) - return {'code': 0, 'msg': 'No text content'} - - # 获取发送者信息 - sender_id = sender.get('sender_id', {}) if isinstance(sender.get('sender_id', {}), dict) else {} - sender_open_id = (sender_id.get('open_id') or "").strip() or None - sender_name = await self._resolve_sender_display_name( - sender=sender, - sender_id=sender_id, - ) - - # 获取群聊信息 - chat_id = chat.get('chat_id') or message.get('chat_id') or '' - chat_type = str(chat.get("chat_type") or message.get("chat_type") or "").strip().lower() or "group" - captured_at = self._extract_message_datetime(message) - occurred_at_iso = ( - captured_at.isoformat() if isinstance(captured_at, datetime) else None - ) - message_attachments = self._normalize_ingress_attachment_records( - message_attachments, - occurred_at=occurred_at_iso, - source_message_id=(message.get("message_id") or "").strip() or None, - source_chat_id=chat_id or None, - ) - - # 使用信号捕获服务处理 - capture_service = ProactiveSignalCaptureService(self.db) - - try: - result = await capture_service.capture_from_feishu( - group_chat_id=chat_id, - message_content=message_content, - sender=sender_name, - tenant_id=self.tenant_id, - proposer_open_id=sender_open_id, - captured_at=captured_at, - mentioned_bot_names=mentioned_bot_names, - feishu_message_id=(message.get("message_id") or "").strip() or None, - chat_type=chat_type, - ) - await self._persist_signal_attachments_from_ingress( - signal_id=result.get("signal_id"), - attachments=message_attachments, - ) - except Exception as exc: - self._emit_state_transition( - trace_id=state_trace_id, - state="ERROR_REPLY", - reason="capture_exception", - error=str(exc), - ) - if chat_id: - await self._safe_reply_text( - chat_id=chat_id, - mention_open_id=sender_open_id, - mention_name=sender_name, - text=self._build_error_reply_text(), - trace_id=state_trace_id, - source_message_id=message_id, - perf_t0=perf_t0, - ) - self._emit_state_transition( - trace_id=state_trace_id, - state="REPLIED", - route="error_template", - ) - return { - "code": 0, - "msg": "error_handled", - "data": { - "status": "error", - "classification": "error", - "ai_trace_id": state_trace_id, - }, - } - - print( - "[Feishu] Message processed: " - f"status={result.get('status')}, " - f"classification={result.get('classification')}, " - f"linked_projects={result.get('linked_project_ids', [])}, " - f"auto_tasks={len(result.get('auto_tasks', []) if isinstance(result.get('auto_tasks', []), list) else [])}, " - f"ai_trace_id={result.get('ai_trace_id')}" - ) - scene = self._classify_message_scene(capture_result=result) - self._emit_state_transition( - trace_id=state_trace_id, - state="CLASSIFIED", - scene=scene, - status=str(result.get("status") or "unknown"), - classification=str(result.get("classification") or "unknown"), - ) - if str(result.get("status") or "").lower() != "error": - self._emit_state_transition( - trace_id=state_trace_id, - state="COMMITTED", - ai_trace_id=str(result.get("ai_trace_id") or ""), - ) - - reply_route = "none" - capability_reply = self._build_capability_reply(message_content) - if scene == "execute": - if self._is_duplicate_capture_result(result): - self._emit_state_transition( - trace_id=state_trace_id, - state="ROUTED", - route="duplicate_suppressed", - ) - else: - self._emit_state_transition( - trace_id=state_trace_id, - state="ROUTED", - route="tool_first_execute", - ) - deterministic_reply = capability_reply or str(result.get("direct_reply_text") or "").strip() - if not deterministic_reply: - deterministic_reply = self._build_execution_receipt_reply( - message_content=message_content, - capture_result=result, - ) - deterministic_reply, guard_reason = self._guard_execute_reply_text_with_reason( - deterministic_reply, - trace_id=state_trace_id, - ) - if guard_reason: - asyncio.create_task( - self._record_execute_guard_alert( - trace_id=state_trace_id, - message_id=message_id, - message_content=message_content, - capture_result=result, - guard_reason=guard_reason, - ) - ) - if not deterministic_reply and str(result.get("status") or "").lower() == "error": - deterministic_reply = self._build_error_reply_text() - if chat_id and deterministic_reply: - await self._safe_reply_text( - chat_id=chat_id, - mention_open_id=sender_open_id, - mention_name=sender_name, - text=deterministic_reply, - trace_id=state_trace_id, - source_message_id=message_id, - perf_t0=perf_t0, - ) - reply_route = "execute_template" - elif scene == "qa": - self._emit_state_transition( - trace_id=state_trace_id, - state="ROUTED", - route="qa_consult", - ) - if chat_id and capability_reply: - await self._safe_reply_text( - chat_id=chat_id, - mention_open_id=sender_open_id, - mention_name=sender_name, - text=capability_reply, - trace_id=state_trace_id, - source_message_id=message_id, - perf_t0=perf_t0, - ) - reply_route = "qa_capability" - elif chat_id and bool(result.get("direct_reply_required")): - await self._safe_reply_text( - chat_id=chat_id, - mention_open_id=sender_open_id, - mention_name=sender_name, - text=str(result.get("direct_reply_text") or "已收到,正在处理。"), - trace_id=state_trace_id, - source_message_id=message_id, - perf_t0=perf_t0, - ) - reply_route = "qa_direct" - elif ( - chat_id - and self._should_reply_to_mention( - mentioned_bot_names=mentioned_bot_names, - message_content=message_content, - ) - ): - await self._reply_with_active_role_prompt( - chat_id=chat_id, - sender_open_id=sender_open_id, - sender_name=sender_name, - message_content=message_content, - capture_result=result, - trace_id=state_trace_id, - source_message_id=message_id, - perf_t0=perf_t0, - ) - reply_route = "qa_llm" - else: - self._emit_state_transition( - trace_id=state_trace_id, - state="ERROR_REPLY", - reason="scene_error", - capture_status=str(result.get("status") or "unknown"), - ) - if chat_id: - await self._safe_reply_text( - chat_id=chat_id, - mention_open_id=sender_open_id, - mention_name=sender_name, - text=self._build_error_reply_text(), - trace_id=state_trace_id, - source_message_id=message_id, - perf_t0=perf_t0, - ) - reply_route = "error_template" - - if reply_route != "none": - self._emit_state_transition( - trace_id=state_trace_id, - state="REPLIED", - route=reply_route, - ) - await self._record_message_trace( - trace_id=state_trace_id, - message_id=message_id, - chat_type=chat_type, - message_content=message_content, - capture_result=result, - reply_source=reply_route, - ) - - return { - 'code': 0, - 'msg': 'success', - 'data': { - 'status': result.get('status'), - 'classification': result.get('classification'), - 'linked_project_ids': result.get('linked_project_ids', []), - 'auto_tasks_count': len(result.get('auto_tasks', []) if isinstance(result.get('auto_tasks', []), list) else []), - 'ai_trace_id': result.get('ai_trace_id'), - 'state_trace_id': state_trace_id, - 'scene': scene, - }, - } - - async def _record_ingress_trace( - self, - *, - ingress_source: str, - trace_id: str, - message_id: str, - event: Dict[str, Any], - message_content: str, - ) -> None: - try: - message = event.get("message", {}) if isinstance(event.get("message", {}), dict) else {} - chat = event.get("chat", {}) if isinstance(event.get("chat", {}), dict) else {} - sender = event.get("sender", {}) if isinstance(event.get("sender", {}), dict) else {} - sender_id = sender.get("sender_id", {}) if isinstance(sender.get("sender_id", {}), dict) else {} - sender_name = ( - str(sender.get("sender_name") or sender.get("name") or "").strip() - or str(sender_id.get("open_id") or "").strip() - or "unknown" - ) - chat_id = str(chat.get("chat_id") or message.get("chat_id") or "").strip() or None - chat_type = str(chat.get("chat_type") or message.get("chat_type") or "").strip() or None - - async with AsyncSessionLocal() as db: - db.add( - AIProcessingLog( - tenant_id=int(self.tenant_id or 1), - feishu_message_id=(message_id or "")[:128] or None, - feishu_item_id=None, - raw_message=(message_content or "")[:8000] or None, - ai_raw_output=json.dumps( - { - "trace_id": trace_id, - "ingress_source": ingress_source, - "event_type": str(event.get("type") or "")[:120], - "chat_id": chat_id, - "chat_type": chat_type, - "sender_name": sender_name[:200], - }, - ensure_ascii=False, - )[:120000], - project_score=None, - signal_confidence=None, - level="ingress", - normalized_text=(message_content or "")[:8000] or None, - route=ingress_source[:80], - intent=None, - executor="feishu_ingress", - operation_code=None, - operation_result={ - "message_type": str(message.get("message_type") or "")[:80], - "chat_id": chat_id, - "chat_type": chat_type, - "sender_name": sender_name[:200], - }, - reply_source=None, - routing_decision="event_received", - routing_reason=f"Feishu ingress via {ingress_source}", - reasoning_bullets=[ - f"trace_id={trace_id}", - f"message_id={message_id}", - f"source={ingress_source}", - ], - processing_status="success", - error_message=None, - duration_ms=0, - ) - ) - await db.commit() - except Exception as exc: - print(f"[Feishu] ingress trace write failed: trace_id={trace_id}, error={exc}") - - @staticmethod - def _classify_message_scene(*, capture_result: Dict[str, Any]) -> str: - if not isinstance(capture_result, dict): - return "error" - status = str(capture_result.get("status") or "").strip().lower() - classification = str(capture_result.get("classification") or "").strip().lower() - if status in {"captured", "ignored"}: - return "execute" - if status == "answered" or classification in {"question", "qa"}: - return "qa" - if status == "error" or classification == "error": - return "error" - return "qa" - - @staticmethod - def _build_error_reply_text() -> str: - return "系统处理中出现异常,本次未执行。请稍后重试,或补充更明确的目标、对象和动作。" - - @staticmethod - def _emit_state_transition(trace_id: str, state: str, **payload: Any) -> None: - safe_trace = str(trace_id or "unknown") - safe_state = str(state or "unknown") - details = ", ".join([f"{k}={v}" for k, v in payload.items()]) if payload else "" - print(f"[Feishu-SM][{safe_trace}] state={safe_state}{', ' + details if details else ''}") - - async def _reply_with_active_role_prompt( - self, - *, - chat_id: str, - sender_open_id: Optional[str], - sender_name: str, - message_content: str, - capture_result: Dict[str, Any], - trace_id: Optional[str] = None, - source_message_id: Optional[str] = None, - perf_t0: Optional[float] = None, - ) -> None: - """被 @ 时回复一条有人味的执行反馈。""" - deterministic_time_reply = self._build_time_truth_reply(message_content) - if deterministic_time_reply: - await self._safe_reply_text( - chat_id=chat_id, - mention_open_id=sender_open_id, - mention_name=sender_name, - text=deterministic_time_reply, - trace_id=trace_id, - source_message_id=source_message_id, - perf_t0=perf_t0, - ) - return - - execution_receipt = self._build_execution_receipt_reply( - message_content=message_content, - capture_result=capture_result, - ) - execution_receipt = self._guard_execute_reply_text( - execution_receipt, - trace_id=str(trace_id or f"feishu-sm-{uuid4().hex[:12]}"), - ) - if execution_receipt: - await self._safe_reply_text( - chat_id=chat_id, - mention_open_id=sender_open_id, - mention_name=sender_name, - text=execution_receipt, - trace_id=trace_id, - source_message_id=source_message_id, - perf_t0=perf_t0, - ) - return - - butler_reply = self._build_butler_decision_reply(capture_result=capture_result) - if butler_reply: - await self._safe_reply_text( - chat_id=chat_id, - mention_open_id=sender_open_id, - mention_name=sender_name, - text=butler_reply, - trace_id=trace_id, - source_message_id=source_message_id, - perf_t0=perf_t0, - ) - return - - try: - status = str(capture_result.get("status") or "unknown") - classification = str(capture_result.get("classification") or "unknown") - reason = str(capture_result.get("reason") or "").strip() - ai_trace_id = str(capture_result.get("ai_trace_id") or "").strip() - linked_project_ids = capture_result.get("linked_project_ids", []) - linked_projects_text = ", ".join([str(x) for x in linked_project_ids[:3]]) if isinstance(linked_project_ids, list) else "" - now_cn = datetime.now(CN_TZ) - now_cn_text = ( - f"{now_cn.year}年{now_cn.month}月{now_cn.day}日 " - f"{WEEKDAY_CN[now_cn.weekday()]} {now_cn.strftime('%H:%M:%S')}" - ) - - prompt = PROMPT_FEISHU_HUMANIZED_REPLY.format( - now_cn_text=now_cn_text, - sender_name=sender_name, - user_message=(message_content or "")[:600], - status=status, - classification=classification, - reason=(reason or "无"), - ai_trace_id=(ai_trace_id or "无"), - linked_projects_text=(linked_projects_text or "无"), - ) - - llm = LLMService() - raw_reply = await llm.generate( - prompt, - db=self.db, - tenant_id=self.tenant_id, - function_name="feishu_humanized_reply", - temperature=0.4, - max_tokens=180, - ) - reply_text = self._clean_reply_text(raw_reply) - if not self._is_capability_question(message_content): - reply_text = self._strip_identity_lead(reply_text) - if (not reply_text) or self._looks_like_provider_error(reply_text): - reply_text = self._fallback_reply_text(capture_result) - except Exception as exc: - print(f"[Feishu] generate mention reply failed: {exc}") - reply_text = self._fallback_reply_text(capture_result) - reply_text, _ = self._guard_qa_reply_text_with_reason( - reply_text, - trace_id=str(trace_id or f"feishu-sm-{uuid4().hex[:12]}"), - capture_result=capture_result, - ) - - await self._safe_reply_text( - chat_id=chat_id, - mention_open_id=sender_open_id, - mention_name=sender_name, - text=reply_text, - trace_id=trace_id, - source_message_id=source_message_id, - perf_t0=perf_t0, - ) - - async def _safe_reply_text( - self, - *, - chat_id: str, - text: str, - mention_open_id: Optional[str] = None, - mention_name: Optional[str] = None, - trace_id: Optional[str] = None, - source_message_id: Optional[str] = None, - perf_t0: Optional[float] = None, - ) -> None: - try: - plain_text = self._normalize_outgoing_reply_text(text) - mention_prefix = self._build_mention_prefix( - open_id=mention_open_id, - display_name=mention_name, - ) - final_text = f"{mention_prefix}{plain_text}".strip() - outbox_id = await self._enqueue_reply_outbox( - chat_id=chat_id, - message_text=final_text, - trace_id=trace_id, - source_message_id=source_message_id, - ) - perf_t1 = time.perf_counter() - if outbox_id > 0 and perf_t0 is not None: - _OUTBOX_PERF_T0_BY_ID[int(outbox_id)] = float(perf_t0) - if perf_t0 is not None: - print( - f"[PERF] outbox_written ts={now_cn_naive().isoformat()} T1={perf_t1:.6f} " - f"delta={perf_t1 - perf_t0:.2f}s " - f"outbox_id={outbox_id}" - ) - else: - print( - f"[PERF] outbox_written ts={now_cn_naive().isoformat()} T1={perf_t1:.6f} " - f"delta=N/A " - f"outbox_id={outbox_id}" - ) - except Exception as exc: - print(f"[Feishu] enqueue reply outbox failed: chat_id={chat_id}, error={exc}") - # 统一由 drain_loop 消费 outbox,禁止业务路径直发,避免双发送。 - return - - async def _enqueue_reply_outbox( - self, - *, - chat_id: str, - message_text: str, - trace_id: Optional[str], - source_message_id: Optional[str], - ) -> int: - async with AsyncSessionLocal() as db: - normalized_source_message_id = (source_message_id or "")[:128] or None - normalized_message_text = (message_text or "")[:2000] or "已收到,正在处理。" - reply_hash = self._build_reply_hash( - source_message_id=normalized_source_message_id, - message_text=normalized_message_text, - ) - if reply_hash: - sent_exists = ( - await db.execute( - select(FeishuReplyOutbox.id) - .where( - FeishuReplyOutbox.tenant_id == int(self.tenant_id or 1), - FeishuReplyOutbox.reply_hash == reply_hash, - FeishuReplyOutbox.status == "sent", - ) - .limit(1) - ) - ).scalar_one_or_none() - if sent_exists is not None: - return int(sent_exists) - row = FeishuReplyOutbox( - tenant_id=int(self.tenant_id or 1), - trace_id=(trace_id or "")[:64] or None, - source_message_id=normalized_source_message_id, - chat_id=(chat_id or "")[:128], - message_text=normalized_message_text, - reply_hash=reply_hash, - status="pending", - attempts=0, - next_retry_at=None, - ) - db.add(row) - try: - await db.flush() - await self._notify_outbox_ready(db, outbox_id=int(row.id) if row.id is not None else None) - await db.commit() - except IntegrityError: - await db.rollback() - if reply_hash: - sent_exists = ( - await db.execute( - select(FeishuReplyOutbox.id) - .where( - FeishuReplyOutbox.tenant_id == int(self.tenant_id or 1), - FeishuReplyOutbox.reply_hash == reply_hash, - FeishuReplyOutbox.status == "sent", - ) - .limit(1) - ) - ).scalar_one_or_none() - if sent_exists is not None: - return int(sent_exists) - raise - await db.refresh(row) - return int(row.id) - - @classmethod - async def dispatch_pending_outbox_entries(cls, *, limit: int = 30) -> int: - fetch_limit = max(1, min(200, int(limit))) - dispatched = 0 - for _ in range(fetch_limit): - claimed = await cls._claim_next_outbox_entry(now=now_cn_naive()) - if claimed is None: - break - await cls._dispatch_claimed_outbox_entry(claimed=claimed) - dispatched += 1 - return dispatched - - @classmethod - async def _claim_next_outbox_entry(cls, *, now: datetime) -> Optional[dict[str, Any]]: - stale_before = now - timedelta(seconds=OUTBOX_STALE_SENDING_SECONDS) - async with AsyncSessionLocal() as db: - await db.execute( - update(FeishuReplyOutbox) - .where( - FeishuReplyOutbox.status == "sending", - FeishuReplyOutbox.updated_at <= stale_before, - ) - .values(status="retrying", next_retry_at=now) - ) - candidate_id = ( - await db.execute( - select(FeishuReplyOutbox.id) - .where( - FeishuReplyOutbox.status.in_(("pending", "retrying")), - or_( - FeishuReplyOutbox.next_retry_at.is_(None), - FeishuReplyOutbox.next_retry_at <= now, - ), - ) - .order_by(FeishuReplyOutbox.id.asc()) - .with_for_update(skip_locked=True) - .limit(1) - ) - ).scalar_one_or_none() - if candidate_id is None: - await db.commit() - return None - - claimed = ( - await db.execute( - update(FeishuReplyOutbox) - .where( - FeishuReplyOutbox.id == int(candidate_id), - FeishuReplyOutbox.status.in_(("pending", "retrying")), - or_( - FeishuReplyOutbox.next_retry_at.is_(None), - FeishuReplyOutbox.next_retry_at <= now, - ), - ) - .values( - status="sending", - attempts=FeishuReplyOutbox.attempts + 1, - last_error=None, - next_retry_at=None, - ) - .returning( - FeishuReplyOutbox.id, - FeishuReplyOutbox.tenant_id, - FeishuReplyOutbox.chat_id, - FeishuReplyOutbox.message_text, - FeishuReplyOutbox.reply_hash, - FeishuReplyOutbox.attempts, - ) - ) - ).mappings().first() - await db.commit() - if claimed is None: - return None - return dict(claimed) - - @classmethod - async def _dispatch_claimed_outbox_entry(cls, *, claimed: dict[str, Any]) -> None: - outbox_id = int(claimed.get("id") or 0) - if outbox_id <= 0: - return - tenant_id = int(claimed.get("tenant_id") or 1) - reply_hash = str(claimed.get("reply_hash") or "").strip() - if reply_hash: - async with AsyncSessionLocal() as db_check: - sent_duplicate = ( - await db_check.execute( - select(FeishuReplyOutbox.id) - .where( - FeishuReplyOutbox.id != outbox_id, - FeishuReplyOutbox.tenant_id == tenant_id, - FeishuReplyOutbox.reply_hash == reply_hash, - FeishuReplyOutbox.status == "sent", - ) - .limit(1) - ) - ).scalar_one_or_none() - if sent_duplicate is not None: - async with AsyncSessionLocal() as db_skip: - row = ( - await db_skip.execute( - select(FeishuReplyOutbox).where(FeishuReplyOutbox.id == outbox_id) - ) - ).scalar_one_or_none() - if row is not None and str(row.status or "").lower() == "sending": - row.status = "sent" - row.sent_at = now_cn_naive() - row.next_retry_at = None - row.last_error = f"dedupe_skip_sent:{int(sent_duplicate)}" - await db_skip.commit() - _OUTBOX_PERF_T0_BY_ID.pop(outbox_id, None) - return - - send_error: Optional[str] = None - try: - async with AsyncSessionLocal() as db_send: - service = FeishuIntegrationService(db_send) - await service.load_config(tenant_id=int(claimed.get("tenant_id") or 1)) - await service.send_message_to_group( - chat_id=str(claimed.get("chat_id") or ""), - message=str(claimed.get("message_text") or ""), - ) - except Exception as exc: - send_error = str(exc) - - async with AsyncSessionLocal() as db_update: - row = ( - await db_update.execute( - select(FeishuReplyOutbox).where(FeishuReplyOutbox.id == outbox_id) - ) - ).scalar_one_or_none() - if row is None: - return - if str(row.status or "").lower() != "sending": - return - attempts = int(row.attempts or claimed.get("attempts") or 0) - if send_error is None: - row.status = "sent" - row.sent_at = now_cn_naive() - row.next_retry_at = None - row.last_error = None - await db_update.commit() - perf_t2 = time.perf_counter() - perf_t0 = _OUTBOX_PERF_T0_BY_ID.pop(outbox_id, None) - if perf_t0 is not None: - print( - f"[PERF] reply_dispatched ts={now_cn_naive().isoformat()} T2={perf_t2:.6f} " - f"delta={perf_t2 - perf_t0:.2f}s " - f"outbox_id={outbox_id}" - ) - else: - print( - f"[PERF] reply_dispatched ts={now_cn_naive().isoformat()} T2={perf_t2:.6f} " - f"delta=N/A " - f"outbox_id={outbox_id}" - ) - return - if attempts >= OUTBOX_MAX_ATTEMPTS: - row.status = "failed" - row.next_retry_at = None - _OUTBOX_PERF_T0_BY_ID.pop(outbox_id, None) - else: - row.status = "retrying" - delay_seconds = min(30, 2 * (2 ** max(0, attempts - 1))) - row.next_retry_at = now_cn_naive() + timedelta(seconds=delay_seconds) - row.last_error = (send_error or "unknown_send_error")[:1000] - await db_update.commit() - - @staticmethod - def _clean_reply_text(value: Optional[str]) -> str: - text = str(value or "").strip() - if text.startswith("```"): - text = text.replace("```", "").strip() - if text.lower().startswith("json"): - text = text[4:].strip() - return text[:500] - - @classmethod - def _normalize_outgoing_reply_text(cls, value: Optional[str]) -> str: - text = cls._clean_reply_text(value) - if (not text) or cls._looks_like_provider_error(text): - return cls._build_error_reply_text() - return text[:500] - - @staticmethod - def _build_reply_hash(*, source_message_id: Optional[str], message_text: str) -> Optional[str]: - source = str(source_message_id or "").strip() - body = str(message_text or "").strip() - if not source or not body: - return None - raw = f"{source}{body}".encode("utf-8") - return hashlib.md5(raw, usedforsecurity=False).hexdigest() - - @staticmethod - def _is_capability_question(message_content: Optional[str]) -> bool: - normalized = re.sub(r"\s+", "", str(message_content or "").lower()) - if not normalized: - return False - capability_tokens = ( - "你是谁", - "你是什么", - "你能做什么", - "你可以做什么", - "系统能力", - "能力介绍", - "介绍下你", - ) - return any(token in normalized for token in capability_tokens) - - @classmethod - def _build_capability_reply(cls, message_content: Optional[str]) -> Optional[str]: - if not cls._is_capability_question(message_content): - return None - return ( - "我是 PMFlow 的项目协同助手。" - "我可以识别需求并入库信号、关联项目和任务、跟进状态并回答项目问题。" - "你直接说目标、对象和期望动作即可。" - ) - - @staticmethod - def _strip_identity_lead(reply_text: str) -> str: - text = str(reply_text or "").strip() - if not text: - return text - lowered = text.lower() - forbidden_prefixes = ( - "我是", - "我可以", - "我现在", - "是的,我现在", - "可以在这里和我", - ) - forbidden_contains = ( - "通过 feishu", - "群聊接入 openclaw", - "你可以直接在这里和我对话", - ) - if any(lowered.startswith(prefix) for prefix in forbidden_prefixes) or any(token in lowered for token in forbidden_contains): - return "" - return text - - @staticmethod - def _looks_like_provider_error(text: str) -> bool: - lowered = str(text or "").strip().lower() - if not lowered: - return False - error_signals = ( - "[error]", - "request was aborted", - "request aborted", - "connection aborted", - "operation aborted", - "context canceled", - "context cancelled", - "cancelled", - "canceled", - "rate limit", - "too many requests", - "429", - "quota", - "insufficient_quota", - "insufficient quota", - "api key", - "authentication failed", - "service unavailable", - "upstream error", - "upstream request", - "bad gateway", - "gateway timeout", - "internal server error", - "connection reset", - "econnreset", - "socket hang up", - ) - if any(flag in lowered for flag in error_signals): - return True - - if len(lowered) <= 160: - compact = lowered.replace("\n", " ").strip() - ascii_chars = sum(1 for ch in compact if ord(ch) < 128) - ascii_ratio = ascii_chars / max(1, len(compact)) - generic_error_tokens = ("error", "failed", "abort", "timeout", "exception") - if ascii_ratio >= 0.9 and any(token in compact for token in generic_error_tokens): - return True - - return False - - @staticmethod - def _fallback_reply_text(capture_result: Dict[str, Any]) -> str: - status = str(capture_result.get("status") or "") - classification = str(capture_result.get("classification") or "") - if status == "captured": - if classification in {"project", "new_project"}: - return "我已收到需求并完成初步识别,已按项目级信息入队处理。你可以继续补充目标、范围和时间,我会进一步细化分派。" - return "我已收到需求并完成初步识别,已进入信号处理流程。若你希望我直接分派任务,请补充目标、优先级和期望时间。" - return "我已收到消息并开始处理。若你希望我直接分派任务,请补充目标、优先级和时间要求。" - - @staticmethod - def _build_butler_decision_reply(capture_result: Dict[str, Any]) -> Optional[str]: - if not isinstance(capture_result, dict): - return None - status = str(capture_result.get("status") or "").strip().lower() - if status != "captured": - return None - - results = capture_result.get("results") - if not isinstance(results, list) or not results: - return None - - auto_results = [row for row in results if str((row or {}).get("route_status") or "") == "auto_processed"] - pending_results = [row for row in results if str((row or {}).get("route_status") or "") == "pending"] - classification = str(capture_result.get("classification") or "signal").strip().lower() - signal_id = str(capture_result.get("signal_id") or "").strip() - linked_project_ids = capture_result.get("linked_project_ids") or [] - project_hint = "" - if isinstance(linked_project_ids, list) and linked_project_ids: - project_hint = f",并关联项目 #{linked_project_ids[0]}" - - if pending_results: - if classification in {"project", "new_project"}: - return ( - "我已先完成项目候选入队,当前有不确定项需要你确认:" - "1) 归属项目/是否新建 2) 优先级 3) 截止时间。" - "确认后我会自动继续推进。" - ) - return ( - "我已先完成信号入队,当前判断存在不确定项(多半是项目归属或范围边界)。" - "请你补充:归属项目、优先级、期望完成时间,我会立即自动续跑。" - ) - - if auto_results: - if classification in {"project", "new_project"}: - return ( - f"已自动完成项目流处理{project_hint}。" - "后续我会继续按管家流程拆解与分派,除非你要求人工接管。" - ) - if signal_id: - return ( - f"已自动完成信号流处理(信号#{signal_id}{project_hint})。" - "这是明确需求场景,我会继续自动推进后续动作。" - ) - return "已自动完成信号流处理。后续我会继续自动推进,只有不确定项才会向你确认。" - - return None - - @staticmethod - def _build_execution_receipt_reply( - *, - message_content: Optional[str], - capture_result: Dict[str, Any], - ) -> Optional[str]: - """ - 对“已执行”场景优先给简短回执,避免 LLM 输出教程式长文或二次处理建议。 - """ - if not isinstance(capture_result, dict): - return None - status = str(capture_result.get("status") or "").strip().lower() - if status not in {"captured", "ignored"}: - return None - - if status == "ignored": - reason = str(capture_result.get("reason") or "").strip().lower() - if reason == "duplicate_filtered": - return "这条消息我已处理过,未重复入库。" - if reason in {"non_requirement_noise", "empty_or_emoji_only"}: - return "这条消息不是可执行需求,我没有入库。请直接说要新增什么信号或任务。" - return "这条消息已忽略(未入库)。如需执行,请明确目标、对象和动作。" - - text = str(message_content or "").strip() - strict_keywords = ("单独信号源", "单个信号源", "只入库", "仅入库", "不需要二次处理") - strict_mode = any(k in text for k in strict_keywords) - primary_route_status = "" - results = capture_result.get("results") - if isinstance(results, list) and results: - first = results[0] if isinstance(results[0], dict) else {} - primary_route_status = str((first or {}).get("route_status") or "").strip().lower() - - if primary_route_status == "auto_done" or primary_route_status == "auto_processed": - return "已自动处理完成并入库,后续动作已继续自动推进。" - if primary_route_status == "confirmed": - return "已按确认结果执行并入库。" - if primary_route_status in {"queued_for_decision", "pending"}: - clarification_message = str(capture_result.get("clarification_message") or "").strip() - if clarification_message: - return clarification_message - return "已完成入库,当前进入待确认队列。" - if primary_route_status == "dismissed": - return "该消息已记录,但当前标记为忽略,不会继续执行。" - if primary_route_status == "failed": - return "未执行:受控执行器拒绝了这次动作,请补充缺失字段或检查项目上下文。" - if primary_route_status == "undone": - return "上一次自动结果已撤销,当前不会继续执行。" - - signal_id = str(capture_result.get("signal_id") or "").strip() - linked_project_ids = capture_result.get("linked_project_ids") - project_id: Optional[str] = None - if isinstance(linked_project_ids, list) and linked_project_ids: - project_id = str(linked_project_ids[0]).strip() or None - if not project_id: - candidate = capture_result.get("created_project_id") - if candidate is not None: - project_id = str(candidate).strip() or None - - if signal_id and project_id: - if strict_mode: - return f"已按你的要求仅执行入库:信号#{signal_id},项目#{project_id}。" - return f"已执行完成:信号#{signal_id} 已入库并关联项目#{project_id}。" - if signal_id: - if strict_mode: - return f"已按你的要求仅执行入库:信号#{signal_id}。" - return f"已执行完成:信号#{signal_id} 已入库。" - if project_id: - if strict_mode: - return f"已按你的要求执行:项目#{project_id} 已关联。" - return f"已执行完成:已关联项目#{project_id}。" - return "已执行完成。" - - @staticmethod - def _detect_forbidden_execute_keyword(text: Optional[str]) -> Optional[str]: - lowered = str(text or "").strip().lower() - if not lowered: - return None - forbidden = ( - "curl", - "调用方式", - "接口教程", - "api 调用", - "请求示例", - "postman", - "sdk 调用", - ) - for token in forbidden: - if token in lowered: - return token - return None - - @staticmethod - def _detect_forbidden_qa_keyword(text: Optional[str]) -> Optional[str]: - lowered = str(text or "").strip().lower() - if not lowered: - return None - forbidden = ( - "模型失败了所以没分析", - "让我直接处理", - "直接操作数据库", - "先检查数据库", - "检查数据库迁移", - "数据库迁移状态", - "alembic", - "ddl", - "alter table", - "drop table", - "create table", - "truncate table", - ) - for token in forbidden: - if token in lowered: - return token - return None - - @classmethod - def _contains_forbidden_execute_keywords(cls, text: Optional[str]) -> bool: - return cls._detect_forbidden_execute_keyword(text) is not None - - def _guard_execute_reply_text_with_reason( - self, - text: Optional[str], - *, - trace_id: str, - ) -> tuple[Optional[str], Optional[str]]: - if not text: - return text, None - hit = self._detect_forbidden_execute_keyword(text) - if not hit: - return text, None - self._emit_state_transition( - trace_id=trace_id, - state="ROUTED", - route="execute_template_guard", - reason="forbidden_keyword_detected", - keyword=hit, - ) - return ( - "已执行完成并入库。如需我继续动作,请直接补充目标、对象和时限。", - hit, - ) - - def _guard_execute_reply_text(self, text: Optional[str], *, trace_id: str) -> Optional[str]: - sanitized, _ = self._guard_execute_reply_text_with_reason(text=text, trace_id=trace_id) - return sanitized - - def _guard_qa_reply_text_with_reason( - self, - text: Optional[str], - *, - trace_id: str, - capture_result: Dict[str, Any], - ) -> tuple[Optional[str], Optional[str]]: - if not text: - return text, None - hit = self._detect_forbidden_qa_keyword(text) - if not hit: - return text, None - self._emit_state_transition( - trace_id=trace_id, - state="ROUTED", - route="qa_llm_guard", - reason="forbidden_keyword_detected", - keyword=hit, - ) - return self._fallback_reply_text(capture_result), hit - - async def _record_execute_guard_alert( - self, - *, - trace_id: str, - message_id: str, - message_content: str, - capture_result: Dict[str, Any], - guard_reason: str, - ) -> None: - try: - async with AsyncSessionLocal() as db: - db.add( - AIProcessingLog( - tenant_id=int(self.tenant_id or 1), - feishu_message_id=(message_id or "")[:128] or None, - feishu_item_id=None, - raw_message=(message_content or "")[:8000] or None, - ai_raw_output=json.dumps( - { - "trace_id": trace_id, - "guard_reason": guard_reason, - "status": capture_result.get("status"), - "classification": capture_result.get("classification"), - }, - ensure_ascii=False, - )[:120000], - project_score=None, - signal_confidence=None, - level="execute_guard", - routing_decision="execute_reply_guard_triggered", - routing_reason=f"执行态回复命中禁词({guard_reason}),已切换模板模式。", - reasoning_bullets=[ - "执行态不允许输出调用教程类文本。", - "本次回复已被熔断为固定模板。", - f"trace_id={trace_id}", - ], - processing_status="success", - error_message=None, - duration_ms=0, - ) - ) - recipients = ( - await db.execute( - select(User.id).where( - and_( - User.tenant_id == int(self.tenant_id or 1), - User.is_active.is_(True), - ) - ).limit(5) - ) - ).scalars().all() - await NotificationService(db).create_notifications( - tenant_id=int(self.tenant_id or 1), - user_ids=[int(uid) for uid in recipients], - category="alert", - title="执行态回复熔断已触发", - content=( - f"trace_id={trace_id}\n" - f"message_id={message_id}\n" - f"原因:命中禁词 {guard_reason}\n" - "系统已自动切换为固定模板回复。" - )[:2000], - source="feishu_guard", - entity_type="feishu_message", - entity_id=None, - target_url="/ai-logs", - commit=False, - ) - await db.commit() - except Exception as exc: - print(f"[Feishu] execute guard alert write failed: trace_id={trace_id}, error={exc}") - - async def _record_message_trace( - self, - *, - trace_id: str, - message_id: str, - chat_type: str, - message_content: str, - capture_result: Dict[str, Any], - reply_source: str, - ) -> None: - try: - async with AsyncSessionLocal() as db: - db.add( - AIProcessingLog( - tenant_id=int(self.tenant_id or 1), - feishu_message_id=(message_id or "")[:128] or None, - feishu_item_id=None, - raw_message=(str(capture_result.get("normalized_text") or message_content) or "")[:8000] or None, - ai_raw_output=json.dumps( - { - "trace_id": trace_id, - "chat_type": chat_type, - "normalized_text": str(capture_result.get("normalized_text") or message_content)[:2000], - "route": capture_result.get("route"), - "db_mutations": capture_result.get("db_mutations") or [], - "reply_source": reply_source or "none", - "reply_text": str(capture_result.get("direct_reply_text") or "")[:500], - }, - ensure_ascii=False, - )[:120000], - project_score=None, - signal_confidence=None, - level="reply_trace", - routing_decision=str(capture_result.get("route") or "reply_trace")[:64], - routing_reason="飞书消息 reply trace", - reasoning_bullets=[ - f"chat_type={chat_type}", - f"reply_source={reply_source or 'none'}", - f"trace_id={trace_id}", - ], - processing_status="success", - error_message=None, - duration_ms=0, - ) - ) - await db.commit() - except Exception as exc: - print(f"[Feishu] message trace write failed: trace_id={trace_id}, error={exc}") - - @staticmethod - def _build_mention_prefix(*, open_id: Optional[str], display_name: Optional[str]) -> str: - user_open_id = str(open_id or "").strip() - if not user_open_id: - return "" - safe_name = str(display_name or "你").replace("<", "").replace(">", "").strip()[:24] or "你" - return f'{safe_name} ' - - @staticmethod - def _is_duplicate_capture_result(result: Dict[str, Any]) -> bool: - if not isinstance(result, dict): - return False - status = str(result.get("status") or "").strip().lower() - if status != "ignored": - return False - reason = str(result.get("reason") or "").strip().lower() - classification = str(result.get("classification") or "").strip().lower() - return reason == "duplicate_filtered" or classification == "duplicate" - - @staticmethod - def _should_reply_to_mention(*, mentioned_bot_names: List[str], message_content: Optional[str]) -> bool: - # 仅在明确 @PMFlow(而不是 @all / @其他成员)时触发自动回复 - normalized_names = {str(x or "").strip().lower() for x in mentioned_bot_names if str(x or "").strip()} - if any(name in {"pmflow", "_user_1"} for name in normalized_names): - return True - - # 兼容文本中的 @ 语法(不同消息类型返回格式不一致) - text = str(message_content or "").strip().lower() - if not text: - return False - if "@pmflow" in text or "@_user_1" in text: - return True - return False - - @staticmethod - def _build_time_truth_reply(message_content: Optional[str]) -> Optional[str]: - text = str(message_content or "").strip().lower() - if not text: - return None - time_keywords = ("今天", "日期", "时间", "几号", "几点", "星期", "周几", "当前日期") - if not any(keyword in text for keyword in time_keywords): - return None - - now_cn = datetime.now(CN_TZ) - today_text = ( - f"{now_cn.year}年{now_cn.month}月{now_cn.day}日" - f"({WEEKDAY_CN[now_cn.weekday()]}){now_cn.strftime('%H:%M:%S')}" - ) - if "2023年11月7日" in text or "2023-11-07" in text: - return ( - f"我这边已校准为北京时间 {today_text}。" - "你看到“2023年11月7日”通常是客户端缓存或本机时区异常," - "建议刷新会话并检查系统时区是否为 Asia/Shanghai。" - ) - return ( - f"我这边当前北京时间是 {today_text}。" - "如果你的界面显示不一致,优先检查设备时区和飞书客户端缓存。" - ) - - async def _resolve_sender_display_name( - self, - *, - sender: Dict[str, Any], - sender_id: Dict[str, Any], - ) -> str: - sender_name = (sender.get("sender_name") or "").strip() - if sender_name: - return sender_name - for key in ("name", "en_name"): - value = (sender.get(key) or "").strip() - if value: - return value - - open_id = (sender_id.get("open_id") or "").strip() - if open_id: - remote_name = await self._fetch_user_name_by_open_id(open_id) - if remote_name: - return remote_name - return f"飞书用户({open_id[:8]})" - - user_id = (sender_id.get("user_id") or "").strip() - if user_id: - return f"飞书用户({user_id[:8]})" - return "未知用户" - - async def _fetch_user_name_by_open_id(self, open_id: str) -> Optional[str]: - safe_open_id = (open_id or "").strip() - if not safe_open_id: - return None - try: - access_token = await self._get_tenant_access_token() - except Exception as exc: - print(f"[Feishu] fetch user name skipped: token unavailable: {exc}") - return None - - url = f"https://open.feishu.cn/open-apis/contact/v3/users/{safe_open_id}" - headers = {"Authorization": f"Bearer {access_token}"} - params = {"user_id_type": "open_id"} - try: - async with httpx.AsyncClient(timeout=10.0, trust_env=False) as client: - resp = await client.get(url, headers=headers, params=params) - resp.raise_for_status() - payload = resp.json() or {} - if payload.get("code") != 0: - print( - "[Feishu] fetch user name failed: " - f"code={payload.get('code')} msg={payload.get('msg')}" - ) - return None - user = payload.get("data", {}).get("user", {}) if isinstance(payload.get("data", {}), dict) else {} - name = (user.get("name") or user.get("en_name") or "").strip() - return name or None - except Exception as exc: - print(f"[Feishu] fetch user name failed: {exc}") - return None - - @staticmethod - def _extract_message_datetime(message: Dict[str, Any]) -> Optional[datetime]: - if not isinstance(message, dict): - return None - raw = message.get("create_time") or message.get("update_time") - if raw is None: - return None - try: - ts = int(str(raw).strip()) - if ts > 10_000_000_000: - ts = ts / 1000.0 - return datetime.fromtimestamp(ts, tz=CN_TZ) - except Exception: - return None - - @staticmethod - def _extract_mentioned_bot_names(message: Dict[str, Any]) -> List[str]: - if not isinstance(message, dict): - return [] - mentions = message.get("mentions", []) - names: List[str] = [] - if isinstance(mentions, list): - for item in mentions: - if not isinstance(item, dict): - continue - name = str(item.get("name") or "").strip() - if not name: - continue - if name not in names: - names.append(name) - - content = message.get("content") - text = "" - if isinstance(content, dict): - text = str(content.get("text") or "") - elif isinstance(content, str): - try: - parsed = json.loads(content) - if isinstance(parsed, dict): - text = str(parsed.get("text") or "") - else: - text = content - except Exception: - text = content - - if text: - for raw in re.findall(r"]*>([^<]+)", text, flags=re.IGNORECASE): - name = str(raw or "").strip() - if name and name not in names: - names.append(name) - for raw in re.findall(r"@([^\s@]+)", text): - name = str(raw or "").strip() - if name and name not in names: - names.append(name) - return names - - @staticmethod - def _build_attachment_placeholder_text(attachments: List[Dict[str, Any]]) -> str: - if not attachments: - return "" - names: List[str] = [] - for item in attachments[:3]: - attachment_type = str(item.get("attachment_type") or "附件").strip() - file_name = str(item.get("file_name") or "").strip() - names.append(file_name or attachment_type) - suffix = "、".join(names) - return f"[附件消息] {suffix}" if suffix else "[附件消息]" - - @staticmethod - def _normalize_ingress_attachment_records( - items: List[Dict[str, Any]], - *, - occurred_at: Optional[str] = None, - source_message_id: Optional[str] = None, - source_chat_id: Optional[str] = None, - ) -> List[Dict[str, Any]]: - normalized: List[Dict[str, Any]] = [] - seen_ids: set[str] = set() - for idx, raw in enumerate(items): - if not isinstance(raw, dict): - continue - attachment_type = str(raw.get("attachment_type") or raw.get("type") or "file").strip().lower() - if attachment_type in {"img", "picture"}: - attachment_type = "image" - if attachment_type in {"doc", "docs"}: - attachment_type = "document" - if attachment_type not in {"image", "file", "media", "document"}: - attachment_type = "file" - - file_key = str(raw.get("file_key") or "").strip() or None - image_key = str(raw.get("image_key") or "").strip() or None - media_id = str(raw.get("media_id") or "").strip() or None - download_url = str(raw.get("download_url") or raw.get("url") or "").strip() or None - attachment_id = ( - str(raw.get("attachment_id") or raw.get("id") or file_key or image_key or media_id or "") - .strip() - ) - if not attachment_id: - attachment_id = f"attachment-{idx + 1}" - if not any([file_key, image_key, media_id, download_url]): - continue - if attachment_id in seen_ids: - continue - seen_ids.add(attachment_id) - - file_size = raw.get("file_size") if raw.get("file_size") is not None else raw.get("size") - try: - file_size = int(str(file_size).strip()) if file_size is not None else None - except (TypeError, ValueError): - file_size = None - file_name = ( - str(raw.get("file_name") or raw.get("name") or raw.get("title") or "").strip() - or f"{attachment_type}-{attachment_id}" - ) - mime_type = str(raw.get("mime_type") or raw.get("mime") or "").strip() or None - normalized.append( - { - "id": attachment_id, - "attachment_id": attachment_id, - "attachment_type": attachment_type, - "file_name": file_name, - "mime_type": mime_type, - "file_size": file_size, - "file_key": file_key, - "image_key": image_key, - "media_id": media_id, - "download_url": download_url, - "source": str(raw.get("source") or "feishu").strip() or "feishu", - "occurred_at": str(raw.get("occurred_at") or occurred_at or "").strip() or None, - "source_message_id": str(raw.get("source_message_id") or source_message_id or "").strip() or None, - "source_chat_id": str(raw.get("source_chat_id") or source_chat_id or "").strip() or None, - } - ) - return normalized - - def _extract_message_payload(self, event: Dict[str, Any]) -> Dict[str, Any]: - """提取消息文本与附件元数据。""" - message = event.get("message", {}) - if not isinstance(message, dict) and isinstance(event.get("event", {}), dict): - message = event.get("event", {}).get("message", {}) - if not isinstance(message, dict): - return {"text": None, "attachments": [], "message_type": ""} - - message_type = str(message.get("message_type") or "").strip().lower() - content = message.get("content", "") - content_json: Dict[str, Any] = {} - if isinstance(content, dict): - content_json = content - elif isinstance(content, str) and content.strip(): - try: - parsed = json.loads(content) - if isinstance(parsed, dict): - content_json = parsed - except Exception: - if message_type == "text": - return {"text": content.strip() or None, "attachments": [], "message_type": message_type} - - text_parts: List[str] = [] - attachments: List[Dict[str, Any]] = [] - - if message_type == "text": - text = str(content_json.get("text") or "").strip() - return {"text": text or None, "attachments": [], "message_type": message_type} - - if message_type == "post": - post = content_json.get("post", {}) if isinstance(content_json, dict) else {} - zh_cn = post.get("zh_cn", {}) if isinstance(post, dict) else {} - elements = zh_cn.get("content", []) if isinstance(zh_cn, dict) else [] - if (not elements) and isinstance(content_json, dict): - direct_elements = content_json.get("content", []) - if isinstance(direct_elements, list): - elements = direct_elements - for row in elements: - row_items = row if isinstance(row, list) else [row] - for elem in row_items: - if not isinstance(elem, dict): - continue - tag = str(elem.get("tag") or "").strip().lower() - if tag == "text": - text = str(elem.get("text") or "").strip() - if text: - text_parts.append(text) - elif tag in {"img", "image"}: - attachments.append( - { - "attachment_type": "image", - "image_key": elem.get("image_key"), - "file_name": elem.get("title") or "图片附件", - } - ) - elif tag in {"file", "media"}: - attachments.append( - { - "attachment_type": "media" if tag == "media" else "file", - "file_key": elem.get("file_key"), - "media_id": elem.get("media_id"), - "file_name": elem.get("file_name") or elem.get("title"), - "file_size": elem.get("file_size"), - } - ) - elif message_type in {"image", "file", "media"}: - attachments.append( - { - "attachment_type": message_type, - "image_key": content_json.get("image_key"), - "file_key": content_json.get("file_key"), - "media_id": content_json.get("media_id"), - "file_name": content_json.get("file_name") or content_json.get("title"), - "file_size": content_json.get("file_size"), - "mime_type": content_json.get("file_type") or content_json.get("mime_type"), - } - ) - text = str(content_json.get("text") or content_json.get("caption") or "").strip() - if text: - text_parts.append(text) - elif isinstance(content_json, dict): - guessed_keys = { - "image_key": content_json.get("image_key"), - "file_key": content_json.get("file_key"), - "media_id": content_json.get("media_id"), - "download_url": content_json.get("download_url"), - } - if any(v for v in guessed_keys.values()): - attachments.append( - { - "attachment_type": "file", - "image_key": guessed_keys.get("image_key"), - "file_key": guessed_keys.get("file_key"), - "media_id": guessed_keys.get("media_id"), - "download_url": guessed_keys.get("download_url"), - "file_name": content_json.get("file_name") or content_json.get("title"), - "file_size": content_json.get("file_size"), - "mime_type": content_json.get("mime_type"), - } - ) - text = str(content_json.get("text") or "").strip() - if text: - text_parts.append(text) - - normalized_attachments = self._normalize_ingress_attachment_records(attachments) - merged_text = " ".join([part for part in text_parts if part]).strip() - if not merged_text and normalized_attachments: - merged_text = self._build_attachment_placeholder_text(normalized_attachments) - return { - "text": merged_text or None, - "attachments": normalized_attachments, - "message_type": message_type, - } - - def _extract_message_content(self, event: Dict[str, Any]) -> Optional[str]: - payload = self._extract_message_payload(event) - text = str(payload.get("text") or "").strip() - return text or None - - async def _persist_signal_attachments_from_ingress( - self, - *, - signal_id: Any, - attachments: List[Dict[str, Any]], - ) -> None: - if not attachments: - return - try: - signal_int = int(str(signal_id).strip()) - except (TypeError, ValueError, AttributeError): - return - signal = ( - await self.db.execute( - select(Signal).where( - and_( - Signal.tenant_id == int(self.tenant_id or 1), - Signal.id == signal_int, - ) - ) - ) - ).scalar_one_or_none() - if signal is None: - return - - try: - payload = signal.strategic_analysis - strategic: Dict[str, Any] = {} - if isinstance(payload, dict): - strategic = dict(payload) - elif isinstance(payload, str) and payload.strip(): - parsed = json.loads(payload) - if isinstance(parsed, dict): - strategic = parsed - - source_context = strategic.get("source_context") - if not isinstance(source_context, dict): - source_context = {} - existing = source_context.get("attachments") - existing_rows = existing if isinstance(existing, list) else [] - merged = self._normalize_ingress_attachment_records( - [*(row for row in existing_rows if isinstance(row, dict)), *attachments] - ) - if not merged: - return - source_context["attachments"] = merged - strategic["source_context"] = source_context - signal.strategic_analysis = json.dumps(strategic, ensure_ascii=False) - await self.db.commit() - except Exception as exc: - print(f"[Feishu] persist signal attachments failed: signal_id={signal_int}, error={exc}") - try: - await self.db.rollback() - except Exception: - pass - - async def send_message_to_group( - self, - chat_id: str, - message: str - ) -> Dict[str, Any]: - """ - 发送消息到飞书群聊 - - 用于推送通知、简报等 - """ - if not self.is_configured(): - raise ValueError("飞书未配置") - - # 获取 tenant_access_token - access_token = await self._get_tenant_access_token() - - # 发送消息 API - url = "https://open.feishu.cn/open-apis/im/v1/messages" - headers = { - 'Authorization': f'Bearer {access_token}', - 'Content-Type': 'application/json' - } - - message_data = { - 'receive_id': chat_id, - 'msg_type': 'text', - 'content': json.dumps({'text': message}) - } - - async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client: - resp = await client.post( - url, - headers=headers, - params={'receive_id_type': 'chat_id'}, - json=message_data, - ) - payload: Dict[str, Any] - try: - payload = resp.json() - except Exception: - payload = {"http_status": resp.status_code, "raw": resp.text[:500]} - if resp.status_code >= 400: - raise ValueError( - f"飞书发送失败(http={resp.status_code}): {payload}" - ) - if int(payload.get("code", -1)) != 0: - raise ValueError( - f"飞书发送失败: code={payload.get('code')} msg={payload.get('msg') or payload.get('message')}" - ) - return payload - - async def _get_tenant_access_token(self) -> str: - """获取 tenant_access_token""" - if not self.app_id or not self.app_secret: - raise ValueError("飞书未配置") - - url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" - headers = {'Content-Type': 'application/json'} - data = { - 'app_id': self.app_id, - 'app_secret': self.app_secret - } - - async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client: - try: - resp = await client.post(url, headers=headers, json=data) - except httpx.RequestError as exc: - raise ValueError(f"访问飞书 auth 接口失败: {exc}") from exc - - try: - result = resp.json() - except Exception: - result = {"http_status": resp.status_code, "raw": resp.text[:500]} - - if resp.status_code >= 400: - raise ValueError( - f"获取 tenant_access_token 失败(http={resp.status_code}): {result}" - ) - - if result.get('code') == 0 and result.get('tenant_access_token'): - return result.get('tenant_access_token') - - raise ValueError( - "获取 tenant_access_token 失败: " - f"code={result.get('code')} msg={result.get('msg') or result.get('message')} payload={result}" - ) - - async def download_attachment_bytes(self, attachment: Dict[str, Any]) -> Dict[str, Any]: - """通过飞书开放平台代理下载附件。""" - if not self.is_configured(): - raise ValueError("飞书未配置") - access_token = await self._get_tenant_access_token() - attachment_type = str(attachment.get("attachment_type") or "file").strip().lower() - file_name = str(attachment.get("file_name") or "").strip() or None - media_type = str(attachment.get("mime_type") or "").strip() or None - - image_key = str(attachment.get("image_key") or "").strip() - file_key = str(attachment.get("file_key") or "").strip() - media_id = str(attachment.get("media_id") or "").strip() - download_url = str(attachment.get("download_url") or "").strip() - - url = "" - if download_url: - url = download_url - elif attachment_type == "image" and image_key: - url = f"https://open.feishu.cn/open-apis/im/v1/images/{image_key}" - elif file_key: - url = f"https://open.feishu.cn/open-apis/im/v1/files/{file_key}" - elif media_id: - url = f"https://open.feishu.cn/open-apis/im/v1/files/{media_id}" - - if not url: - raise ValueError("附件缺少可下载 key,无法代理下载") - - headers = {"Authorization": f"Bearer {access_token}"} - async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client: - try: - resp = await client.get(url, headers=headers, follow_redirects=True) - except httpx.RequestError as exc: - raise ValueError(f"飞书附件下载请求失败: {exc}") from exc - - if resp.status_code >= 400: - body_preview = resp.text[:300] if resp.text else "" - raise ValueError( - f"飞书附件下载失败(http={resp.status_code}): {body_preview}" - ) - - content = resp.content or b"" - if not content: - raise ValueError("飞书附件下载为空内容") - - response_content_type = str(resp.headers.get("content-type") or "").strip() - content_type = media_type or response_content_type or "application/octet-stream" - return { - "content": content, - "content_type": content_type, - "file_name": file_name, - } - - async def send_rich_card( - self, - chat_id: str, - title: str, - content: List[Dict[str, Any]] - ) -> Dict[str, Any]: - """ - 发送富文本卡片消息 - - 用于发送格式化的简报、通知等 - """ - if not self.is_configured(): - raise ValueError("飞书未配置") - - access_token = await self._get_tenant_access_token() - - url = "https://open.feishu.cn/open-apis/im/v1/messages" - headers = { - 'Authorization': f'Bearer {access_token}', - 'Content-Type': 'application/json' - } - - # 构造卡片内容 - card_content = { - 'config': {'wide_screen_mode': True}, - 'header': { - 'title': { - 'content': title, - 'tag': 'plain_text' - } - }, - 'elements': [] - } - - for item in content: - if item.get('type') == 'text': - card_content['elements'].append({ - 'tag': 'div', - 'text': { - 'content': item.get('text', ''), - 'tag': 'lark_md' - } - }) - elif item.get('type') == 'divider': - card_content['elements'].append({'tag': 'hr'}) - - message_data = { - 'receive_id': chat_id, - 'msg_type': 'interactive', - 'content': json.dumps(card_content) - } - - async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client: - resp = await client.post( - url, - headers=headers, - params={'receive_id_type': 'chat_id'}, - json=message_data, - ) - payload: Dict[str, Any] - try: - payload = resp.json() - except Exception: - payload = {"http_status": resp.status_code, "raw": resp.text[:500]} - if resp.status_code >= 400: - raise ValueError( - f"飞书发送失败(http={resp.status_code}): {payload}" - ) - if int(payload.get("code", -1)) != 0: - raise ValueError( - f"飞书发送失败: code={payload.get('code')} msg={payload.get('msg') or payload.get('message')}" - ) - return payload - - -# 全局服务实例 -_feishu_service: Optional[FeishuIntegrationService] = None - - -async def dispatch_pending_feishu_replies(limit: int = 30) -> int: - """后台补偿发送:扫描 pending/retrying outbox 并触发派发。""" - return await FeishuIntegrationService.dispatch_pending_outbox_entries(limit=limit) - - -def get_feishu_service(db: AsyncSession) -> FeishuIntegrationService: - """获取飞书服务实例""" - return FeishuIntegrationService(db) +__all__ = ["FeishuIntegrationService", "dispatch_pending_feishu_replies", "get_feishu_service"] diff --git a/backend/app/services/feishu_integration_service/__init__.py b/backend/app/services/feishu_integration_service/__init__.py new file mode 100644 index 00000000..9404ef2a --- /dev/null +++ b/backend/app/services/feishu_integration_service/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .service import FeishuIntegrationService, dispatch_pending_feishu_replies, get_feishu_service + +__all__ = ["FeishuIntegrationService", "dispatch_pending_feishu_replies", "get_feishu_service"] diff --git a/backend/app/services/feishu_integration_service/mixin_config.py b/backend/app/services/feishu_integration_service/mixin_config.py new file mode 100644 index 00000000..2233f993 --- /dev/null +++ b/backend/app/services/feishu_integration_service/mixin_config.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from .shared import * + +class FeishuConfigMixin: + def __init__(self, db: AsyncSession): + self.db = db + + # 从数据库获取飞书配置 + self.app_id = None + self.app_secret = None + self.encrypt_key = None + self.verification_token = None + self.event_mode = "long_connection" + self.tenant_id = 1 + + @staticmethod + def _normalize_event_mode(_: Optional[str] = None) -> str: + return "long_connection" + + @staticmethod + def _is_postgresql_session(db: AsyncSession) -> bool: + try: + bind = db.get_bind() + except Exception: + return False + dialect_name = str(getattr(getattr(bind, "dialect", None), "name", "")).lower() + return dialect_name.startswith("postgres") + + @staticmethod + async def _notify_outbox_ready(db: AsyncSession, *, outbox_id: Optional[int]) -> None: + """仅在 PostgreSQL 下发送 outbox ready 事件。""" + if not FeishuIntegrationService._is_postgresql_session(db): + return + try: + payload = str(int(outbox_id))[:64] if outbox_id is not None else "" + await db.execute( + text("SELECT pg_notify('feishu_outbox_ready', :payload)"), + {"payload": payload}, + ) + except Exception as exc: + print(f"[Feishu] outbox notify failed: outbox_id={outbox_id}, error={exc}") + + async def load_config(self, tenant_id: int = 1): + """从数据库加载飞书配置""" + self.tenant_id = tenant_id + + result = await self.db.execute( + select(Integration).where( + Integration.tenant_id == tenant_id, + Integration.type == 'feishu', + Integration.is_active.is_(True), + ) + ) + integration = result.scalar_one_or_none() + + if integration: + if isinstance(integration.config, str): + config = json.loads(integration.config) if integration.config else {} + else: + config = integration.config or {} + self.app_id = config.get('app_id') + self.app_secret = config.get('app_secret') + self.encrypt_key = None + self.verification_token = None + self.event_mode = self._normalize_event_mode(config.get('event_mode')) + else: + self.app_id = None + self.app_secret = None + self.encrypt_key = None + self.verification_token = None + self.event_mode = 'long_connection' + + def is_configured(self) -> bool: + """检查是否已配置""" + return bool(self.app_id and self.app_secret) + + async def save_config( + self, + app_id: str, + app_secret: str, + encrypt_key: str, + verification_token: str, + event_mode: str = "long_connection", + tenant_id: int = 1 + ): + """保存飞书配置到数据库""" + mode = self._normalize_event_mode(event_mode) + config = { + 'app_id': app_id, + 'app_secret': app_secret, + 'encrypt_key': "", + 'verification_token': "", + 'event_mode': mode, + } + + # 查找或创建集成记录 + result = await self.db.execute( + select(Integration).where( + Integration.tenant_id == tenant_id, + Integration.type == 'feishu' + ) + ) + integration = result.scalar_one_or_none() + + if integration: + integration.config = config + integration.is_active = True + else: + integration = Integration( + type='feishu', + config=config, + is_active=True, + tenant_id=tenant_id + ) + self.db.add(integration) + + await self.db.commit() + + # 更新内存配置 + self.app_id = app_id + self.app_secret = app_secret + self.encrypt_key = None + self.verification_token = None + self.event_mode = mode + self.tenant_id = tenant_id + + async def handle_message_event( + self, + event_data: Dict[str, Any] + ) -> Dict[str, Any]: + """ + 处理飞书消息事件 + + 支持的事件类型: + - message.receive_v1: 收到群聊消息 + - im.message.receive_v1: 收到群聊消息(schema 2.0) + """ + try: + event_type = (event_data.get('type') or '').strip() + + # 飞书 schema 2.0 会返回 im.message.receive_v1 + if event_type in {'message.receive_v1', 'im.message.receive_v1'}: + return await self._handle_message_receive(event_data) + + return { + 'code': 0, + 'msg': f'Event type {event_type} not handled' + } + + except Exception as e: + print(f"[Feishu] Handle event error: {e}") + return { + 'code': 1, + 'msg': str(e) + } + diff --git a/backend/app/services/feishu_integration_service/mixin_message_payload.py b/backend/app/services/feishu_integration_service/mixin_message_payload.py new file mode 100644 index 00000000..f21fc3db --- /dev/null +++ b/backend/app/services/feishu_integration_service/mixin_message_payload.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +from .shared import * + +class FeishuMessagePayloadMixin: + @staticmethod + def _build_attachment_placeholder_text(attachments: List[Dict[str, Any]]) -> str: + if not attachments: + return "" + names: List[str] = [] + for item in attachments[:3]: + attachment_type = str(item.get("attachment_type") or "附件").strip() + file_name = str(item.get("file_name") or "").strip() + names.append(file_name or attachment_type) + suffix = "、".join(names) + return f"[附件消息] {suffix}" if suffix else "[附件消息]" + + @staticmethod + def _normalize_ingress_attachment_records( + items: List[Dict[str, Any]], + *, + occurred_at: Optional[str] = None, + source_message_id: Optional[str] = None, + source_chat_id: Optional[str] = None, + ) -> List[Dict[str, Any]]: + normalized: List[Dict[str, Any]] = [] + seen_ids: set[str] = set() + for idx, raw in enumerate(items): + if not isinstance(raw, dict): + continue + attachment_type = str(raw.get("attachment_type") or raw.get("type") or "file").strip().lower() + if attachment_type in {"img", "picture"}: + attachment_type = "image" + if attachment_type in {"doc", "docs"}: + attachment_type = "document" + if attachment_type not in {"image", "file", "media", "document"}: + attachment_type = "file" + + file_key = str(raw.get("file_key") or "").strip() or None + image_key = str(raw.get("image_key") or "").strip() or None + media_id = str(raw.get("media_id") or "").strip() or None + download_url = str(raw.get("download_url") or raw.get("url") or "").strip() or None + attachment_id = ( + str(raw.get("attachment_id") or raw.get("id") or file_key or image_key or media_id or "") + .strip() + ) + if not attachment_id: + attachment_id = f"attachment-{idx + 1}" + if not any([file_key, image_key, media_id, download_url]): + continue + if attachment_id in seen_ids: + continue + seen_ids.add(attachment_id) + + file_size = raw.get("file_size") if raw.get("file_size") is not None else raw.get("size") + try: + file_size = int(str(file_size).strip()) if file_size is not None else None + except (TypeError, ValueError): + file_size = None + file_name = ( + str(raw.get("file_name") or raw.get("name") or raw.get("title") or "").strip() + or f"{attachment_type}-{attachment_id}" + ) + mime_type = str(raw.get("mime_type") or raw.get("mime") or "").strip() or None + normalized.append( + { + "id": attachment_id, + "attachment_id": attachment_id, + "attachment_type": attachment_type, + "file_name": file_name, + "mime_type": mime_type, + "file_size": file_size, + "file_key": file_key, + "image_key": image_key, + "media_id": media_id, + "download_url": download_url, + "source": str(raw.get("source") or "feishu").strip() or "feishu", + "occurred_at": str(raw.get("occurred_at") or occurred_at or "").strip() or None, + "source_message_id": str(raw.get("source_message_id") or source_message_id or "").strip() or None, + "source_chat_id": str(raw.get("source_chat_id") or source_chat_id or "").strip() or None, + } + ) + return normalized + + def _extract_message_payload(self, event: Dict[str, Any]) -> Dict[str, Any]: + """提取消息文本与附件元数据。""" + message = event.get("message", {}) + if not isinstance(message, dict) and isinstance(event.get("event", {}), dict): + message = event.get("event", {}).get("message", {}) + if not isinstance(message, dict): + return {"text": None, "attachments": [], "message_type": ""} + + message_type = str(message.get("message_type") or "").strip().lower() + content = message.get("content", "") + content_json: Dict[str, Any] = {} + if isinstance(content, dict): + content_json = content + elif isinstance(content, str) and content.strip(): + try: + parsed = json.loads(content) + if isinstance(parsed, dict): + content_json = parsed + except Exception: + if message_type == "text": + return {"text": content.strip() or None, "attachments": [], "message_type": message_type} + + text_parts: List[str] = [] + attachments: List[Dict[str, Any]] = [] + + if message_type == "text": + text = str(content_json.get("text") or "").strip() + return {"text": text or None, "attachments": [], "message_type": message_type} + + if message_type == "post": + post = content_json.get("post", {}) if isinstance(content_json, dict) else {} + zh_cn = post.get("zh_cn", {}) if isinstance(post, dict) else {} + elements = zh_cn.get("content", []) if isinstance(zh_cn, dict) else [] + if (not elements) and isinstance(content_json, dict): + direct_elements = content_json.get("content", []) + if isinstance(direct_elements, list): + elements = direct_elements + for row in elements: + row_items = row if isinstance(row, list) else [row] + for elem in row_items: + if not isinstance(elem, dict): + continue + tag = str(elem.get("tag") or "").strip().lower() + if tag == "text": + text = str(elem.get("text") or "").strip() + if text: + text_parts.append(text) + elif tag in {"img", "image"}: + attachments.append( + { + "attachment_type": "image", + "image_key": elem.get("image_key"), + "file_name": elem.get("title") or "图片附件", + } + ) + elif tag in {"file", "media"}: + attachments.append( + { + "attachment_type": "media" if tag == "media" else "file", + "file_key": elem.get("file_key"), + "media_id": elem.get("media_id"), + "file_name": elem.get("file_name") or elem.get("title"), + "file_size": elem.get("file_size"), + } + ) + elif message_type in {"image", "file", "media"}: + attachments.append( + { + "attachment_type": message_type, + "image_key": content_json.get("image_key"), + "file_key": content_json.get("file_key"), + "media_id": content_json.get("media_id"), + "file_name": content_json.get("file_name") or content_json.get("title"), + "file_size": content_json.get("file_size"), + "mime_type": content_json.get("file_type") or content_json.get("mime_type"), + } + ) + text = str(content_json.get("text") or content_json.get("caption") or "").strip() + if text: + text_parts.append(text) + elif isinstance(content_json, dict): + guessed_keys = { + "image_key": content_json.get("image_key"), + "file_key": content_json.get("file_key"), + "media_id": content_json.get("media_id"), + "download_url": content_json.get("download_url"), + } + if any(v for v in guessed_keys.values()): + attachments.append( + { + "attachment_type": "file", + "image_key": guessed_keys.get("image_key"), + "file_key": guessed_keys.get("file_key"), + "media_id": guessed_keys.get("media_id"), + "download_url": guessed_keys.get("download_url"), + "file_name": content_json.get("file_name") or content_json.get("title"), + "file_size": content_json.get("file_size"), + "mime_type": content_json.get("mime_type"), + } + ) + text = str(content_json.get("text") or "").strip() + if text: + text_parts.append(text) + + normalized_attachments = self._normalize_ingress_attachment_records(attachments) + merged_text = " ".join([part for part in text_parts if part]).strip() + if not merged_text and normalized_attachments: + merged_text = self._build_attachment_placeholder_text(normalized_attachments) + return { + "text": merged_text or None, + "attachments": normalized_attachments, + "message_type": message_type, + } + + def _extract_message_content(self, event: Dict[str, Any]) -> Optional[str]: + payload = self._extract_message_payload(event) + text = str(payload.get("text") or "").strip() + return text or None + + async def _persist_signal_attachments_from_ingress( + self, + *, + signal_id: Any, + attachments: List[Dict[str, Any]], + ) -> None: + if not attachments: + return + try: + signal_int = int(str(signal_id).strip()) + except (TypeError, ValueError, AttributeError): + return + signal = ( + await self.db.execute( + select(Signal).where( + and_( + Signal.tenant_id == int(self.tenant_id or 1), + Signal.id == signal_int, + ) + ) + ) + ).scalar_one_or_none() + if signal is None: + return + + try: + payload = signal.strategic_analysis + strategic: Dict[str, Any] = {} + if isinstance(payload, dict): + strategic = dict(payload) + elif isinstance(payload, str) and payload.strip(): + parsed = json.loads(payload) + if isinstance(parsed, dict): + strategic = parsed + + source_context = strategic.get("source_context") + if not isinstance(source_context, dict): + source_context = {} + existing = source_context.get("attachments") + existing_rows = existing if isinstance(existing, list) else [] + merged = self._normalize_ingress_attachment_records( + [*(row for row in existing_rows if isinstance(row, dict)), *attachments] + ) + if not merged: + return + source_context["attachments"] = merged + strategic["source_context"] = source_context + signal.strategic_analysis = json.dumps(strategic, ensure_ascii=False) + await self.db.commit() + except Exception as exc: + print(f"[Feishu] persist signal attachments failed: signal_id={signal_int}, error={exc}") + try: + await self.db.rollback() + except Exception: + pass + diff --git a/backend/app/services/feishu_integration_service/mixin_receive.py b/backend/app/services/feishu_integration_service/mixin_receive.py new file mode 100644 index 00000000..fa286c27 --- /dev/null +++ b/backend/app/services/feishu_integration_service/mixin_receive.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +from .shared import * + +class FeishuReceiveMixin: + async def _handle_message_receive( + self, + event: Dict[str, Any] + ) -> Dict[str, Any]: + """处理收到的消息事件""" + state_trace_id = f"feishu-sm-{uuid4().hex[:12]}" + ingress_source = str(event.get("_ingress_source") or "feishu").strip() or "feishu" + message = event.get('message', {}) if isinstance(event.get('message', {}), dict) else {} + chat = event.get('chat', {}) if isinstance(event.get('chat', {}), dict) else {} + sender = event.get('sender', {}) if isinstance(event.get('sender', {}), dict) else {} + message_id = (message.get("message_id") or "").strip() or "unknown" + perf_t0_raw = event.get("_perf_t0") + perf_t0: Optional[float] = None + if isinstance(perf_t0_raw, (int, float)): + perf_t0 = float(perf_t0_raw) + else: + try: + perf_t0 = float(str(perf_t0_raw).strip()) + except Exception: + perf_t0 = None + self._emit_state_transition( + trace_id=state_trace_id, + state="RECEIVED", + message_id=message_id, + message_type=str(message.get("message_type") or "unknown"), + ) + sender_type = str(sender.get("sender_type") or "").strip().lower() + if sender_type in {"app", "bot"}: + return {'code': 0, 'msg': 'Ignore bot/app message'} + + mentioned_bot_names = self._extract_mentioned_bot_names(message) + + # 解析消息内容与附件元数据 + message_payload = self._extract_message_payload(event) + message_content = str(message_payload.get("text") or "").strip() or None + message_attachments = ( + message_payload.get("attachments") + if isinstance(message_payload.get("attachments"), list) + else [] + ) + await self._record_ingress_trace( + ingress_source=ingress_source, + trace_id=state_trace_id, + message_id=message_id, + event=event, + message_content=message_content or "", + ) + if not message_content: + print( + f"[Feishu] message.receive_v1 ignored: no text content, " + f"message_type={message.get('message_type')}, keys={list(message.keys())}" + ) + chat_id = chat.get('chat_id') or message.get('chat_id') or '' + if mentioned_bot_names and chat_id: + await self._safe_reply_text( + chat_id=chat_id, + mention_open_id=(sender.get('sender_id', {}) or {}).get('open_id'), + mention_name=(sender.get('sender_name') or sender.get('name') or '你'), + text="我已收到你的消息,但目前无法直接解析纯图片内容。请补充 1-2 句文字需求,我会立即给出分派建议。", + trace_id=state_trace_id, + source_message_id=message_id, + perf_t0=perf_t0, + ) + self._emit_state_transition( + trace_id=state_trace_id, + state="REPLIED", + route="empty_text_prompt", + ) + return {'code': 0, 'msg': 'No text content'} + + # 获取发送者信息 + sender_id = sender.get('sender_id', {}) if isinstance(sender.get('sender_id', {}), dict) else {} + sender_open_id = (sender_id.get('open_id') or "").strip() or None + sender_name = await self._resolve_sender_display_name( + sender=sender, + sender_id=sender_id, + ) + + # 获取群聊信息 + chat_id = chat.get('chat_id') or message.get('chat_id') or '' + chat_type = str(chat.get("chat_type") or message.get("chat_type") or "").strip().lower() or "group" + captured_at = self._extract_message_datetime(message) + occurred_at_iso = ( + captured_at.isoformat() if isinstance(captured_at, datetime) else None + ) + message_attachments = self._normalize_ingress_attachment_records( + message_attachments, + occurred_at=occurred_at_iso, + source_message_id=(message.get("message_id") or "").strip() or None, + source_chat_id=chat_id or None, + ) + + # 使用信号捕获服务处理 + capture_service = ProactiveSignalCaptureService(self.db) + + try: + result = await capture_service.capture_from_feishu( + group_chat_id=chat_id, + message_content=message_content, + sender=sender_name, + tenant_id=self.tenant_id, + proposer_open_id=sender_open_id, + captured_at=captured_at, + mentioned_bot_names=mentioned_bot_names, + feishu_message_id=(message.get("message_id") or "").strip() or None, + chat_type=chat_type, + ) + await self._persist_signal_attachments_from_ingress( + signal_id=result.get("signal_id"), + attachments=message_attachments, + ) + except Exception as exc: + self._emit_state_transition( + trace_id=state_trace_id, + state="ERROR_REPLY", + reason="capture_exception", + error=str(exc), + ) + if chat_id: + await self._safe_reply_text( + chat_id=chat_id, + mention_open_id=sender_open_id, + mention_name=sender_name, + text=self._build_error_reply_text(), + trace_id=state_trace_id, + source_message_id=message_id, + perf_t0=perf_t0, + ) + self._emit_state_transition( + trace_id=state_trace_id, + state="REPLIED", + route="error_template", + ) + return { + "code": 0, + "msg": "error_handled", + "data": { + "status": "error", + "classification": "error", + "ai_trace_id": state_trace_id, + }, + } + + print( + "[Feishu] Message processed: " + f"status={result.get('status')}, " + f"classification={result.get('classification')}, " + f"linked_projects={result.get('linked_project_ids', [])}, " + f"auto_tasks={len(result.get('auto_tasks', []) if isinstance(result.get('auto_tasks', []), list) else [])}, " + f"ai_trace_id={result.get('ai_trace_id')}" + ) + scene = self._classify_message_scene(capture_result=result) + self._emit_state_transition( + trace_id=state_trace_id, + state="CLASSIFIED", + scene=scene, + status=str(result.get("status") or "unknown"), + classification=str(result.get("classification") or "unknown"), + ) + if str(result.get("status") or "").lower() != "error": + self._emit_state_transition( + trace_id=state_trace_id, + state="COMMITTED", + ai_trace_id=str(result.get("ai_trace_id") or ""), + ) + + reply_route = await self._route_scene_reply( + scene=scene, + result=result, + message_content=message_content, + chat_id=chat_id, + sender_open_id=sender_open_id, + sender_name=sender_name, + mentioned_bot_names=mentioned_bot_names, + trace_id=state_trace_id, + message_id=message_id, + perf_t0=perf_t0, + ) + + if reply_route != "none": + self._emit_state_transition( + trace_id=state_trace_id, + state="REPLIED", + route=reply_route, + ) + await self._record_message_trace( + trace_id=state_trace_id, + message_id=message_id, + chat_type=chat_type, + message_content=message_content, + capture_result=result, + reply_source=reply_route, + ) + + return { + 'code': 0, + 'msg': 'success', + 'data': { + 'status': result.get('status'), + 'classification': result.get('classification'), + 'linked_project_ids': result.get('linked_project_ids', []), + 'auto_tasks_count': len(result.get('auto_tasks', []) if isinstance(result.get('auto_tasks', []), list) else []), + 'ai_trace_id': result.get('ai_trace_id'), + 'state_trace_id': state_trace_id, + 'scene': scene, + }, + } + diff --git a/backend/app/services/feishu_integration_service/mixin_receive_routing.py b/backend/app/services/feishu_integration_service/mixin_receive_routing.py new file mode 100644 index 00000000..76e41e3f --- /dev/null +++ b/backend/app/services/feishu_integration_service/mixin_receive_routing.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from .shared import * + +class FeishuReceiveRoutingMixin: + async def _route_scene_reply( + self, + *, + scene: str, + result: Dict[str, Any], + message_content: str, + chat_id: str, + sender_open_id: Optional[str], + sender_name: str, + mentioned_bot_names: List[str], + trace_id: str, + message_id: str, + perf_t0: Optional[float], + ) -> str: + reply_route = "none" + capability_reply = self._build_capability_reply(message_content) + if scene == "execute": + if self._is_duplicate_capture_result(result): + self._emit_state_transition( + trace_id=trace_id, + state="ROUTED", + route="duplicate_suppressed", + ) + return reply_route + + self._emit_state_transition( + trace_id=trace_id, + state="ROUTED", + route="tool_first_execute", + ) + deterministic_reply = capability_reply or str(result.get("direct_reply_text") or "").strip() + if not deterministic_reply: + deterministic_reply = self._build_execution_receipt_reply( + message_content=message_content, + capture_result=result, + ) + deterministic_reply, guard_reason = self._guard_execute_reply_text_with_reason( + deterministic_reply, + trace_id=trace_id, + ) + if guard_reason: + asyncio.create_task( + self._record_execute_guard_alert( + trace_id=trace_id, + message_id=message_id, + message_content=message_content, + capture_result=result, + guard_reason=guard_reason, + ) + ) + if not deterministic_reply and str(result.get("status") or "").lower() == "error": + deterministic_reply = self._build_error_reply_text() + if chat_id and deterministic_reply: + await self._safe_reply_text( + chat_id=chat_id, + mention_open_id=sender_open_id, + mention_name=sender_name, + text=deterministic_reply, + trace_id=trace_id, + source_message_id=message_id, + perf_t0=perf_t0, + ) + return "execute_template" + return reply_route + + if scene == "qa": + self._emit_state_transition( + trace_id=trace_id, + state="ROUTED", + route="qa_consult", + ) + if chat_id and capability_reply: + await self._safe_reply_text( + chat_id=chat_id, + mention_open_id=sender_open_id, + mention_name=sender_name, + text=capability_reply, + trace_id=trace_id, + source_message_id=message_id, + perf_t0=perf_t0, + ) + return "qa_capability" + if chat_id and bool(result.get("direct_reply_required")): + await self._safe_reply_text( + chat_id=chat_id, + mention_open_id=sender_open_id, + mention_name=sender_name, + text=str(result.get("direct_reply_text") or "已收到,正在处理。"), + trace_id=trace_id, + source_message_id=message_id, + perf_t0=perf_t0, + ) + return "qa_direct" + if chat_id and self._should_reply_to_mention( + mentioned_bot_names=mentioned_bot_names, + message_content=message_content, + ): + await self._reply_with_active_role_prompt( + chat_id=chat_id, + sender_open_id=sender_open_id, + sender_name=sender_name, + message_content=message_content, + capture_result=result, + trace_id=trace_id, + source_message_id=message_id, + perf_t0=perf_t0, + ) + return "qa_llm" + return reply_route + + self._emit_state_transition( + trace_id=trace_id, + state="ERROR_REPLY", + reason="scene_error", + capture_status=str(result.get("status") or "unknown"), + ) + if chat_id: + await self._safe_reply_text( + chat_id=chat_id, + mention_open_id=sender_open_id, + mention_name=sender_name, + text=self._build_error_reply_text(), + trace_id=trace_id, + source_message_id=message_id, + perf_t0=perf_t0, + ) + return "error_template" + return reply_route + + @staticmethod + def _classify_message_scene(*, capture_result: Dict[str, Any]) -> str: + if not isinstance(capture_result, dict): + return "error" + status = str(capture_result.get("status") or "").strip().lower() + classification = str(capture_result.get("classification") or "").strip().lower() + if status in {"captured", "ignored"}: + return "execute" + if status == "answered" or classification in {"question", "qa"}: + return "qa" + if status == "error" or classification == "error": + return "error" + return "qa" + + @staticmethod + def _build_error_reply_text() -> str: + return "系统处理中出现异常,本次未执行。请稍后重试,或补充更明确的目标、对象和动作。" + + @staticmethod + def _emit_state_transition(trace_id: str, state: str, **payload: Any) -> None: + safe_trace = str(trace_id or "unknown") + safe_state = str(state or "unknown") + details = ", ".join([f"{k}={v}" for k, v in payload.items()]) if payload else "" + print(f"[Feishu-SM][{safe_trace}] state={safe_state}{', ' + details if details else ''}") + diff --git a/backend/app/services/feishu_integration_service/mixin_reply_guard.py b/backend/app/services/feishu_integration_service/mixin_reply_guard.py new file mode 100644 index 00000000..c2499443 --- /dev/null +++ b/backend/app/services/feishu_integration_service/mixin_reply_guard.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +from .shared import * + +class FeishuReplyGuardMixin: + @staticmethod + def _detect_forbidden_execute_keyword(text: Optional[str]) -> Optional[str]: + lowered = str(text or "").strip().lower() + if not lowered: + return None + forbidden = ( + "curl", + "调用方式", + "接口教程", + "api 调用", + "请求示例", + "postman", + "sdk 调用", + ) + for token in forbidden: + if token in lowered: + return token + return None + + @staticmethod + def _detect_forbidden_qa_keyword(text: Optional[str]) -> Optional[str]: + lowered = str(text or "").strip().lower() + if not lowered: + return None + forbidden = ( + "模型失败了所以没分析", + "让我直接处理", + "直接操作数据库", + "先检查数据库", + "检查数据库迁移", + "数据库迁移状态", + "alembic", + "ddl", + "alter table", + "drop table", + "create table", + "truncate table", + ) + for token in forbidden: + if token in lowered: + return token + return None + + @classmethod + def _contains_forbidden_execute_keywords(cls, text: Optional[str]) -> bool: + return cls._detect_forbidden_execute_keyword(text) is not None + + def _guard_execute_reply_text_with_reason( + self, + text: Optional[str], + *, + trace_id: str, + ) -> tuple[Optional[str], Optional[str]]: + if not text: + return text, None + hit = self._detect_forbidden_execute_keyword(text) + if not hit: + return text, None + self._emit_state_transition( + trace_id=trace_id, + state="ROUTED", + route="execute_template_guard", + reason="forbidden_keyword_detected", + keyword=hit, + ) + return ( + "已执行完成并入库。如需我继续动作,请直接补充目标、对象和时限。", + hit, + ) + + def _guard_execute_reply_text(self, text: Optional[str], *, trace_id: str) -> Optional[str]: + sanitized, _ = self._guard_execute_reply_text_with_reason(text=text, trace_id=trace_id) + return sanitized + + def _guard_qa_reply_text_with_reason( + self, + text: Optional[str], + *, + trace_id: str, + capture_result: Dict[str, Any], + ) -> tuple[Optional[str], Optional[str]]: + if not text: + return text, None + hit = self._detect_forbidden_qa_keyword(text) + if not hit: + return text, None + self._emit_state_transition( + trace_id=trace_id, + state="ROUTED", + route="qa_llm_guard", + reason="forbidden_keyword_detected", + keyword=hit, + ) + return self._fallback_reply_text(capture_result), hit + + async def _record_execute_guard_alert( + self, + *, + trace_id: str, + message_id: str, + message_content: str, + capture_result: Dict[str, Any], + guard_reason: str, + ) -> None: + try: + async with AsyncSessionLocal() as db: + db.add( + AIProcessingLog( + tenant_id=int(self.tenant_id or 1), + feishu_message_id=(message_id or "")[:128] or None, + feishu_item_id=None, + raw_message=(message_content or "")[:8000] or None, + ai_raw_output=json.dumps( + { + "trace_id": trace_id, + "guard_reason": guard_reason, + "status": capture_result.get("status"), + "classification": capture_result.get("classification"), + }, + ensure_ascii=False, + )[:120000], + project_score=None, + signal_confidence=None, + level="execute_guard", + routing_decision="execute_reply_guard_triggered", + routing_reason=f"执行态回复命中禁词({guard_reason}),已切换模板模式。", + reasoning_bullets=[ + "执行态不允许输出调用教程类文本。", + "本次回复已被熔断为固定模板。", + f"trace_id={trace_id}", + ], + processing_status="success", + error_message=None, + duration_ms=0, + ) + ) + recipients = ( + await db.execute( + select(User.id).where( + and_( + User.tenant_id == int(self.tenant_id or 1), + User.is_active.is_(True), + ) + ).limit(5) + ) + ).scalars().all() + await NotificationService(db).create_notifications( + tenant_id=int(self.tenant_id or 1), + user_ids=[int(uid) for uid in recipients], + category="alert", + title="执行态回复熔断已触发", + content=( + f"trace_id={trace_id}\n" + f"message_id={message_id}\n" + f"原因:命中禁词 {guard_reason}\n" + "系统已自动切换为固定模板回复。" + )[:2000], + source="feishu_guard", + entity_type="feishu_message", + entity_id=None, + target_url="/ai-logs", + commit=False, + ) + await db.commit() + except Exception as exc: + print(f"[Feishu] execute guard alert write failed: trace_id={trace_id}, error={exc}") + + @staticmethod + def _is_duplicate_capture_result(result: Dict[str, Any]) -> bool: + if not isinstance(result, dict): + return False + status = str(result.get("status") or "").strip().lower() + if status != "ignored": + return False + reason = str(result.get("reason") or "").strip().lower() + classification = str(result.get("classification") or "").strip().lower() + return reason == "duplicate_filtered" or classification == "duplicate" + + @staticmethod + def _should_reply_to_mention(*, mentioned_bot_names: List[str], message_content: Optional[str]) -> bool: + # 仅在明确 @PMFlow(而不是 @all / @其他成员)时触发自动回复 + normalized_names = {str(x or "").strip().lower() for x in mentioned_bot_names if str(x or "").strip()} + if any(name in {"pmflow", "_user_1"} for name in normalized_names): + return True + + # 兼容文本中的 @ 语法(不同消息类型返回格式不一致) + text = str(message_content or "").strip().lower() + if not text: + return False + if "@pmflow" in text or "@_user_1" in text: + return True + return False + diff --git a/backend/app/services/feishu_integration_service/mixin_reply_llm_core.py b/backend/app/services/feishu_integration_service/mixin_reply_llm_core.py new file mode 100644 index 00000000..9b2a56bc --- /dev/null +++ b/backend/app/services/feishu_integration_service/mixin_reply_llm_core.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +from .shared import * + +class FeishuReplyLLMCoreMixin: + async def _reply_with_active_role_prompt( + self, + *, + chat_id: str, + sender_open_id: Optional[str], + sender_name: str, + message_content: str, + capture_result: Dict[str, Any], + trace_id: Optional[str] = None, + source_message_id: Optional[str] = None, + perf_t0: Optional[float] = None, + ) -> None: + """被 @ 时回复一条有人味的执行反馈。""" + deterministic_time_reply = self._build_time_truth_reply(message_content) + if deterministic_time_reply: + await self._safe_reply_text( + chat_id=chat_id, + mention_open_id=sender_open_id, + mention_name=sender_name, + text=deterministic_time_reply, + trace_id=trace_id, + source_message_id=source_message_id, + perf_t0=perf_t0, + ) + return + + execution_receipt = self._build_execution_receipt_reply( + message_content=message_content, + capture_result=capture_result, + ) + execution_receipt = self._guard_execute_reply_text( + execution_receipt, + trace_id=str(trace_id or f"feishu-sm-{uuid4().hex[:12]}"), + ) + if execution_receipt: + await self._safe_reply_text( + chat_id=chat_id, + mention_open_id=sender_open_id, + mention_name=sender_name, + text=execution_receipt, + trace_id=trace_id, + source_message_id=source_message_id, + perf_t0=perf_t0, + ) + return + + butler_reply = self._build_butler_decision_reply(capture_result=capture_result) + if butler_reply: + await self._safe_reply_text( + chat_id=chat_id, + mention_open_id=sender_open_id, + mention_name=sender_name, + text=butler_reply, + trace_id=trace_id, + source_message_id=source_message_id, + perf_t0=perf_t0, + ) + return + + try: + status = str(capture_result.get("status") or "unknown") + classification = str(capture_result.get("classification") or "unknown") + reason = str(capture_result.get("reason") or "").strip() + ai_trace_id = str(capture_result.get("ai_trace_id") or "").strip() + linked_project_ids = capture_result.get("linked_project_ids", []) + linked_projects_text = ", ".join([str(x) for x in linked_project_ids[:3]]) if isinstance(linked_project_ids, list) else "" + now_cn = datetime.now(CN_TZ) + now_cn_text = ( + f"{now_cn.year}年{now_cn.month}月{now_cn.day}日 " + f"{WEEKDAY_CN[now_cn.weekday()]} {now_cn.strftime('%H:%M:%S')}" + ) + + prompt = PROMPT_FEISHU_HUMANIZED_REPLY.format( + now_cn_text=now_cn_text, + sender_name=sender_name, + user_message=(message_content or "")[:600], + status=status, + classification=classification, + reason=(reason or "无"), + ai_trace_id=(ai_trace_id or "无"), + linked_projects_text=(linked_projects_text or "无"), + ) + + llm = LLMService() + raw_reply = await llm.generate( + prompt, + db=self.db, + tenant_id=self.tenant_id, + function_name="feishu_humanized_reply", + temperature=0.4, + max_tokens=180, + ) + reply_text = self._clean_reply_text(raw_reply) + if not self._is_capability_question(message_content): + reply_text = self._strip_identity_lead(reply_text) + if (not reply_text) or self._looks_like_provider_error(reply_text): + reply_text = self._fallback_reply_text(capture_result) + except Exception as exc: + print(f"[Feishu] generate mention reply failed: {exc}") + reply_text = self._fallback_reply_text(capture_result) + reply_text, _ = self._guard_qa_reply_text_with_reason( + reply_text, + trace_id=str(trace_id or f"feishu-sm-{uuid4().hex[:12]}"), + capture_result=capture_result, + ) + + await self._safe_reply_text( + chat_id=chat_id, + mention_open_id=sender_open_id, + mention_name=sender_name, + text=reply_text, + trace_id=trace_id, + source_message_id=source_message_id, + perf_t0=perf_t0, + ) + + @staticmethod + def _clean_reply_text(value: Optional[str]) -> str: + text = str(value or "").strip() + if text.startswith("```"): + text = text.replace("```", "").strip() + if text.lower().startswith("json"): + text = text[4:].strip() + return text[:500] + + @staticmethod + def _is_capability_question(message_content: Optional[str]) -> bool: + normalized = re.sub(r"\s+", "", str(message_content or "").lower()) + if not normalized: + return False + capability_tokens = ( + "你是谁", + "你是什么", + "你能做什么", + "你可以做什么", + "系统能力", + "能力介绍", + "介绍下你", + ) + return any(token in normalized for token in capability_tokens) + + @classmethod + def _build_capability_reply(cls, message_content: Optional[str]) -> Optional[str]: + if not cls._is_capability_question(message_content): + return None + return ( + "我是 PMFlow 的项目协同助手。" + "我可以识别需求并入库信号、关联项目和任务、跟进状态并回答项目问题。" + "你直接说目标、对象和期望动作即可。" + ) + + @staticmethod + def _strip_identity_lead(reply_text: str) -> str: + text = str(reply_text or "").strip() + if not text: + return text + lowered = text.lower() + forbidden_prefixes = ( + "我是", + "我可以", + "我现在", + "是的,我现在", + "可以在这里和我", + ) + forbidden_contains = ( + "通过 feishu", + "群聊接入 openclaw", + "你可以直接在这里和我对话", + ) + if any(lowered.startswith(prefix) for prefix in forbidden_prefixes) or any(token in lowered for token in forbidden_contains): + return "" + return text + + @staticmethod + def _looks_like_provider_error(text: str) -> bool: + lowered = str(text or "").strip().lower() + if not lowered: + return False + error_signals = ( + "[error]", + "request was aborted", + "request aborted", + "connection aborted", + "operation aborted", + "context canceled", + "context cancelled", + "cancelled", + "canceled", + "rate limit", + "too many requests", + "429", + "quota", + "insufficient_quota", + "insufficient quota", + "api key", + "authentication failed", + "service unavailable", + "upstream error", + "upstream request", + "bad gateway", + "gateway timeout", + "internal server error", + "connection reset", + "econnreset", + "socket hang up", + ) + if any(flag in lowered for flag in error_signals): + return True + + if len(lowered) <= 160: + compact = lowered.replace("\n", " ").strip() + ascii_chars = sum(1 for ch in compact if ord(ch) < 128) + ascii_ratio = ascii_chars / max(1, len(compact)) + generic_error_tokens = ("error", "failed", "abort", "timeout", "exception") + if ascii_ratio >= 0.9 and any(token in compact for token in generic_error_tokens): + return True + + return False + + @staticmethod + def _fallback_reply_text(capture_result: Dict[str, Any]) -> str: + status = str(capture_result.get("status") or "") + classification = str(capture_result.get("classification") or "") + if status == "captured": + if classification in {"project", "new_project"}: + return "我已收到需求并完成初步识别,已按项目级信息入队处理。你可以继续补充目标、范围和时间,我会进一步细化分派。" + return "我已收到需求并完成初步识别,已进入信号处理流程。若你希望我直接分派任务,请补充目标、优先级和期望时间。" + return "我已收到消息并开始处理。若你希望我直接分派任务,请补充目标、优先级和时间要求。" + + @staticmethod + def _build_time_truth_reply(message_content: Optional[str]) -> Optional[str]: + text = str(message_content or "").strip().lower() + if not text: + return None + time_keywords = ("今天", "日期", "时间", "几号", "几点", "星期", "周几", "当前日期") + if not any(keyword in text for keyword in time_keywords): + return None + + now_cn = datetime.now(CN_TZ) + today_text = ( + f"{now_cn.year}年{now_cn.month}月{now_cn.day}日" + f"({WEEKDAY_CN[now_cn.weekday()]}){now_cn.strftime('%H:%M:%S')}" + ) + if "2023年11月7日" in text or "2023-11-07" in text: + return ( + f"我这边已校准为北京时间 {today_text}。" + "你看到“2023年11月7日”通常是客户端缓存或本机时区异常," + "建议刷新会话并检查系统时区是否为 Asia/Shanghai。" + ) + return ( + f"我这边当前北京时间是 {today_text}。" + "如果你的界面显示不一致,优先检查设备时区和飞书客户端缓存。" + ) + diff --git a/backend/app/services/feishu_integration_service/mixin_reply_outbox.py b/backend/app/services/feishu_integration_service/mixin_reply_outbox.py new file mode 100644 index 00000000..84955b9b --- /dev/null +++ b/backend/app/services/feishu_integration_service/mixin_reply_outbox.py @@ -0,0 +1,297 @@ +from __future__ import annotations + +from .shared import * + +class FeishuReplyOutboxMixin: + async def _safe_reply_text( + self, + *, + chat_id: str, + text: str, + mention_open_id: Optional[str] = None, + mention_name: Optional[str] = None, + trace_id: Optional[str] = None, + source_message_id: Optional[str] = None, + perf_t0: Optional[float] = None, + ) -> None: + try: + plain_text = self._normalize_outgoing_reply_text(text) + mention_prefix = self._build_mention_prefix( + open_id=mention_open_id, + display_name=mention_name, + ) + final_text = f"{mention_prefix}{plain_text}".strip() + outbox_id = await self._enqueue_reply_outbox( + chat_id=chat_id, + message_text=final_text, + trace_id=trace_id, + source_message_id=source_message_id, + ) + perf_t1 = time.perf_counter() + if outbox_id > 0 and perf_t0 is not None: + _OUTBOX_PERF_T0_BY_ID[int(outbox_id)] = float(perf_t0) + if perf_t0 is not None: + print( + f"[PERF] outbox_written ts={now_cn_naive().isoformat()} T1={perf_t1:.6f} " + f"delta={perf_t1 - perf_t0:.2f}s " + f"outbox_id={outbox_id}" + ) + else: + print( + f"[PERF] outbox_written ts={now_cn_naive().isoformat()} T1={perf_t1:.6f} " + f"delta=N/A " + f"outbox_id={outbox_id}" + ) + except Exception as exc: + print(f"[Feishu] enqueue reply outbox failed: chat_id={chat_id}, error={exc}") + # 统一由 drain_loop 消费 outbox,禁止业务路径直发,避免双发送。 + return + + async def _enqueue_reply_outbox( + self, + *, + chat_id: str, + message_text: str, + trace_id: Optional[str], + source_message_id: Optional[str], + ) -> int: + async with AsyncSessionLocal() as db: + normalized_source_message_id = (source_message_id or "")[:128] or None + normalized_message_text = (message_text or "")[:2000] or "已收到,正在处理。" + reply_hash = self._build_reply_hash( + source_message_id=normalized_source_message_id, + message_text=normalized_message_text, + ) + if reply_hash: + sent_exists = ( + await db.execute( + select(FeishuReplyOutbox.id) + .where( + FeishuReplyOutbox.tenant_id == int(self.tenant_id or 1), + FeishuReplyOutbox.reply_hash == reply_hash, + FeishuReplyOutbox.status == "sent", + ) + .limit(1) + ) + ).scalar_one_or_none() + if sent_exists is not None: + return int(sent_exists) + row = FeishuReplyOutbox( + tenant_id=int(self.tenant_id or 1), + trace_id=(trace_id or "")[:64] or None, + source_message_id=normalized_source_message_id, + chat_id=(chat_id or "")[:128], + message_text=normalized_message_text, + reply_hash=reply_hash, + status="pending", + attempts=0, + next_retry_at=None, + ) + db.add(row) + try: + await db.flush() + await self._notify_outbox_ready(db, outbox_id=int(row.id) if row.id is not None else None) + await db.commit() + except IntegrityError: + await db.rollback() + if reply_hash: + sent_exists = ( + await db.execute( + select(FeishuReplyOutbox.id) + .where( + FeishuReplyOutbox.tenant_id == int(self.tenant_id or 1), + FeishuReplyOutbox.reply_hash == reply_hash, + FeishuReplyOutbox.status == "sent", + ) + .limit(1) + ) + ).scalar_one_or_none() + if sent_exists is not None: + return int(sent_exists) + raise + await db.refresh(row) + return int(row.id) + + @classmethod + async def dispatch_pending_outbox_entries(cls, *, limit: int = 30) -> int: + fetch_limit = max(1, min(200, int(limit))) + dispatched = 0 + for _ in range(fetch_limit): + claimed = await cls._claim_next_outbox_entry(now=now_cn_naive()) + if claimed is None: + break + await cls._dispatch_claimed_outbox_entry(claimed=claimed) + dispatched += 1 + return dispatched + + @classmethod + async def _claim_next_outbox_entry(cls, *, now: datetime) -> Optional[dict[str, Any]]: + stale_before = now - timedelta(seconds=OUTBOX_STALE_SENDING_SECONDS) + async with AsyncSessionLocal() as db: + await db.execute( + update(FeishuReplyOutbox) + .where( + FeishuReplyOutbox.status == "sending", + FeishuReplyOutbox.updated_at <= stale_before, + ) + .values(status="retrying", next_retry_at=now) + ) + candidate_id = ( + await db.execute( + select(FeishuReplyOutbox.id) + .where( + FeishuReplyOutbox.status.in_(("pending", "retrying")), + or_( + FeishuReplyOutbox.next_retry_at.is_(None), + FeishuReplyOutbox.next_retry_at <= now, + ), + ) + .order_by(FeishuReplyOutbox.id.asc()) + .with_for_update(skip_locked=True) + .limit(1) + ) + ).scalar_one_or_none() + if candidate_id is None: + await db.commit() + return None + + claimed = ( + await db.execute( + update(FeishuReplyOutbox) + .where( + FeishuReplyOutbox.id == int(candidate_id), + FeishuReplyOutbox.status.in_(("pending", "retrying")), + or_( + FeishuReplyOutbox.next_retry_at.is_(None), + FeishuReplyOutbox.next_retry_at <= now, + ), + ) + .values( + status="sending", + attempts=FeishuReplyOutbox.attempts + 1, + last_error=None, + next_retry_at=None, + ) + .returning( + FeishuReplyOutbox.id, + FeishuReplyOutbox.tenant_id, + FeishuReplyOutbox.chat_id, + FeishuReplyOutbox.message_text, + FeishuReplyOutbox.reply_hash, + FeishuReplyOutbox.attempts, + ) + ) + ).mappings().first() + await db.commit() + if claimed is None: + return None + return dict(claimed) + + @classmethod + async def _dispatch_claimed_outbox_entry(cls, *, claimed: dict[str, Any]) -> None: + outbox_id = int(claimed.get("id") or 0) + if outbox_id <= 0: + return + tenant_id = int(claimed.get("tenant_id") or 1) + reply_hash = str(claimed.get("reply_hash") or "").strip() + if reply_hash: + async with AsyncSessionLocal() as db_check: + sent_duplicate = ( + await db_check.execute( + select(FeishuReplyOutbox.id) + .where( + FeishuReplyOutbox.id != outbox_id, + FeishuReplyOutbox.tenant_id == tenant_id, + FeishuReplyOutbox.reply_hash == reply_hash, + FeishuReplyOutbox.status == "sent", + ) + .limit(1) + ) + ).scalar_one_or_none() + if sent_duplicate is not None: + async with AsyncSessionLocal() as db_skip: + row = ( + await db_skip.execute( + select(FeishuReplyOutbox).where(FeishuReplyOutbox.id == outbox_id) + ) + ).scalar_one_or_none() + if row is not None and str(row.status or "").lower() == "sending": + row.status = "sent" + row.sent_at = now_cn_naive() + row.next_retry_at = None + row.last_error = f"dedupe_skip_sent:{int(sent_duplicate)}" + await db_skip.commit() + _OUTBOX_PERF_T0_BY_ID.pop(outbox_id, None) + return + + send_error: Optional[str] = None + try: + async with AsyncSessionLocal() as db_send: + service = FeishuIntegrationService(db_send) + await service.load_config(tenant_id=int(claimed.get("tenant_id") or 1)) + await service.send_message_to_group( + chat_id=str(claimed.get("chat_id") or ""), + message=str(claimed.get("message_text") or ""), + ) + except Exception as exc: + send_error = str(exc) + + async with AsyncSessionLocal() as db_update: + row = ( + await db_update.execute( + select(FeishuReplyOutbox).where(FeishuReplyOutbox.id == outbox_id) + ) + ).scalar_one_or_none() + if row is None: + return + if str(row.status or "").lower() != "sending": + return + attempts = int(row.attempts or claimed.get("attempts") or 0) + if send_error is None: + row.status = "sent" + row.sent_at = now_cn_naive() + row.next_retry_at = None + row.last_error = None + await db_update.commit() + perf_t2 = time.perf_counter() + perf_t0 = _OUTBOX_PERF_T0_BY_ID.pop(outbox_id, None) + if perf_t0 is not None: + print( + f"[PERF] reply_dispatched ts={now_cn_naive().isoformat()} T2={perf_t2:.6f} " + f"delta={perf_t2 - perf_t0:.2f}s " + f"outbox_id={outbox_id}" + ) + else: + print( + f"[PERF] reply_dispatched ts={now_cn_naive().isoformat()} T2={perf_t2:.6f} " + f"delta=N/A " + f"outbox_id={outbox_id}" + ) + return + if attempts >= OUTBOX_MAX_ATTEMPTS: + row.status = "failed" + row.next_retry_at = None + _OUTBOX_PERF_T0_BY_ID.pop(outbox_id, None) + else: + row.status = "retrying" + delay_seconds = min(30, 2 * (2 ** max(0, attempts - 1))) + row.next_retry_at = now_cn_naive() + timedelta(seconds=delay_seconds) + row.last_error = (send_error or "unknown_send_error")[:1000] + await db_update.commit() + + @classmethod + def _normalize_outgoing_reply_text(cls, value: Optional[str]) -> str: + text = cls._clean_reply_text(value) + if (not text) or cls._looks_like_provider_error(text): + return cls._build_error_reply_text() + return text[:500] + + @staticmethod + def _build_reply_hash(*, source_message_id: Optional[str], message_text: str) -> Optional[str]: + source = str(source_message_id or "").strip() + body = str(message_text or "").strip() + if not source or not body: + return None + raw = f"{source}{body}".encode("utf-8") + return hashlib.md5(raw, usedforsecurity=False).hexdigest() + diff --git a/backend/app/services/feishu_integration_service/mixin_reply_templates.py b/backend/app/services/feishu_integration_service/mixin_reply_templates.py new file mode 100644 index 00000000..cd20a90e --- /dev/null +++ b/backend/app/services/feishu_integration_service/mixin_reply_templates.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from .shared import * + +class FeishuReplyTemplatesMixin: + @staticmethod + def _build_butler_decision_reply(capture_result: Dict[str, Any]) -> Optional[str]: + if not isinstance(capture_result, dict): + return None + status = str(capture_result.get("status") or "").strip().lower() + if status != "captured": + return None + + results = capture_result.get("results") + if not isinstance(results, list) or not results: + return None + + auto_results = [row for row in results if str((row or {}).get("route_status") or "") == "auto_processed"] + pending_results = [row for row in results if str((row or {}).get("route_status") or "") == "pending"] + classification = str(capture_result.get("classification") or "signal").strip().lower() + signal_id = str(capture_result.get("signal_id") or "").strip() + linked_project_ids = capture_result.get("linked_project_ids") or [] + project_hint = "" + if isinstance(linked_project_ids, list) and linked_project_ids: + project_hint = f",并关联项目 #{linked_project_ids[0]}" + + if pending_results: + if classification in {"project", "new_project"}: + return ( + "我已先完成项目候选入队,当前有不确定项需要你确认:" + "1) 归属项目/是否新建 2) 优先级 3) 截止时间。" + "确认后我会自动继续推进。" + ) + return ( + "我已先完成信号入队,当前判断存在不确定项(多半是项目归属或范围边界)。" + "请你补充:归属项目、优先级、期望完成时间,我会立即自动续跑。" + ) + + if auto_results: + if classification in {"project", "new_project"}: + return ( + f"已自动完成项目流处理{project_hint}。" + "后续我会继续按管家流程拆解与分派,除非你要求人工接管。" + ) + if signal_id: + return ( + f"已自动完成信号流处理(信号#{signal_id}{project_hint})。" + "这是明确需求场景,我会继续自动推进后续动作。" + ) + return "已自动完成信号流处理。后续我会继续自动推进,只有不确定项才会向你确认。" + + return None + + @staticmethod + def _build_execution_receipt_reply( + *, + message_content: Optional[str], + capture_result: Dict[str, Any], + ) -> Optional[str]: + """ + 对“已执行”场景优先给简短回执,避免 LLM 输出教程式长文或二次处理建议。 + """ + if not isinstance(capture_result, dict): + return None + status = str(capture_result.get("status") or "").strip().lower() + if status not in {"captured", "ignored"}: + return None + + if status == "ignored": + reason = str(capture_result.get("reason") or "").strip().lower() + if reason == "duplicate_filtered": + return "这条消息我已处理过,未重复入库。" + if reason in {"non_requirement_noise", "empty_or_emoji_only"}: + return "这条消息不是可执行需求,我没有入库。请直接说要新增什么信号或任务。" + return "这条消息已忽略(未入库)。如需执行,请明确目标、对象和动作。" + + text = str(message_content or "").strip() + strict_keywords = ("单独信号源", "单个信号源", "只入库", "仅入库", "不需要二次处理") + strict_mode = any(k in text for k in strict_keywords) + primary_route_status = "" + results = capture_result.get("results") + if isinstance(results, list) and results: + first = results[0] if isinstance(results[0], dict) else {} + primary_route_status = str((first or {}).get("route_status") or "").strip().lower() + + if primary_route_status == "auto_done" or primary_route_status == "auto_processed": + return "已自动处理完成并入库,后续动作已继续自动推进。" + if primary_route_status == "confirmed": + return "已按确认结果执行并入库。" + if primary_route_status in {"queued_for_decision", "pending"}: + clarification_message = str(capture_result.get("clarification_message") or "").strip() + if clarification_message: + return clarification_message + return "已完成入库,当前进入待确认队列。" + if primary_route_status == "dismissed": + return "该消息已记录,但当前标记为忽略,不会继续执行。" + if primary_route_status == "failed": + return "未执行:受控执行器拒绝了这次动作,请补充缺失字段或检查项目上下文。" + if primary_route_status == "undone": + return "上一次自动结果已撤销,当前不会继续执行。" + + signal_id = str(capture_result.get("signal_id") or "").strip() + linked_project_ids = capture_result.get("linked_project_ids") + project_id: Optional[str] = None + if isinstance(linked_project_ids, list) and linked_project_ids: + project_id = str(linked_project_ids[0]).strip() or None + if not project_id: + candidate = capture_result.get("created_project_id") + if candidate is not None: + project_id = str(candidate).strip() or None + + if signal_id and project_id: + if strict_mode: + return f"已按你的要求仅执行入库:信号#{signal_id},项目#{project_id}。" + return f"已执行完成:信号#{signal_id} 已入库并关联项目#{project_id}。" + if signal_id: + if strict_mode: + return f"已按你的要求仅执行入库:信号#{signal_id}。" + return f"已执行完成:信号#{signal_id} 已入库。" + if project_id: + if strict_mode: + return f"已按你的要求执行:项目#{project_id} 已关联。" + return f"已执行完成:已关联项目#{project_id}。" + return "已执行完成。" + diff --git a/backend/app/services/feishu_integration_service/mixin_sender_resolution.py b/backend/app/services/feishu_integration_service/mixin_sender_resolution.py new file mode 100644 index 00000000..293bb9df --- /dev/null +++ b/backend/app/services/feishu_integration_service/mixin_sender_resolution.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from .shared import * + +class FeishuSenderResolutionMixin: + @staticmethod + def _build_mention_prefix(*, open_id: Optional[str], display_name: Optional[str]) -> str: + user_open_id = str(open_id or "").strip() + if not user_open_id: + return "" + safe_name = str(display_name or "你").replace("<", "").replace(">", "").strip()[:24] or "你" + return f'{safe_name} ' + + async def _resolve_sender_display_name( + self, + *, + sender: Dict[str, Any], + sender_id: Dict[str, Any], + ) -> str: + sender_name = (sender.get("sender_name") or "").strip() + if sender_name: + return sender_name + for key in ("name", "en_name"): + value = (sender.get(key) or "").strip() + if value: + return value + + open_id = (sender_id.get("open_id") or "").strip() + if open_id: + remote_name = await self._fetch_user_name_by_open_id(open_id) + if remote_name: + return remote_name + return f"飞书用户({open_id[:8]})" + + user_id = (sender_id.get("user_id") or "").strip() + if user_id: + return f"飞书用户({user_id[:8]})" + return "未知用户" + + async def _fetch_user_name_by_open_id(self, open_id: str) -> Optional[str]: + safe_open_id = (open_id or "").strip() + if not safe_open_id: + return None + try: + access_token = await self._get_tenant_access_token() + except Exception as exc: + print(f"[Feishu] fetch user name skipped: token unavailable: {exc}") + return None + + url = f"https://open.feishu.cn/open-apis/contact/v3/users/{safe_open_id}" + headers = {"Authorization": f"Bearer {access_token}"} + params = {"user_id_type": "open_id"} + try: + async with httpx.AsyncClient(timeout=10.0, trust_env=False) as client: + resp = await client.get(url, headers=headers, params=params) + resp.raise_for_status() + payload = resp.json() or {} + if payload.get("code") != 0: + print( + "[Feishu] fetch user name failed: " + f"code={payload.get('code')} msg={payload.get('msg')}" + ) + return None + user = payload.get("data", {}).get("user", {}) if isinstance(payload.get("data", {}), dict) else {} + name = (user.get("name") or user.get("en_name") or "").strip() + return name or None + except Exception as exc: + print(f"[Feishu] fetch user name failed: {exc}") + return None + + @staticmethod + def _extract_message_datetime(message: Dict[str, Any]) -> Optional[datetime]: + if not isinstance(message, dict): + return None + raw = message.get("create_time") or message.get("update_time") + if raw is None: + return None + try: + ts = int(str(raw).strip()) + if ts > 10_000_000_000: + ts = ts / 1000.0 + return datetime.fromtimestamp(ts, tz=CN_TZ) + except Exception: + return None + + @staticmethod + def _extract_mentioned_bot_names(message: Dict[str, Any]) -> List[str]: + if not isinstance(message, dict): + return [] + mentions = message.get("mentions", []) + names: List[str] = [] + if isinstance(mentions, list): + for item in mentions: + if not isinstance(item, dict): + continue + name = str(item.get("name") or "").strip() + if not name: + continue + if name not in names: + names.append(name) + + content = message.get("content") + text = "" + if isinstance(content, dict): + text = str(content.get("text") or "") + elif isinstance(content, str): + try: + parsed = json.loads(content) + if isinstance(parsed, dict): + text = str(parsed.get("text") or "") + else: + text = content + except Exception: + text = content + + if text: + for raw in re.findall(r"]*>([^<]+)", text, flags=re.IGNORECASE): + name = str(raw or "").strip() + if name and name not in names: + names.append(name) + for raw in re.findall(r"@([^\s@]+)", text): + name = str(raw or "").strip() + if name and name not in names: + names.append(name) + return names + diff --git a/backend/app/services/feishu_integration_service/mixin_trace.py b/backend/app/services/feishu_integration_service/mixin_trace.py new file mode 100644 index 00000000..3701d273 --- /dev/null +++ b/backend/app/services/feishu_integration_service/mixin_trace.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from .shared import * + +class FeishuTraceMixin: + async def _record_ingress_trace( + self, + *, + ingress_source: str, + trace_id: str, + message_id: str, + event: Dict[str, Any], + message_content: str, + ) -> None: + try: + message = event.get("message", {}) if isinstance(event.get("message", {}), dict) else {} + chat = event.get("chat", {}) if isinstance(event.get("chat", {}), dict) else {} + sender = event.get("sender", {}) if isinstance(event.get("sender", {}), dict) else {} + sender_id = sender.get("sender_id", {}) if isinstance(sender.get("sender_id", {}), dict) else {} + sender_name = ( + str(sender.get("sender_name") or sender.get("name") or "").strip() + or str(sender_id.get("open_id") or "").strip() + or "unknown" + ) + chat_id = str(chat.get("chat_id") or message.get("chat_id") or "").strip() or None + chat_type = str(chat.get("chat_type") or message.get("chat_type") or "").strip() or None + + async with AsyncSessionLocal() as db: + db.add( + AIProcessingLog( + tenant_id=int(self.tenant_id or 1), + feishu_message_id=(message_id or "")[:128] or None, + feishu_item_id=None, + raw_message=(message_content or "")[:8000] or None, + ai_raw_output=json.dumps( + { + "trace_id": trace_id, + "ingress_source": ingress_source, + "event_type": str(event.get("type") or "")[:120], + "chat_id": chat_id, + "chat_type": chat_type, + "sender_name": sender_name[:200], + }, + ensure_ascii=False, + )[:120000], + project_score=None, + signal_confidence=None, + level="ingress", + normalized_text=(message_content or "")[:8000] or None, + route=ingress_source[:80], + intent=None, + executor="feishu_ingress", + operation_code=None, + operation_result={ + "message_type": str(message.get("message_type") or "")[:80], + "chat_id": chat_id, + "chat_type": chat_type, + "sender_name": sender_name[:200], + }, + reply_source=None, + routing_decision="event_received", + routing_reason=f"Feishu ingress via {ingress_source}", + reasoning_bullets=[ + f"trace_id={trace_id}", + f"message_id={message_id}", + f"source={ingress_source}", + ], + processing_status="success", + error_message=None, + duration_ms=0, + ) + ) + await db.commit() + except Exception as exc: + print(f"[Feishu] ingress trace write failed: trace_id={trace_id}, error={exc}") + + async def _record_message_trace( + self, + *, + trace_id: str, + message_id: str, + chat_type: str, + message_content: str, + capture_result: Dict[str, Any], + reply_source: str, + ) -> None: + try: + async with AsyncSessionLocal() as db: + db.add( + AIProcessingLog( + tenant_id=int(self.tenant_id or 1), + feishu_message_id=(message_id or "")[:128] or None, + feishu_item_id=None, + raw_message=(str(capture_result.get("normalized_text") or message_content) or "")[:8000] or None, + ai_raw_output=json.dumps( + { + "trace_id": trace_id, + "chat_type": chat_type, + "normalized_text": str(capture_result.get("normalized_text") or message_content)[:2000], + "route": capture_result.get("route"), + "db_mutations": capture_result.get("db_mutations") or [], + "reply_source": reply_source or "none", + "reply_text": str(capture_result.get("direct_reply_text") or "")[:500], + }, + ensure_ascii=False, + )[:120000], + project_score=None, + signal_confidence=None, + level="reply_trace", + routing_decision=str(capture_result.get("route") or "reply_trace")[:64], + routing_reason="飞书消息 reply trace", + reasoning_bullets=[ + f"chat_type={chat_type}", + f"reply_source={reply_source or 'none'}", + f"trace_id={trace_id}", + ], + processing_status="success", + error_message=None, + duration_ms=0, + ) + ) + await db.commit() + except Exception as exc: + print(f"[Feishu] message trace write failed: trace_id={trace_id}, error={exc}") + diff --git a/backend/app/services/feishu_integration_service/mixin_transport.py b/backend/app/services/feishu_integration_service/mixin_transport.py new file mode 100644 index 00000000..f10c2975 --- /dev/null +++ b/backend/app/services/feishu_integration_service/mixin_transport.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +from .shared import * + +class FeishuTransportMixin: + async def send_message_to_group( + self, + chat_id: str, + message: str + ) -> Dict[str, Any]: + """ + 发送消息到飞书群聊 + + 用于推送通知、简报等 + """ + if not self.is_configured(): + raise ValueError("飞书未配置") + + # 获取 tenant_access_token + access_token = await self._get_tenant_access_token() + + # 发送消息 API + url = "https://open.feishu.cn/open-apis/im/v1/messages" + headers = { + 'Authorization': f'Bearer {access_token}', + 'Content-Type': 'application/json' + } + + message_data = { + 'receive_id': chat_id, + 'msg_type': 'text', + 'content': json.dumps({'text': message}) + } + + async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client: + resp = await client.post( + url, + headers=headers, + params={'receive_id_type': 'chat_id'}, + json=message_data, + ) + payload: Dict[str, Any] + try: + payload = resp.json() + except Exception: + payload = {"http_status": resp.status_code, "raw": resp.text[:500]} + if resp.status_code >= 400: + raise ValueError( + f"飞书发送失败(http={resp.status_code}): {payload}" + ) + if int(payload.get("code", -1)) != 0: + raise ValueError( + f"飞书发送失败: code={payload.get('code')} msg={payload.get('msg') or payload.get('message')}" + ) + return payload + + async def _get_tenant_access_token(self) -> str: + """获取 tenant_access_token""" + if not self.app_id or not self.app_secret: + raise ValueError("飞书未配置") + + url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" + headers = {'Content-Type': 'application/json'} + data = { + 'app_id': self.app_id, + 'app_secret': self.app_secret + } + + async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client: + try: + resp = await client.post(url, headers=headers, json=data) + except httpx.RequestError as exc: + raise ValueError(f"访问飞书 auth 接口失败: {exc}") from exc + + try: + result = resp.json() + except Exception: + result = {"http_status": resp.status_code, "raw": resp.text[:500]} + + if resp.status_code >= 400: + raise ValueError( + f"获取 tenant_access_token 失败(http={resp.status_code}): {result}" + ) + + if result.get('code') == 0 and result.get('tenant_access_token'): + return result.get('tenant_access_token') + + raise ValueError( + "获取 tenant_access_token 失败: " + f"code={result.get('code')} msg={result.get('msg') or result.get('message')} payload={result}" + ) + + async def download_attachment_bytes(self, attachment: Dict[str, Any]) -> Dict[str, Any]: + """通过飞书开放平台代理下载附件。""" + if not self.is_configured(): + raise ValueError("飞书未配置") + access_token = await self._get_tenant_access_token() + attachment_type = str(attachment.get("attachment_type") or "file").strip().lower() + file_name = str(attachment.get("file_name") or "").strip() or None + media_type = str(attachment.get("mime_type") or "").strip() or None + + image_key = str(attachment.get("image_key") or "").strip() + file_key = str(attachment.get("file_key") or "").strip() + media_id = str(attachment.get("media_id") or "").strip() + download_url = str(attachment.get("download_url") or "").strip() + + url = "" + if download_url: + url = download_url + elif attachment_type == "image" and image_key: + url = f"https://open.feishu.cn/open-apis/im/v1/images/{image_key}" + elif file_key: + url = f"https://open.feishu.cn/open-apis/im/v1/files/{file_key}" + elif media_id: + url = f"https://open.feishu.cn/open-apis/im/v1/files/{media_id}" + + if not url: + raise ValueError("附件缺少可下载 key,无法代理下载") + + headers = {"Authorization": f"Bearer {access_token}"} + async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client: + try: + resp = await client.get(url, headers=headers, follow_redirects=True) + except httpx.RequestError as exc: + raise ValueError(f"飞书附件下载请求失败: {exc}") from exc + + if resp.status_code >= 400: + body_preview = resp.text[:300] if resp.text else "" + raise ValueError( + f"飞书附件下载失败(http={resp.status_code}): {body_preview}" + ) + + content = resp.content or b"" + if not content: + raise ValueError("飞书附件下载为空内容") + + response_content_type = str(resp.headers.get("content-type") or "").strip() + content_type = media_type or response_content_type or "application/octet-stream" + return { + "content": content, + "content_type": content_type, + "file_name": file_name, + } + + async def send_rich_card( + self, + chat_id: str, + title: str, + content: List[Dict[str, Any]] + ) -> Dict[str, Any]: + """ + 发送富文本卡片消息 + + 用于发送格式化的简报、通知等 + """ + if not self.is_configured(): + raise ValueError("飞书未配置") + + access_token = await self._get_tenant_access_token() + + url = "https://open.feishu.cn/open-apis/im/v1/messages" + headers = { + 'Authorization': f'Bearer {access_token}', + 'Content-Type': 'application/json' + } + + # 构造卡片内容 + card_content = { + 'config': {'wide_screen_mode': True}, + 'header': { + 'title': { + 'content': title, + 'tag': 'plain_text' + } + }, + 'elements': [] + } + + for item in content: + if item.get('type') == 'text': + card_content['elements'].append({ + 'tag': 'div', + 'text': { + 'content': item.get('text', ''), + 'tag': 'lark_md' + } + }) + elif item.get('type') == 'divider': + card_content['elements'].append({'tag': 'hr'}) + + message_data = { + 'receive_id': chat_id, + 'msg_type': 'interactive', + 'content': json.dumps(card_content) + } + + async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client: + resp = await client.post( + url, + headers=headers, + params={'receive_id_type': 'chat_id'}, + json=message_data, + ) + payload: Dict[str, Any] + try: + payload = resp.json() + except Exception: + payload = {"http_status": resp.status_code, "raw": resp.text[:500]} + if resp.status_code >= 400: + raise ValueError( + f"飞书发送失败(http={resp.status_code}): {payload}" + ) + if int(payload.get("code", -1)) != 0: + raise ValueError( + f"飞书发送失败: code={payload.get('code')} msg={payload.get('msg') or payload.get('message')}" + ) + return payload + diff --git a/backend/app/services/feishu_integration_service/service.py b/backend/app/services/feishu_integration_service/service.py new file mode 100644 index 00000000..33a03497 --- /dev/null +++ b/backend/app/services/feishu_integration_service/service.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from .mixin_config import FeishuConfigMixin +from .mixin_message_payload import FeishuMessagePayloadMixin +from .mixin_receive import FeishuReceiveMixin +from .mixin_receive_routing import FeishuReceiveRoutingMixin +from .mixin_reply_guard import FeishuReplyGuardMixin +from .mixin_reply_outbox import FeishuReplyOutboxMixin +from .mixin_reply_llm_core import FeishuReplyLLMCoreMixin +from .mixin_reply_templates import FeishuReplyTemplatesMixin +from .mixin_sender_resolution import FeishuSenderResolutionMixin +from .mixin_trace import FeishuTraceMixin +from .mixin_transport import FeishuTransportMixin +from .shared import AsyncSession + + +class FeishuIntegrationService( + FeishuConfigMixin, + FeishuReceiveMixin, + FeishuReceiveRoutingMixin, + FeishuTraceMixin, + FeishuReplyLLMCoreMixin, + FeishuReplyTemplatesMixin, + FeishuReplyOutboxMixin, + FeishuReplyGuardMixin, + FeishuSenderResolutionMixin, + FeishuMessagePayloadMixin, + FeishuTransportMixin, +): + """飞书集成服务。""" + + +# 全局服务实例 +_feishu_service = None + + +async def dispatch_pending_feishu_replies(limit: int = 30) -> int: + """后台补偿发送:扫描 pending/retrying outbox 并触发派发。""" + return await FeishuIntegrationService.dispatch_pending_outbox_entries(limit=limit) + + +def get_feishu_service(db: AsyncSession) -> FeishuIntegrationService: + """获取飞书服务实例。""" + return FeishuIntegrationService(db) + + +__all__ = [ + "FeishuIntegrationService", + "dispatch_pending_feishu_replies", + "get_feishu_service", +] diff --git a/backend/app/services/feishu_integration_service/shared.py b/backend/app/services/feishu_integration_service/shared.py new file mode 100644 index 00000000..e42ba839 --- /dev/null +++ b/backend/app/services/feishu_integration_service/shared.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +""" +Feishu Integration Service - 飞书集成服务 + +功能: +1. 接收飞书群聊消息事件 +2. AI 判断消息是否为有价值的信号 +3. 自动创建信号并关联到项目 +4. 推送通知给相关角色 + +飞书开放平台配置: +- App ID: 从飞书开放平台获取 +- App Secret: 从飞书开放平台获取 +- 事件接收: 统一使用长连接模式 +""" +import asyncio +import json +import hashlib +import re +import time +from uuid import uuid4 +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional +import httpx +from sqlalchemy import and_, or_, text, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from ...models import ( + User, + Project, + Signal, + ProjectSignalLink, + Integration, + FeishuReplyOutbox, + AIProcessingLog, +) +from ...database import AsyncSessionLocal +from ...config.ai_prompts import PROMPT_FEISHU_HUMANIZED_REPLY +from ..notification_service import NotificationService +from ..signal_capture import ProactiveSignalCaptureService +from ..llm import LLMService + +from ...utils.timezone import now_cn_naive +CN_TZ = timezone(timedelta(hours=8)) +WEEKDAY_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] +OUTBOX_MAX_ATTEMPTS = 3 +OUTBOX_STALE_SENDING_SECONDS = 120 +_OUTBOX_PERF_T0_BY_ID: dict[int, float] = {} + + diff --git a/backend/app/services/feishu_long_connection.py b/backend/app/services/feishu_long_connection.py index 1ebaadc4..74c4355c 100644 --- a/backend/app/services/feishu_long_connection.py +++ b/backend/app/services/feishu_long_connection.py @@ -1,559 +1,13 @@ -"""Feishu 长连接事件接收管理器。""" - -from __future__ import annotations - -import asyncio -from datetime import datetime, timezone -import importlib.util -import json -import os -import threading -import time -from typing import Any, Optional - -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from sqlalchemy.future import select - -from ..core.logging import get_logger -from ..models import Integration -from .feishu_integration import FeishuIntegrationService - -from ..utils.timezone import CN_TZ, now_cn_aware -logger = get_logger(__name__) -DEFAULT_FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS = 3 -DEFAULT_FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS = 1.0 - - -class FeishuLongConnectionManager: - """管理飞书长连接 SDK 生命周期与事件消费。""" - - def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: - self._session_factory = session_factory - self._loop: Optional[asyncio.AbstractEventLoop] = None - self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() - self._worker_task: Optional[asyncio.Task[None]] = None - self._supervisor_task: Optional[asyncio.Task[None]] = None - self._thread: Optional[threading.Thread] = None - self._client: Any = None - self._boot_lock = asyncio.Lock() - - self._running = False - self._sdk_available = self._check_sdk_available() - self._configured = False - self._event_mode = "long_connection" - self._tenant_id = 1 - self._last_error: Optional[str] = None - self._last_error_code: Optional[str] = None - self._last_error_retryable = False - self._last_event_at: Optional[str] = None - self._last_auth_at: Optional[str] = None - self._last_connect_at: Optional[str] = None - self._last_disconnect_at: Optional[str] = None - self._restart_attempts = 0 - self._next_retry_at = 0.0 - self._stopping = False - self._max_retry_attempts = self._read_retry_max_attempts() - self._retry_base_seconds = self._read_retry_base_seconds() - - async def start(self) -> None: - """启动长连接(若未配置或未安装 SDK 则仅记录状态)。""" - if self._running: - return - - self._stopping = False - self._running = True - self._loop = asyncio.get_running_loop() - self._worker_task = asyncio.create_task( - self._event_worker(), - name="feishu-long-connection-worker", - ) - self._supervisor_task = asyncio.create_task( - self._connection_supervisor(), - name="feishu-long-connection-supervisor", - ) - - started = await self._ensure_connection(reason="startup") - if started: - logger.info("feishu_long_connection_started", tenant_id=self._tenant_id, event_mode=self._event_mode) - - async def stop(self) -> None: - """停止长连接。""" - self._stopping = True - self._running = False - - current_client = self._client - if current_client is not None: - try: - await asyncio.to_thread(self._stop_client_blocking, current_client) - except Exception as exc: # pragma: no cover - logger.warning(f"feishu_long_connection_stop_failed: {exc}") - - self._client = None - - if self._supervisor_task: - self._supervisor_task.cancel() - try: - await self._supervisor_task - except asyncio.CancelledError: - pass - self._supervisor_task = None - - if self._worker_task: - self._worker_task.cancel() - try: - await self._worker_task - except asyncio.CancelledError: - pass - self._worker_task = None - - if self._thread and self._thread.is_alive(): - await asyncio.to_thread(self._thread.join, 3) - self._thread = None - - while not self._queue.empty(): - try: - self._queue.get_nowait() - self._queue.task_done() - except asyncio.QueueEmpty: - break - - logger.info("feishu_long_connection_stopped") - self._stopping = False - - async def restart(self) -> None: - """重启长连接(用于配置变更后重连)。""" - await self.stop() - await self.start() - - def status(self) -> dict[str, Any]: - """返回当前运行状态,供 API 展示。""" - return { - "running": self._running, - "tenant_id": self._tenant_id, - "event_mode": self._event_mode, - "sdk_available": self._sdk_available, - "configured": self._configured, - "worker_alive": bool(self._worker_task and not self._worker_task.done()), - "supervisor_alive": bool(self._supervisor_task and not self._supervisor_task.done()), - "thread_alive": bool(self._thread and self._thread.is_alive()), - "last_error": self._last_error, - "last_error_code": self._last_error_code, - "last_error_retryable": self._last_error_retryable, - "last_event_at": self._last_event_at, - "last_auth_at": self._last_auth_at, - "last_connect_at": self._last_connect_at, - "last_disconnect_at": self._last_disconnect_at, - "restart_attempts": self._restart_attempts, - "next_retry_at": ( - datetime.fromtimestamp(self._next_retry_at, tz=CN_TZ).isoformat() - if self._next_retry_at > 0 - else None - ), - "retry_policy": { - "max_attempts": self._max_retry_attempts, - "base_seconds": self._retry_base_seconds, - }, - } - - def _check_sdk_available(self) -> bool: - # 只做可用性探测,避免在主线程提前 import lark_oapi, - # 否则 SDK 会把全局 event loop 绑定到 uvicorn loop,导致线程内 run_until_complete 报错。 - return importlib.util.find_spec("lark_oapi") is not None - - @staticmethod - def _read_retry_max_attempts() -> int: - raw = os.getenv( - "FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS", - str(DEFAULT_FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS), - ) - try: - parsed = int(str(raw).strip()) - except Exception: - parsed = DEFAULT_FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS - # 强制最多 3 次,避免无限重连雪崩。 - return min(3, max(1, parsed)) - - @staticmethod - def _read_retry_base_seconds() -> float: - raw = os.getenv( - "FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS", - str(DEFAULT_FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS), - ) - try: - parsed = float(str(raw).strip()) - except Exception: - parsed = DEFAULT_FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS - if parsed <= 0: - parsed = DEFAULT_FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS - return parsed - - @staticmethod - def _stop_client_blocking(client: Any) -> None: - stop_fn = getattr(client, "stop", None) - if callable(stop_fn): - stop_fn() - - async def _load_active_config(self) -> Optional[dict[str, Any]]: - async with self._session_factory() as db: - result = await db.execute( - select(Integration).where( - Integration.type == "feishu", - Integration.is_active.is_(True), - ).order_by(Integration.updated_at.desc(), Integration.id.desc()) - ) - integration = result.scalars().first() - if not integration: - return None - - config = integration.config - parsed: dict[str, Any] - if isinstance(config, str): - try: - parsed = json.loads(config) if config else {} - except Exception: - parsed = {} - else: - parsed = config or {} - parsed["tenant_id"] = int(getattr(integration, "tenant_id", 1) or 1) - return parsed - - async def _ensure_connection(self, *, reason: str) -> bool: - async with self._boot_lock: - if not self._running: - return False - if self._thread and self._thread.is_alive(): - return True - if self._client is not None: - try: - await asyncio.to_thread(self._stop_client_blocking, self._client) - except Exception as exc: # pragma: no cover - logger.warning(f"feishu_long_connection_stale_client_stop_failed: {exc}") - finally: - self._client = None - - config = await self._load_active_config() - self._event_mode = "long_connection" - self._tenant_id = int((config or {}).get("tenant_id") or 1) - app_id = str((config or {}).get("app_id") or "").strip() - app_secret = str((config or {}).get("app_secret") or "").strip() - self._configured = bool(app_id and app_secret) - - if not self._configured: - self._clear_error() - logger.info("feishu_long_connection_not_configured") - return False - - if not self._sdk_available: - self._set_error("lark_oapi not installed", code="sdk_missing", retryable=False) - logger.warning("feishu_long_connection_sdk_missing: pip install lark-oapi") - return False - - try: - await self._preflight_auth(tenant_id=self._tenant_id) - self._last_auth_at = now_cn_aware().isoformat() - except Exception as exc: - code, retryable = self._classify_error(exc) - self._set_error(str(exc), code=code, retryable=retryable) - if retryable: - self._schedule_retry() - logger.warning( - "feishu_long_connection_preflight_failed", - tenant_id=self._tenant_id, - error_code=code, - retryable=retryable, - reason=reason, - error=str(exc), - ) - return False - - self._clear_error() - self._last_connect_at = now_cn_aware().isoformat() - self._last_disconnect_at = None - self._restart_attempts = 0 - self._next_retry_at = 0.0 - self._start_ws_thread(app_id, app_secret) - return True - - async def _preflight_auth(self, *, tenant_id: int) -> None: - async with self._session_factory() as db: - service = FeishuIntegrationService(db) - await service.load_config(tenant_id=tenant_id) - await service._get_tenant_access_token() - - async def _connection_supervisor(self) -> None: - while self._running: - try: - await asyncio.sleep(3) - except asyncio.CancelledError: - break - - if not self._running or self._stopping: - continue - if not self._configured or self._event_mode != "long_connection": - continue - if not self._sdk_available: - continue - if self._thread and self._thread.is_alive(): - continue - if self._last_error_code and not self._last_error_retryable: - continue - - now = time.time() - if self._next_retry_at > now: - continue - - await self._ensure_connection(reason="supervisor") - - @staticmethod - def _classify_error(exc: Exception) -> tuple[str, bool]: - text = str(exc or "").strip().lower() - if "401" in text or "403" in text or "unauthorized" in text or "invalid app" in text: - return "auth_failed", False - if any(marker in text for marker in ("timed out", "timeout", "econnreset", "connection reset", "network", "dns", "temporary failure")): - return "network_error", True - if "certificate" in text or "ssl" in text or "tls" in text: - return "network_error", True - return "client_error", True - - def _set_error(self, message: str, *, code: str, retryable: bool) -> None: - self._last_error = str(message or "").strip() or None - self._last_error_code = code - self._last_error_retryable = retryable - - def _clear_error(self) -> None: - self._last_error = None - self._last_error_code = None - self._last_error_retryable = False - - def _schedule_retry(self) -> None: - if self._restart_attempts >= self._max_retry_attempts: - self._last_error_retryable = False - self._next_retry_at = 0.0 - logger.error( - "feishu_long_connection_retry_exhausted", - tenant_id=self._tenant_id, - attempts=self._restart_attempts, - max_attempts=self._max_retry_attempts, - last_error_code=self._last_error_code, - last_error=self._last_error, - ) - return - self._restart_attempts += 1 - backoff = self._retry_base_seconds * (2 ** max(0, self._restart_attempts - 1)) - self._next_retry_at = time.time() + backoff - - def _start_ws_thread(self, app_id: str, app_secret: str) -> None: - self._thread = threading.Thread( - target=self._run_ws_client, - args=(app_id, app_secret), - name="feishu-long-connection", - daemon=True, - ) - self._thread.start() - - def _run_ws_client(self, app_id: str, app_secret: str) -> None: - """在独立线程中运行 SDK 客户端(SDK start 是阻塞调用)。""" - try: - import lark_oapi as lark # type: ignore - - builder = lark.EventDispatcherHandler.builder("", "") - register_receive = getattr(builder, "register_p2_im_message_receive_v1", None) - if not callable(register_receive): - raise RuntimeError("当前 lark_oapi 版本不支持 register_p2_im_message_receive_v1") - builder = register_receive(self._on_message_event) - - # 明确注册高频非业务事件,避免 SDK 抛 processor not found 干扰可观测性。 - register_read = getattr(builder, "register_p2_im_message_message_read_v1", None) - if callable(register_read): - builder = register_read(self._on_ignored_event) - register_reaction_create = getattr(builder, "register_p2_im_message_reaction_created_v1", None) - if callable(register_reaction_create): - builder = register_reaction_create(self._on_ignored_event) - register_reaction_delete = getattr(builder, "register_p2_im_message_reaction_deleted_v1", None) - if callable(register_reaction_delete): - builder = register_reaction_delete(self._on_ignored_event) - register_recalled = getattr(builder, "register_p2_im_message_recalled_v1", None) - if callable(register_recalled): - builder = register_recalled(self._on_ignored_event) - - event_handler = builder.build() - self._client = lark.ws.Client( - app_id, - app_secret, - event_handler=event_handler, - log_level=lark.LogLevel.INFO, - ) - self._client.start() - except Exception as exc: # pragma: no cover - code, retryable = self._classify_error(exc) - self._set_error(str(exc), code=code, retryable=retryable) - if retryable and self._running and not self._stopping: - self._schedule_retry() - logger.error( - "feishu_long_connection_client_failed", - tenant_id=self._tenant_id, - error_code=code, - retryable=retryable, - error=str(exc), - ) - finally: - if self._client is not None: - try: - self._stop_client_blocking(self._client) - except Exception as exc: # pragma: no cover - logger.warning(f"feishu_long_connection_client_cleanup_failed: {exc}") - self._client = None - if self._running and not self._stopping: - self._last_disconnect_at = now_cn_aware().isoformat() - if not self._last_error_code: - self._set_error("feishu long connection thread exited", code="thread_exited", retryable=True) - self._schedule_retry() - logger.warning("feishu_long_connection_thread_exited", tenant_id=self._tenant_id) - self._thread = None - - def _on_message_event(self, data: Any) -> None: - """SDK 回调:将事件安全投递到主事件循环。""" - if not self._running or not self._loop: - return - - try: - perf_t0 = time.perf_counter() - payload = self._to_dict(data) - message_id = "" - event_type = "" - try: - event = payload.get("event", {}) if isinstance(payload, dict) else {} - header = payload.get("header", {}) if isinstance(payload, dict) else {} - message = event.get("message", {}) if isinstance(event, dict) else {} - message_id = str(message.get("message_id") or "").strip() - event_type = str( - (header.get("event_type") if isinstance(header, dict) else "") - or payload.get("event_type") - or payload.get("type") - or "" - ).strip() - except Exception: - pass - if isinstance(payload, dict): - payload["_perf_t0"] = float(perf_t0) - print( - f"[PERF] message_received ts={now_cn_aware().isoformat()} T0={perf_t0:.6f} " - f"message_id={message_id or 'unknown'} " - f"event_type={event_type or 'unknown'}" - ) - logger.info( - "feishu_long_connection_event_received: " - f"event_type={event_type or 'unknown'}, message_id={message_id or 'unknown'}" - ) - self._loop.call_soon_threadsafe(self._queue.put_nowait, payload) - except Exception as exc: # pragma: no cover - self._set_error(str(exc), code="enqueue_error", retryable=True) - logger.warning(f"feishu_long_connection_enqueue_failed: {exc}") - - def _on_ignored_event(self, data: Any) -> None: - """显式忽略非业务事件,避免 SDK 报 processor not found。""" - if not self._running: - return - payload = self._to_dict(data) - event_type = "" - try: - header = payload.get("header", {}) if isinstance(payload, dict) else {} - event_type = str( - (header.get("event_type") if isinstance(header, dict) else "") - or payload.get("event_type") - or payload.get("type") - or "" - ).strip() - except Exception: - pass - logger.info(f"feishu_long_connection_event_ignored: event_type={event_type or 'unknown'}") - - async def _event_worker(self) -> None: - while self._running: - try: - payload = await self._queue.get() - except asyncio.CancelledError: - break - - try: - await self._process_payload(payload) - self._last_event_at = now_cn_aware().isoformat() - except Exception as exc: - self._set_error(str(exc), code="event_process_failed", retryable=True) - logger.warning(f"feishu_long_connection_process_failed: {exc}") - finally: - self._queue.task_done() - - async def _process_payload(self, payload: dict[str, Any]) -> None: - event_data = self._extract_event_data(payload) - if not event_data: - logger.info("feishu_long_connection_event_ignored: empty_event") - return - - event_type = str(event_data.get("type") or "").strip() - if event_type and event_type not in {"im.message.receive_v1", "message.receive_v1"}: - logger.info(f"feishu_long_connection_event_skipped: event_type={event_type}") - return - - async with self._session_factory() as db: - service = FeishuIntegrationService(db) - await service.load_config(tenant_id=self._tenant_id) - await service.handle_message_event(event_data) - - def _extract_event_data(self, payload: dict[str, Any]) -> dict[str, Any]: - event_data = payload.get("event") - if not isinstance(event_data, dict): - event_data = {} - - header = payload.get("header") - event_type: Optional[str] = None - if isinstance(header, dict): - event_type = header.get("event_type") - if not event_type: - event_type = payload.get("event_type") or payload.get("type") - - if event_type: - event_data = {**event_data, "type": event_type} - - perf_t0 = payload.get("_perf_t0") - if isinstance(perf_t0, (int, float)): - event_data = {**event_data, "_perf_t0": float(perf_t0)} - - if event_data: - event_data = {**event_data, "_ingress_source": "long_connection"} - - return event_data - - def _to_dict(self, value: Any) -> Any: - if value is None or isinstance(value, (str, int, float, bool)): - return value - if isinstance(value, list): - return [self._to_dict(item) for item in value] - if isinstance(value, dict): - return {k: self._to_dict(v) for k, v in value.items()} - to_dict = getattr(value, "to_dict", None) - if callable(to_dict): - return self._to_dict(to_dict()) - if hasattr(value, "__dict__"): - return { - k: self._to_dict(v) - for k, v in value.__dict__.items() - if not k.startswith("_") - } - return str(value) - - -_manager: Optional[FeishuLongConnectionManager] = None - - -def init_feishu_long_connection_manager( - session_factory: async_sessionmaker[AsyncSession], -) -> FeishuLongConnectionManager: - global _manager - if _manager is None: - _manager = FeishuLongConnectionManager(session_factory) - return _manager - - -def get_feishu_long_connection_manager() -> Optional[FeishuLongConnectionManager]: - return _manager +"""Compatibility exports for Feishu long connection manager.""" + +from .feishu_long_connection_core import ( + FeishuLongConnectionManager, + get_feishu_long_connection_manager, + init_feishu_long_connection_manager, +) + +__all__ = [ + "FeishuLongConnectionManager", + "init_feishu_long_connection_manager", + "get_feishu_long_connection_manager", +] diff --git a/backend/app/services/feishu_long_connection_core/__init__.py b/backend/app/services/feishu_long_connection_core/__init__.py new file mode 100644 index 00000000..ce4425de --- /dev/null +++ b/backend/app/services/feishu_long_connection_core/__init__.py @@ -0,0 +1,10 @@ +"""Feishu long connection service package.""" + +from .manager import FeishuLongConnectionManager +from .registry import get_feishu_long_connection_manager, init_feishu_long_connection_manager + +__all__ = [ + "FeishuLongConnectionManager", + "init_feishu_long_connection_manager", + "get_feishu_long_connection_manager", +] diff --git a/backend/app/services/feishu_long_connection_core/manager.py b/backend/app/services/feishu_long_connection_core/manager.py new file mode 100644 index 00000000..990416ef --- /dev/null +++ b/backend/app/services/feishu_long_connection_core/manager.py @@ -0,0 +1,17 @@ +"""Feishu long connection manager composed by focused behavior mixins.""" + +from __future__ import annotations + +from .mixin_lifecycle_connection import LongConnectionLifecycleConnectionMixin +from .mixin_lifecycle_control import LongConnectionLifecycleControlMixin +from .mixin_runtime import LongConnectionRuntimeMixin + + +class FeishuLongConnectionManager( + LongConnectionLifecycleControlMixin, + LongConnectionLifecycleConnectionMixin, + LongConnectionRuntimeMixin, +): + """Manage Feishu websocket lifecycle and event dispatch.""" + + pass diff --git a/backend/app/services/feishu_long_connection_core/mixin_lifecycle.py b/backend/app/services/feishu_long_connection_core/mixin_lifecycle.py new file mode 100644 index 00000000..3097dbde --- /dev/null +++ b/backend/app/services/feishu_long_connection_core/mixin_lifecycle.py @@ -0,0 +1,9 @@ +"""Deprecated shim for lifecycle mixins.""" + +from .mixin_lifecycle_connection import LongConnectionLifecycleConnectionMixin +from .mixin_lifecycle_control import LongConnectionLifecycleControlMixin + +__all__ = [ + "LongConnectionLifecycleControlMixin", + "LongConnectionLifecycleConnectionMixin", +] diff --git a/backend/app/services/feishu_long_connection_core/mixin_lifecycle_connection.py b/backend/app/services/feishu_long_connection_core/mixin_lifecycle_connection.py new file mode 100644 index 00000000..c10e4dea --- /dev/null +++ b/backend/app/services/feishu_long_connection_core/mixin_lifecycle_connection.py @@ -0,0 +1,179 @@ +"""Connection setup/supervision/retry logic for Feishu long connection manager.""" + +from __future__ import annotations + +from .shared import ( + Any, + FeishuIntegrationService, + Integration, + Optional, + asyncio, + json, + logger, + now_cn_aware, + select, + threading, + time, +) + + +class LongConnectionLifecycleConnectionMixin: + """Owns connection bootstrap, preflight auth, and retry supervision.""" + + async def _load_active_config(self) -> Optional[dict[str, Any]]: + async with self._session_factory() as db: + result = await db.execute( + select(Integration).where( + Integration.type == "feishu", + Integration.is_active.is_(True), + ).order_by(Integration.updated_at.desc(), Integration.id.desc()) + ) + integration = result.scalars().first() + if not integration: + return None + + config = integration.config + parsed: dict[str, Any] + if isinstance(config, str): + try: + parsed = json.loads(config) if config else {} + except Exception: + parsed = {} + else: + parsed = config or {} + parsed["tenant_id"] = int(getattr(integration, "tenant_id", 1) or 1) + return parsed + + async def _ensure_connection(self, *, reason: str) -> bool: + async with self._boot_lock: + if not self._running: + return False + if self._thread and self._thread.is_alive(): + return True + if self._client is not None: + try: + await asyncio.to_thread(self._stop_client_blocking, self._client) + except Exception as exc: # pragma: no cover + logger.warning(f"feishu_long_connection_stale_client_stop_failed: {exc}") + finally: + self._client = None + + config = await self._load_active_config() + self._event_mode = "long_connection" + self._tenant_id = int((config or {}).get("tenant_id") or 1) + app_id = str((config or {}).get("app_id") or "").strip() + app_secret = str((config or {}).get("app_secret") or "").strip() + self._configured = bool(app_id and app_secret) + + if not self._configured: + self._clear_error() + logger.info("feishu_long_connection_not_configured") + return False + + if not self._sdk_available: + self._set_error("lark_oapi not installed", code="sdk_missing", retryable=False) + logger.warning("feishu_long_connection_sdk_missing: pip install lark-oapi") + return False + + try: + await self._preflight_auth(tenant_id=self._tenant_id) + self._last_auth_at = now_cn_aware().isoformat() + except Exception as exc: + code, retryable = self._classify_error(exc) + self._set_error(str(exc), code=code, retryable=retryable) + if retryable: + self._schedule_retry() + logger.warning( + "feishu_long_connection_preflight_failed", + tenant_id=self._tenant_id, + error_code=code, + retryable=retryable, + reason=reason, + error=str(exc), + ) + return False + + self._clear_error() + self._last_connect_at = now_cn_aware().isoformat() + self._last_disconnect_at = None + self._restart_attempts = 0 + self._next_retry_at = 0.0 + self._start_ws_thread(app_id, app_secret) + return True + + async def _preflight_auth(self, *, tenant_id: int) -> None: + async with self._session_factory() as db: + service = FeishuIntegrationService(db) + await service.load_config(tenant_id=tenant_id) + await service._get_tenant_access_token() + + async def _connection_supervisor(self) -> None: + while self._running: + try: + await asyncio.sleep(3) + except asyncio.CancelledError: + break + + if not self._running or self._stopping: + continue + if not self._configured or self._event_mode != "long_connection": + continue + if not self._sdk_available: + continue + if self._thread and self._thread.is_alive(): + continue + if self._last_error_code and not self._last_error_retryable: + continue + + now = time.time() + if self._next_retry_at > now: + continue + + await self._ensure_connection(reason="supervisor") + + @staticmethod + def _classify_error(exc: Exception) -> tuple[str, bool]: + text = str(exc or "").strip().lower() + if "401" in text or "403" in text or "unauthorized" in text or "invalid app" in text: + return "auth_failed", False + if any(marker in text for marker in ("timed out", "timeout", "econnreset", "connection reset", "network", "dns", "temporary failure")): + return "network_error", True + if "certificate" in text or "ssl" in text or "tls" in text: + return "network_error", True + return "client_error", True + + def _set_error(self, message: str, *, code: str, retryable: bool) -> None: + self._last_error = str(message or "").strip() or None + self._last_error_code = code + self._last_error_retryable = retryable + + def _clear_error(self) -> None: + self._last_error = None + self._last_error_code = None + self._last_error_retryable = False + + def _schedule_retry(self) -> None: + if self._restart_attempts >= self._max_retry_attempts: + self._last_error_retryable = False + self._next_retry_at = 0.0 + logger.error( + "feishu_long_connection_retry_exhausted", + tenant_id=self._tenant_id, + attempts=self._restart_attempts, + max_attempts=self._max_retry_attempts, + last_error_code=self._last_error_code, + last_error=self._last_error, + ) + return + self._restart_attempts += 1 + backoff = self._retry_base_seconds * (2 ** max(0, self._restart_attempts - 1)) + self._next_retry_at = time.time() + backoff + + def _start_ws_thread(self, app_id: str, app_secret: str) -> None: + self._thread = threading.Thread( + target=self._run_ws_client, + args=(app_id, app_secret), + name="feishu-long-connection", + daemon=True, + ) + self._thread.start() diff --git a/backend/app/services/feishu_long_connection_core/mixin_lifecycle_control.py b/backend/app/services/feishu_long_connection_core/mixin_lifecycle_control.py new file mode 100644 index 00000000..11ac8009 --- /dev/null +++ b/backend/app/services/feishu_long_connection_core/mixin_lifecycle_control.py @@ -0,0 +1,186 @@ +"""Lifecycle/control layer for Feishu long connection manager.""" + +from __future__ import annotations + +from .shared import ( + Any, + AsyncSession, + CN_TZ, + DEFAULT_FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS, + DEFAULT_FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS, + Optional, + asyncio, + async_sessionmaker, + datetime, + importlib, + logger, + os, + threading, +) + + +class LongConnectionLifecycleControlMixin: + """Owns startup/shutdown and status projection.""" + + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._session_factory = session_factory + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + self._worker_task: Optional[asyncio.Task[None]] = None + self._supervisor_task: Optional[asyncio.Task[None]] = None + self._thread: Optional[threading.Thread] = None + self._client: Any = None + self._boot_lock = asyncio.Lock() + + self._running = False + self._sdk_available = self._check_sdk_available() + self._configured = False + self._event_mode = "long_connection" + self._tenant_id = 1 + self._last_error: Optional[str] = None + self._last_error_code: Optional[str] = None + self._last_error_retryable = False + self._last_event_at: Optional[str] = None + self._last_auth_at: Optional[str] = None + self._last_connect_at: Optional[str] = None + self._last_disconnect_at: Optional[str] = None + self._restart_attempts = 0 + self._next_retry_at = 0.0 + self._stopping = False + self._max_retry_attempts = self._read_retry_max_attempts() + self._retry_base_seconds = self._read_retry_base_seconds() + + async def start(self) -> None: + """Start long connection if enabled/configured.""" + if self._running: + return + + self._stopping = False + self._running = True + self._loop = asyncio.get_running_loop() + self._worker_task = asyncio.create_task( + self._event_worker(), + name="feishu-long-connection-worker", + ) + self._supervisor_task = asyncio.create_task( + self._connection_supervisor(), + name="feishu-long-connection-supervisor", + ) + + started = await self._ensure_connection(reason="startup") + if started: + logger.info("feishu_long_connection_started", tenant_id=self._tenant_id, event_mode=self._event_mode) + + async def stop(self) -> None: + """Stop long connection and clear in-flight queue/tasks.""" + self._stopping = True + self._running = False + + current_client = self._client + if current_client is not None: + try: + await asyncio.to_thread(self._stop_client_blocking, current_client) + except Exception as exc: # pragma: no cover + logger.warning(f"feishu_long_connection_stop_failed: {exc}") + + self._client = None + + if self._supervisor_task: + self._supervisor_task.cancel() + try: + await self._supervisor_task + except asyncio.CancelledError: + pass + self._supervisor_task = None + + if self._worker_task: + self._worker_task.cancel() + try: + await self._worker_task + except asyncio.CancelledError: + pass + self._worker_task = None + + if self._thread and self._thread.is_alive(): + await asyncio.to_thread(self._thread.join, 3) + self._thread = None + + while not self._queue.empty(): + try: + self._queue.get_nowait() + self._queue.task_done() + except asyncio.QueueEmpty: + break + + logger.info("feishu_long_connection_stopped") + self._stopping = False + + async def restart(self) -> None: + """Restart long connection after config change.""" + await self.stop() + await self.start() + + def status(self) -> dict[str, Any]: + """Return manager state for APIs.""" + return { + "running": self._running, + "tenant_id": self._tenant_id, + "event_mode": self._event_mode, + "sdk_available": self._sdk_available, + "configured": self._configured, + "worker_alive": bool(self._worker_task and not self._worker_task.done()), + "supervisor_alive": bool(self._supervisor_task and not self._supervisor_task.done()), + "thread_alive": bool(self._thread and self._thread.is_alive()), + "last_error": self._last_error, + "last_error_code": self._last_error_code, + "last_error_retryable": self._last_error_retryable, + "last_event_at": self._last_event_at, + "last_auth_at": self._last_auth_at, + "last_connect_at": self._last_connect_at, + "last_disconnect_at": self._last_disconnect_at, + "restart_attempts": self._restart_attempts, + "next_retry_at": ( + datetime.fromtimestamp(self._next_retry_at, tz=CN_TZ).isoformat() + if self._next_retry_at > 0 + else None + ), + "retry_policy": { + "max_attempts": self._max_retry_attempts, + "base_seconds": self._retry_base_seconds, + }, + } + + def _check_sdk_available(self) -> bool: + return importlib.util.find_spec("lark_oapi") is not None + + @staticmethod + def _read_retry_max_attempts() -> int: + raw = os.getenv( + "FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS", + str(DEFAULT_FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS), + ) + try: + parsed = int(str(raw).strip()) + except Exception: + parsed = DEFAULT_FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS + return min(3, max(1, parsed)) + + @staticmethod + def _read_retry_base_seconds() -> float: + raw = os.getenv( + "FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS", + str(DEFAULT_FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS), + ) + try: + parsed = float(str(raw).strip()) + except Exception: + parsed = DEFAULT_FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS + if parsed <= 0: + parsed = DEFAULT_FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS + return parsed + + @staticmethod + def _stop_client_blocking(client: Any) -> None: + stop_fn = getattr(client, "stop", None) + if callable(stop_fn): + stop_fn() diff --git a/backend/app/services/feishu_long_connection_core/mixin_runtime.py b/backend/app/services/feishu_long_connection_core/mixin_runtime.py new file mode 100644 index 00000000..44dcf67e --- /dev/null +++ b/backend/app/services/feishu_long_connection_core/mixin_runtime.py @@ -0,0 +1,199 @@ +"""Event callback and payload processing for Feishu long connection manager.""" + +from __future__ import annotations + +from .shared import Any, FeishuIntegrationService, Optional, asyncio, logger, now_cn_aware, time + + +class LongConnectionRuntimeMixin: + """Owns SDK callback/runtime event processing.""" + + def _run_ws_client(self, app_id: str, app_secret: str) -> None: + """Run blocking SDK websocket client in dedicated thread.""" + try: + import lark_oapi as lark # type: ignore + + builder = lark.EventDispatcherHandler.builder("", "") + register_receive = getattr(builder, "register_p2_im_message_receive_v1", None) + if not callable(register_receive): + raise RuntimeError("当前 lark_oapi 版本不支持 register_p2_im_message_receive_v1") + builder = register_receive(self._on_message_event) + + register_read = getattr(builder, "register_p2_im_message_message_read_v1", None) + if callable(register_read): + builder = register_read(self._on_ignored_event) + register_reaction_create = getattr(builder, "register_p2_im_message_reaction_created_v1", None) + if callable(register_reaction_create): + builder = register_reaction_create(self._on_ignored_event) + register_reaction_delete = getattr(builder, "register_p2_im_message_reaction_deleted_v1", None) + if callable(register_reaction_delete): + builder = register_reaction_delete(self._on_ignored_event) + register_recalled = getattr(builder, "register_p2_im_message_recalled_v1", None) + if callable(register_recalled): + builder = register_recalled(self._on_ignored_event) + + event_handler = builder.build() + self._client = lark.ws.Client( + app_id, + app_secret, + event_handler=event_handler, + log_level=lark.LogLevel.INFO, + ) + self._client.start() + except Exception as exc: # pragma: no cover + code, retryable = self._classify_error(exc) + self._set_error(str(exc), code=code, retryable=retryable) + if retryable and self._running and not self._stopping: + self._schedule_retry() + logger.error( + "feishu_long_connection_client_failed", + tenant_id=self._tenant_id, + error_code=code, + retryable=retryable, + error=str(exc), + ) + finally: + if self._client is not None: + try: + self._stop_client_blocking(self._client) + except Exception as exc: # pragma: no cover + logger.warning(f"feishu_long_connection_client_cleanup_failed: {exc}") + self._client = None + if self._running and not self._stopping: + self._last_disconnect_at = now_cn_aware().isoformat() + if not self._last_error_code: + self._set_error("feishu long connection thread exited", code="thread_exited", retryable=True) + self._schedule_retry() + logger.warning("feishu_long_connection_thread_exited", tenant_id=self._tenant_id) + self._thread = None + + def _on_message_event(self, data: Any) -> None: + """SDK callback: safely enqueue into main event loop.""" + if not self._running or not self._loop: + return + + try: + perf_t0 = time.perf_counter() + payload = self._to_dict(data) + message_id = "" + event_type = "" + try: + event = payload.get("event", {}) if isinstance(payload, dict) else {} + header = payload.get("header", {}) if isinstance(payload, dict) else {} + message = event.get("message", {}) if isinstance(event, dict) else {} + message_id = str(message.get("message_id") or "").strip() + event_type = str( + (header.get("event_type") if isinstance(header, dict) else "") + or payload.get("event_type") + or payload.get("type") + or "" + ).strip() + except Exception: + pass + if isinstance(payload, dict): + payload["_perf_t0"] = float(perf_t0) + print( + f"[PERF] message_received ts={now_cn_aware().isoformat()} T0={perf_t0:.6f} " + f"message_id={message_id or 'unknown'} " + f"event_type={event_type or 'unknown'}" + ) + logger.info( + "feishu_long_connection_event_received: " + f"event_type={event_type or 'unknown'}, message_id={message_id or 'unknown'}" + ) + self._loop.call_soon_threadsafe(self._queue.put_nowait, payload) + except Exception as exc: # pragma: no cover + self._set_error(str(exc), code="enqueue_error", retryable=True) + logger.warning(f"feishu_long_connection_enqueue_failed: {exc}") + + def _on_ignored_event(self, data: Any) -> None: + """Ignore non-business events to avoid SDK processor-not-found noise.""" + if not self._running: + return + payload = self._to_dict(data) + event_type = "" + try: + header = payload.get("header", {}) if isinstance(payload, dict) else {} + event_type = str( + (header.get("event_type") if isinstance(header, dict) else "") + or payload.get("event_type") + or payload.get("type") + or "" + ).strip() + except Exception: + pass + logger.info(f"feishu_long_connection_event_ignored: event_type={event_type or 'unknown'}") + + async def _event_worker(self) -> None: + while self._running: + try: + payload = await self._queue.get() + except asyncio.CancelledError: + break + + try: + await self._process_payload(payload) + self._last_event_at = now_cn_aware().isoformat() + except Exception as exc: + self._set_error(str(exc), code="event_process_failed", retryable=True) + logger.warning(f"feishu_long_connection_process_failed: {exc}") + finally: + self._queue.task_done() + + async def _process_payload(self, payload: dict[str, Any]) -> None: + event_data = self._extract_event_data(payload) + if not event_data: + logger.info("feishu_long_connection_event_ignored: empty_event") + return + + event_type = str(event_data.get("type") or "").strip() + if event_type and event_type not in {"im.message.receive_v1", "message.receive_v1"}: + logger.info(f"feishu_long_connection_event_skipped: event_type={event_type}") + return + + async with self._session_factory() as db: + service = FeishuIntegrationService(db) + await service.load_config(tenant_id=self._tenant_id) + await service.handle_message_event(event_data) + + def _extract_event_data(self, payload: dict[str, Any]) -> dict[str, Any]: + event_data = payload.get("event") + if not isinstance(event_data, dict): + event_data = {} + + header = payload.get("header") + event_type: Optional[str] = None + if isinstance(header, dict): + event_type = header.get("event_type") + if not event_type: + event_type = payload.get("event_type") or payload.get("type") + + if event_type: + event_data = {**event_data, "type": event_type} + + perf_t0 = payload.get("_perf_t0") + if isinstance(perf_t0, (int, float)): + event_data = {**event_data, "_perf_t0": float(perf_t0)} + + if event_data: + event_data = {**event_data, "_ingress_source": "long_connection"} + + return event_data + + def _to_dict(self, value: Any) -> Any: + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, list): + return [self._to_dict(item) for item in value] + if isinstance(value, dict): + return {k: self._to_dict(v) for k, v in value.items()} + to_dict = getattr(value, "to_dict", None) + if callable(to_dict): + return self._to_dict(to_dict()) + if hasattr(value, "__dict__"): + return { + k: self._to_dict(v) + for k, v in value.__dict__.items() + if not k.startswith("_") + } + return str(value) diff --git a/backend/app/services/feishu_long_connection_core/registry.py b/backend/app/services/feishu_long_connection_core/registry.py new file mode 100644 index 00000000..3bd8d433 --- /dev/null +++ b/backend/app/services/feishu_long_connection_core/registry.py @@ -0,0 +1,21 @@ +"""Singleton registry for Feishu long connection manager.""" + +from __future__ import annotations + +from .manager import FeishuLongConnectionManager +from .shared import AsyncSession, Optional, async_sessionmaker + +_manager: Optional[FeishuLongConnectionManager] = None + + +def init_feishu_long_connection_manager( + session_factory: async_sessionmaker[AsyncSession], +) -> FeishuLongConnectionManager: + global _manager + if _manager is None: + _manager = FeishuLongConnectionManager(session_factory) + return _manager + + +def get_feishu_long_connection_manager() -> Optional[FeishuLongConnectionManager]: + return _manager diff --git a/backend/app/services/feishu_long_connection_core/shared.py b/backend/app/services/feishu_long_connection_core/shared.py new file mode 100644 index 00000000..bfce99f9 --- /dev/null +++ b/backend/app/services/feishu_long_connection_core/shared.py @@ -0,0 +1,47 @@ +"""Shared imports/constants for Feishu long connection service.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime +import importlib.util +import json +import os +import threading +import time +from typing import Any, Optional + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker +from sqlalchemy.future import select + +from ...core.logging import get_logger +from ...models import Integration +from ...utils.timezone import CN_TZ, now_cn_aware +from ..feishu_integration import FeishuIntegrationService + +logger = get_logger(__name__) + +DEFAULT_FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS = 3 +DEFAULT_FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS = 1.0 + +__all__ = [ + "Any", + "Optional", + "AsyncSession", + "async_sessionmaker", + "asyncio", + "datetime", + "importlib", + "json", + "os", + "threading", + "time", + "select", + "Integration", + "FeishuIntegrationService", + "CN_TZ", + "now_cn_aware", + "logger", + "DEFAULT_FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS", + "DEFAULT_FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS", +] diff --git a/backend/app/services/feishu_signal_pipeline.py b/backend/app/services/feishu_signal_pipeline.py index 2a61b6e4..ae10743c 100644 --- a/backend/app/services/feishu_signal_pipeline.py +++ b/backend/app/services/feishu_signal_pipeline.py @@ -1,6180 +1,5 @@ -"""飞书消息分流与决策队列服务。""" -from __future__ import annotations +"""Feishu signal pipeline compatibility module.""" -import asyncio -import json -import hashlib -import re -import time -from datetime import datetime, timedelta, timezone -from typing import Any, Literal, Optional +from .feishu_signal_pipeline import FeishuSignalPipelineService -from pydantic import BaseModel, Field, ValidationError -from sqlalchemy import and_, delete, func, or_, select, text -from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession - -from ..config.ai_prompts import ( - PROMPT_FEISHU_LEVEL_CLASSIFICATION, - PROMPT_FEISHU_PROJECT_PREFILL, - PROMPT_FEISHU_SIGNAL_EXTRACTION, -) -from ..core.logging import get_logger -from ..models import ( - FeishuItem, - AIProcessingLog, - FeishuReplyOutbox, - Integration, - Project, - ProjectSignalLink, - ProjectSuggestion, - Signal, - SignalEdit, - TruthNode, - User, -) -from .llm import LLMService -from .adaptive_rag_service import AdaptiveRAGService -from .ai_agent_kernel import ( - build_decision_trace, - gate_execution as kernel_gate_execution, - resolve_correction as kernel_resolve_correction, -) -from .context_cache import context_cache_service -from .notification_service import NotificationService -from .operation_log_service import log_operation -from .project_command_utils import ( - extract_project_create_description, - extract_project_create_title, - normalize_project_title_candidate, -) -from .sstc_service import ALIGNED_STATUSES -from .task_persistence import build_task_create_draft, persist_task -from ..utils.project_members import ensure_project_default_members -from ..utils.sequence import ensure_project_id_sequence - -from ..utils.timezone import CN_TZ, now_cn_aware -logger = get_logger(__name__) - -PROJECT_PRIORITY_ORDER = { - "P0": 4, - "P1": 3, - "P2": 2, - "P3": 1, - "high": 3, - "medium": 2, - "low": 1, -} - -SIGNAL_TYPE_OPTIONS = {"竞品动态", "用户反馈", "项目风险", "新需求"} -PROJECT_INTENT_RE = re.compile(r"(立项|新项目|我们要做|需要开发|启动|规划)", re.IGNORECASE) -SIGNAL_FACT_RE = re.compile(r"(竞品|上线|发布|反馈|风险|bug|故障|告警|投诉)", re.IGNORECASE) -TIMELINE_RE = re.compile(r"(\d+\s*(天|周|月)|本周|本月|月底|下周|下月|Q[1-4]|\d{1,2}月\d{1,2}日)") -OWNER_RE = re.compile(r"(由|负责人|owner|owner:|@)(\S+)") -INDEPENDENT_REQUIREMENT_RE = re.compile( - r"(独立需求|单独需求|单开|另起|新增需求|新建项目|新增项目|单独立项|开个新|做个新)", - re.IGNORECASE, -) -SIMPLE_SIGNAL_REQUEST_RE = re.compile( - r"(新增|增加|加一个|加个|补充|支持|实现|接入|优化|修复|改进).{0,18}(功能|能力|接口|页面|按钮|流程|识别|提醒|校验|报表|字段|规则|模块)", - re.IGNORECASE, -) -PROJECT_COMPLEXITY_RE = re.compile( - r"(立项|新项目|项目章程|里程碑|预算|排期|roadmap|跨团队|跨部门|季度目标|年度目标|okr)", - re.IGNORECASE, -) -QUESTION_REQUIREMENT_VERB_RE = re.compile( - r"(新增|增加|加一个|加个|支持|接入|实现|优化|修复|改进|调整|补充|完善|同步|对齐|落库|入库)", - re.IGNORECASE, -) -QUESTION_REQUIREMENT_OBJECT_RE = re.compile( - r"(功能|能力|接口|页面|按钮|流程|校验|报表|字段|规则|模块|任务|项目|需求|通知|提醒|配置|归属|队列|信号)", - re.IGNORECASE, -) -PROJECT_LIST_QUERY_RE = re.compile( - r"(现在|当前|目前|系统里|系统中|项目里|项目中)?.{0,4}(有哪些项目|项目列表|项目清单|哪(些|几个)项目)", - re.IGNORECASE, -) -PROJECT_ID_QUERY_RE = re.compile( - r"项目\s*id\s*[::]?\s*(\d+).{0,12}(有吗|存在吗|还在吗|是否存在|是不是)", - re.IGNORECASE, -) -LEGACY_COMMAND_PROJECT_CREATE_RE = re.compile( - r"(创建|新建|建立|开个|开一个|建个|建一个|做个|做一个).{0,6}项目", - re.IGNORECASE, -) -LEGACY_COMMAND_VERB_RE = re.compile( - r"(创建|新建|建立|开个|开一个|建个|建一个|做个|做一个|修改|更新|改成|改为|设为|设成|分配|指派|归档|重开|重新打开|恢复|标记完成|完成|提醒|通知|调整工时|调整工作量)", - re.IGNORECASE, -) -LEGACY_COMMAND_OBJECT_RE = re.compile( - r"(项目|任务|负责人|优先级|状态|通知|提醒|工时|工作量)", - re.IGNORECASE, -) -FOLLOWUP_CONTINUE_RE = re.compile(r"(继续处理|继续推进|继续执行|接着处理|继续跑)", re.IGNORECASE) -FOLLOWUP_CREATE_CONTINUE_RE = re.compile(r"(继续创建|接着创建|继续建|接着建)", re.IGNORECASE) -FOLLOWUP_STATUS_RE = re.compile( - r"(进度|状态|处理到哪|处理了吗|现在怎么样|怎么样了|结果呢|是否已经创建|已经创建了吗|创建了吗|创建成功了吗|建好了吗|建成了吗)", - re.IGNORECASE, -) -FOLLOWUP_REFERENCE_RE = re.compile( - r"(刚才|刚刚|前面|之前|上次|上一条|我刚才要求|我刚才说|我说的|那个)", - re.IGNORECASE, -) -OBVIOUS_QUESTION_RE = re.compile( - r"([??]$|吗$|呢$|呀$|吧$|怎么|如何|为什么|多少|几号|几点|周几|有没有|是否|是不是|是什么|什么|哪些|有哪些|在哪|哪一个|哪几个|特点|能力|介绍|说明|风险)", - re.IGNORECASE, -) -QUERY_INTENT_RE = re.compile( - r"(哪些|有哪些|特点|技术特点|能力|介绍|说明|怎么|如何|是什么|什么|能做什么|可以做什么|项目有哪些风险)", - re.IGNORECASE, -) -FEISHU_BOT_MENTION_RE = re.compile(r"(?:]*>[^<]*|@pmflow\b|@_user_1\b)", re.IGNORECASE) -FEISHU_QUOTE_PREFIX_RE = re.compile(r"(?m)^\s*(?:回复|引用|转发)[::]\s*") -FEISHU_QUOTE_LINE_RE = re.compile(r"(?m)^\s*(?:>+|【引用】|引用:|回复:).*$") -FEISHU_NOISE_TOKEN_RE = re.compile(r"\[(?:附带图片|图片|表情|文件|语音|消息卡片)\]", re.IGNORECASE) -EXTERNAL_INTEGRATION_RE = re.compile( - r"(github|gitlab|jira|slack|webhook|飞书|企业微信|企微|lark|api|接入|授权|同步)", - re.IGNORECASE, -) -CROSS_SYSTEM_RE = re.compile(r"(跨系统|跨平台|跨团队|外部系统|第三方系统)", re.IGNORECASE) -TASK_CREATE_RE = re.compile( - r"(新增|新建|创建|加一个|加个|补一个|补个).{0,8}任务", - re.IGNORECASE, -) -PROJECT_CREATE_GATE_RE = re.compile( - r"(新增|新建|创建|建立|立项|放到|放进|加入|录入|进).{0,18}(pmflow|项目列表|项目库|项目台账|项目主数据|一个项目|项目)", - re.IGNORECASE, -) -HEALTHCHECK_RE = re.compile( - r"^(?:ping|pong|health(?:check)?|在线吗|活着吗|存活吗|还在吗|服务正常吗)\??$", - re.IGNORECASE, -) -SHORT_CONFIRM_RE = re.compile( - r"^(?:收到|好的|好|ok|okay|明白|了解|知道了|收到啦|嗯嗯|行|辛苦了|thanks?|thx)[!!.。]?$", - re.IGNORECASE, -) -CREATE_ACTION_RE = re.compile(r"(新增|新建|创建|建立|接入|配置|录入|加入|放到|放进|立项)", re.IGNORECASE) -CORRECTION_CUE_RE = re.compile( - r"(不对|错了|理解错|不是这个|不是这个意思|我说的是|我要的是|不是啊|不是新增 md|不是写 markdown)", - re.IGNORECASE, -) - -ROUTING_AUTO_CREATE_PROJECT = "auto_create_project" -ROUTING_AUTO_ENTER_SIGNAL = "auto_enter_signal" -ROUTING_PENDING_LOW_CONFIDENCE = "pending_low_confidence" -ROUTING_PENDING_COLD_START = "pending_cold_start" -ROUTING_PENDING_UNCERTAIN_PROJECT = "pending_uncertain_project" -ROUTING_DUPLICATE_FILTERED = "duplicate_filtered" -ROUTING_NOISE_FILTERED = "noise_filtered" -ROUTING_FAILED = "failed" -ROUTING_DIRECT_QA = "direct_qa" -ROUTING_LEGACY_COMMAND_EXECUTED = "legacy_command_executed" -ROUTING_LEGACY_COMMAND_BLOCKED = "legacy_command_blocked" -ROUTING_QUEUE_FOLLOWUP_QUERY = "queue_followup_query" -ROUTING_QUEUE_FOLLOWUP_CONFIRM = "queue_followup_confirm" -ROUTING_QUEUE_FOLLOWUP_BLOCKED = "queue_followup_blocked" -ROUTING_INTENT_GATE_QUERY = "intent_gate_query" -ROUTING_INTENT_GATE_HEALTHCHECK = "intent_gate_healthcheck" -ROUTING_INTENT_GATE_CLARIFICATION = "intent_gate_clarification" -ROUTING_INTENT_GATE_EXTERNAL_CONNECT = "intent_gate_external_connect" -ROUTING_OPERATION_COMMITTED = "operation_committed" -ROUTING_OPERATION_FAILED = "operation_failed" - -INTENT_QUERY = "query" -INTENT_SMALLTALK = "smalltalk" -INTENT_HEALTHCHECK = "healthcheck" -INTENT_CREATE_PROJECT = "create_project" -INTENT_CREATE_TASK = "create_task" -INTENT_SIGNAL_INTAKE = "signal_intake" -INTENT_EXTERNAL_CONNECT = "external_connect" -INTENT_CLARIFICATION_REQUIRED = "clarification_required" - -ROUTE_STATUS_CLASSIFIED = "classified" -ROUTE_STATUS_QUEUED = "queued_for_decision" -ROUTE_STATUS_AUTO_RUNNING = "auto_running" -ROUTE_STATUS_AUTO_DONE = "auto_done" -ROUTE_STATUS_FAILED = "failed" -ROUTE_STATUS_DISMISSED = "dismissed" -ROUTE_STATUS_UNDONE = "undone" - -ACTIVE_ITEM_ROUTE_STATUSES = { - ROUTE_STATUS_CLASSIFIED, - ROUTE_STATUS_QUEUED, - ROUTE_STATUS_AUTO_RUNNING, - "pending", -} -AUTO_DONE_ITEM_ROUTE_STATUSES = { - ROUTE_STATUS_AUTO_DONE, - "auto_processed", - "confirmed", -} - -DEFAULT_SIGNAL_AUTO_ENTER_THRESHOLD = 0.80 -DEFAULT_PROJECT_AUTO_CREATE_THRESHOLD = 0.85 -DEFAULT_PROJECT_UNCERTAIN_THRESHOLD = 0.60 - - -class _LevelSplitItemSchema(BaseModel): - level: Literal["project", "signal"] - content: str = Field(min_length=1, max_length=4000) - - -class _LevelClassificationSchema(BaseModel): - build_intent_score: float = Field(ge=0.0, le=1.0) - scope_score: float = Field(ge=0.0, le=1.0) - timeline_score: float = Field(ge=0.0, le=1.0) - owner_score: float = Field(ge=0.0, le=1.0) - project_score: float = Field(ge=0.0, le=1.0) - level: Literal["project", "signal"] - level_confidence: float = Field(ge=0.0, le=1.0) - level_reasoning: str = Field(min_length=1, max_length=600) - should_split: bool = False - split_items: list[_LevelSplitItemSchema] = Field(default_factory=list, max_length=2) - - -class _SignalExtractionSchema(BaseModel): - title: str = Field(min_length=1, max_length=200) - type: Literal["竞品动态", "用户反馈", "项目风险", "新需求"] - priority: Literal["P0", "P1", "P2", "P3"] - matched_project_id: Optional[int] = None - summary: str = Field(min_length=1, max_length=3000) - reasoning: list[str] = Field(min_length=1, max_length=3) - confidence: float = Field(ge=0.0, le=1.0) - recommended_action: Literal["enter_only", "create_charter", "trigger_alert"] - - -class _ProjectPrefillSchema(BaseModel): - suggested_name: str = Field(min_length=1, max_length=255) - suggested_description: str = Field(min_length=1, max_length=4000) - suggested_owner: str = Field(min_length=1, max_length=120) - suggested_timeline: str = Field(min_length=1, max_length=120) - confidence: float = Field(ge=0.0, le=1.0) - reasoning: list[str] = Field(min_length=1, max_length=3) - - -def _normalize_reasoning(value: Any, max_items: int = 3) -> list[str]: - if isinstance(value, list): - rows = [str(x).strip() for x in value if str(x).strip()] - return rows[:max_items] - if isinstance(value, str): - parts = [p.strip(" -•\t") for p in value.splitlines() if p.strip()] - return parts[:max_items] - return [] - - -def _safe_json_object(value: str) -> dict[str, Any]: - raw = (value or "").strip() - if raw.startswith("```"): - chunks = raw.split("```") - if len(chunks) >= 2: - raw = chunks[1].strip() - if raw.startswith("json"): - raw = raw[4:].strip() - try: - parsed = json.loads(raw) - return parsed if isinstance(parsed, dict) else {} - except Exception: - return {} - - -def _validate_schema( - schema: type[BaseModel], - payload: dict[str, Any], - *, - context: str, -) -> dict[str, Any]: - if not isinstance(payload, dict): - raise ValueError(f"{context}_schema_invalid: payload_not_object") - try: - if hasattr(schema, "model_validate"): - model = schema.model_validate(payload) # type: ignore[attr-defined] - else: - model = schema.parse_obj(payload) # type: ignore[attr-defined] - except ValidationError as exc: - raise ValueError(f"{context}_schema_invalid: {str(exc)[:400]}") from exc - if hasattr(model, "model_dump"): - return model.model_dump() # type: ignore[no-any-return] - return model.dict() # type: ignore[no-any-return] - - -def _safe_float(value: Any, default: float = 0.0) -> float: - try: - return float(value) - except (TypeError, ValueError): - return float(default) - - -def _safe_bool(value: Any, default: bool = False) -> bool: - if isinstance(value, bool): - return value - if value is None: - return default - text = str(value).strip().lower() - if text in {"1", "true", "yes", "y", "on", "是", "真"}: - return True - if text in {"0", "false", "no", "n", "off", "否", "假"}: - return False - return default - - -def _safe_int(value: Any) -> Optional[int]: - if isinstance(value, bool): - return None - try: - return int(str(value).strip()) - except (TypeError, ValueError): - return None - - -def _normalize_level_value(value: Any, default: str = "signal") -> str: - text = str(value or "").strip().lower() - if not text: - return default - if text in {"project", "project-level", "project_level", "new_project"}: - return "project" - if text in {"signal", "signal-level", "signal_level"}: - return "signal" - if text in {"项目", "项目级", "立项", "新项目"}: - return "project" - if text in {"信号", "信号级", "需求", "需求级", "事件"}: - return "signal" - if "project" in text or "项目" in text or "立项" in text: - return "project" - if "signal" in text or "信号" in text or "需求" in text: - return "signal" - return default - - -def _first_non_empty(data: dict[str, Any], keys: tuple[str, ...]) -> Any: - for key in keys: - if key not in data: - continue - value = data.get(key) - if value is None: - continue - if isinstance(value, str) and not value.strip(): - continue - return value - return None - - -def _utc_now() -> datetime: - return now_cn_aware() - - -def _compact_text(value: Any, max_len: int = 160) -> str: - text = str(value or "").strip() - if len(text) <= max_len: - return text - return f"{text[: max_len - 3]}..." - - -def _safe_dict(value: Any) -> dict[str, Any]: - if isinstance(value, dict): - return value - if isinstance(value, str): - return _safe_json_object(value) - return {} - - -def _build_undo_deadline(value: Optional[datetime], *, minutes: int = 10) -> Optional[datetime]: - if value is None: - return None - if value.tzinfo is not None: - value = value.astimezone(CN_TZ).replace(tzinfo=None) - return value + timedelta(minutes=minutes) - - -class FeishuSignalPipelineService: - """飞书消息的两段式分流与路由。""" - - def __init__(self, db: AsyncSession, *, tenant_id: int): - self.db = db - self.tenant_id = int(tenant_id) - self.llm = LLMService() - - @staticmethod - def _normalize_route_status(value: Any) -> str: - text = str(value or "").strip().lower() - if text in {ROUTE_STATUS_CLASSIFIED, ROUTE_STATUS_QUEUED, ROUTE_STATUS_AUTO_RUNNING, ROUTE_STATUS_AUTO_DONE, ROUTE_STATUS_FAILED, ROUTE_STATUS_DISMISSED, ROUTE_STATUS_UNDONE}: - return text - if text == "pending": - return ROUTE_STATUS_QUEUED - if text in {"auto_processed", "confirmed"}: - return ROUTE_STATUS_AUTO_DONE - if text == "dismissed": - return ROUTE_STATUS_DISMISSED - return text or ROUTE_STATUS_FAILED - - @classmethod - def _is_active_route_status(cls, value: Any) -> bool: - return cls._normalize_route_status(value) in { - ROUTE_STATUS_CLASSIFIED, - ROUTE_STATUS_QUEUED, - ROUTE_STATUS_AUTO_RUNNING, - } - - @staticmethod - def _is_healthcheck_message(message: str) -> bool: - return bool(HEALTHCHECK_RE.search(str(message or "").strip())) - - @staticmethod - def _is_short_confirmation_message(message: str) -> bool: - return bool(SHORT_CONFIRM_RE.search(str(message or "").strip())) - - @staticmethod - def _is_correction_message(message: str) -> bool: - return bool(CORRECTION_CUE_RE.search(str(message or "").strip())) - - @staticmethod - def _looks_like_continue_followup(message: str) -> bool: - text = str(message or "").strip() - if not text: - return False - if FOLLOWUP_CREATE_CONTINUE_RE.search(text): - return bool(FOLLOWUP_REFERENCE_RE.search(text)) - return bool(FOLLOWUP_CONTINUE_RE.search(text)) - - @staticmethod - def _looks_like_task_create_request(message: str) -> bool: - return bool(TASK_CREATE_RE.search(str(message or "").strip())) - - @staticmethod - def _looks_like_project_create_request(message: str) -> bool: - text = str(message or "").strip() - if not text: - return False - if PROJECT_CREATE_GATE_RE.search(text): - return True - return bool(LEGACY_COMMAND_PROJECT_CREATE_RE.search(text)) - - @staticmethod - def _looks_like_external_connect_request(message: str) -> bool: - text = str(message or "").strip() - if not text: - return False - return bool(CREATE_ACTION_RE.search(text) and EXTERNAL_INTEGRATION_RE.search(text)) - - @staticmethod - def _can_execute_high_value_action(user: Optional[User]) -> bool: - if user is None: - return False - return str(getattr(user, "role", "") or "").upper() in {"BOSS", "ADMIN", "PM"} - - @staticmethod - def _stable_positive_int(*parts: Any) -> int: - raw = "::".join(str(part or "").strip() for part in parts if str(part or "").strip()) - digest = hashlib.sha1(raw.encode("utf-8")).hexdigest() - return int(digest[:12], 16) - - @staticmethod - def _normalize_key_fragment(value: str, *, max_len: int = 80) -> str: - normalized = re.sub(r"[^0-9a-z\u4e00-\u9fff]+", "", str(value or "").strip().lower()) - return normalized[:max_len] - - @classmethod - def _build_intent_fragment(cls, message: str) -> str: - text = str(message or "").strip() - if not text: - return "generic" - if EXTERNAL_INTEGRATION_RE.search(text): - return "external_integration" - if CROSS_SYSTEM_RE.search(text): - return "cross_system" - if PROJECT_COMPLEXITY_RE.search(text): - return "project_setup" - if SIMPLE_SIGNAL_REQUEST_RE.search(text): - return "feature_change" - fragment = cls._normalize_key_fragment(text, max_len=64) - return fragment or "generic" - - def _build_queue_dedupe_key( - self, - *, - level: str, - intent: Optional[str], - message: str, - source_chat_id: str, - matched_project_id: Optional[int], - source_object: Optional[str] = None, - ) -> str: - project_key = str(int(matched_project_id)) if matched_project_id is not None else "global" - source_key = self._normalize_key_fragment(source_chat_id or "direct", max_len=48) or "direct" - intent_key = self._normalize_key_fragment(str(intent or "").strip().lower(), max_len=48) or self._build_intent_fragment(message) - object_key = self._normalize_key_fragment(source_object or message, max_len=64) or "generic" - return f"{level}:{project_key}:{source_key}:{intent_key}:{object_key}"[:255] - - async def _find_active_dedupe_item(self, dedupe_key: Optional[str]) -> Optional[FeishuItem]: - normalized_key = (dedupe_key or "").strip() - if not normalized_key: - return None - return ( - await self.db.execute( - select(FeishuItem) - .where( - and_( - FeishuItem.tenant_id == self.tenant_id, - FeishuItem.dedupe_key == normalized_key, - FeishuItem.route_status.in_(tuple(ACTIVE_ITEM_ROUTE_STATUSES)), - ) - ) - .order_by(FeishuItem.updated_at.desc(), FeishuItem.id.desc()) - .limit(1) - ) - ).scalar_one_or_none() - - async def _build_existing_item_result(self, item: FeishuItem) -> dict[str, Any]: - level = str(item.level or "").lower() - result: dict[str, Any] = { - "item_id": int(item.id), - "level": level, - "project_id": int(item.matched_project_id) if item.matched_project_id is not None else None, - "signal_id": None, - "route_status": self._normalize_route_status(item.route_status), - "clarification_message": item.blocking_reason or None, - } - if level == "project": - suggestion = await self._get_project_suggestion(item.id) - if suggestion and suggestion.created_project_id: - result["project_id"] = int(suggestion.created_project_id) - return result - - if level == "command": - payload = item.result_payload if isinstance(item.result_payload, dict) else {} - project_id = _safe_int(payload.get("project_id") or item.matched_project_id) - if project_id is not None: - result["project_id"] = int(project_id) - return result - - signal = await self._get_signal_by_item(item.id) - if signal: - result["signal_id"] = int(signal.id) - if signal.matched_project_id is not None: - result["project_id"] = int(signal.matched_project_id) - return result - - async def _resolve_intent_gate( - self, - *, - message: str, - inferred_project_id: Optional[int], - source_chat_id: str = "", - source_sender_open_id: Optional[str] = None, - sender: str = "", - ) -> dict[str, Any]: - text = str(message or "").strip() - if not text: - return { - "intent": INTENT_SMALLTALK, - "canonical_action": None, - "clarification_template": "", - "risk_level": "low", - "fields": {}, - "correction_match": None, - } - - explicit_project_request = self._looks_like_project_create_request(text) - explicit_task_request = self._looks_like_task_create_request(text) - explicit_external_request = self._looks_like_external_connect_request(text) - correction_match: Optional[dict[str, Any]] = None - if self.db is not None and len(text) >= 4: - try: - recall_rows = await AdaptiveRAGService(self.db, tenant_id=self.tenant_id).retrieve_intent_correction_samples( - utterance=text, - top_k=1, - project_id=inferred_project_id, - ) - if recall_rows: - correction_match = recall_rows[0] - except Exception as exc: - logger.warning(f"intent_correction_recall_failed: tenant_id={self.tenant_id}, error={exc}") - correction_decision = kernel_resolve_correction(text) - if correction_decision.action != "none" and not ( - explicit_project_request or explicit_task_request or explicit_external_request - ): - decision_trace = build_decision_trace( - intent=INTENT_CLARIFICATION_REQUIRED, - confidence=correction_decision.confidence, - route_tier="rule", - route_function="_resolve_intent_gate", - gate_action="pending_manual", - gate_code="correction_review", - correction_action=correction_decision.action, - correction_keywords=correction_decision.matched_keywords, - complexity_reasons=[], - ) - return { - "intent": INTENT_CLARIFICATION_REQUIRED, - "canonical_action": "clarify", - "clarification_template": "检测到修正/回滚语义,已进入待确认队列。请明确目标对象后继续执行。", - "risk_level": "high", - "fields": {}, - "correction_match": correction_match, - "decision_trace": decision_trace, - } - - if self._is_healthcheck_message(text): - return { - "intent": INTENT_HEALTHCHECK, - "canonical_action": None, - "clarification_template": "", - "risk_level": "low", - "fields": {}, - "correction_match": correction_match, - } - if self._is_short_confirmation_message(text): - return { - "intent": INTENT_SMALLTALK, - "canonical_action": None, - "clarification_template": "", - "risk_level": "low", - "fields": {}, - "correction_match": correction_match, - } - - if ( - correction_match - and float(correction_match.get("score") or 0.0) >= 0.84 - and not (explicit_project_request or explicit_task_request or explicit_external_request) - ): - gate = { - "intent": str(correction_match.get("correct_intent") or INTENT_SIGNAL_INTAKE).strip().lower(), - "canonical_action": str(correction_match.get("canonical_action") or "").strip() or None, - "clarification_template": str(correction_match.get("clarification_template") or "").strip(), - "risk_level": str(correction_match.get("risk_level") or "medium").strip().lower() or "medium", - "fields": {}, - "correction_match": correction_match, - } - if gate["intent"] not in { - INTENT_QUERY, - INTENT_SMALLTALK, - INTENT_HEALTHCHECK, - INTENT_CREATE_PROJECT, - INTENT_CREATE_TASK, - INTENT_SIGNAL_INTAKE, - INTENT_EXTERNAL_CONNECT, - INTENT_CLARIFICATION_REQUIRED, - }: - gate["intent"] = INTENT_SIGNAL_INTAKE - return gate - - if explicit_project_request: - fields = self._extract_project_intent_fields(text) - fields = await self._maybe_prefill_project_fields_from_embedded_content( - message=text, - fields=fields, - ) - fields = await self._maybe_prefill_project_fields_from_context( - message=text, - source_chat_id=source_chat_id, - source_sender_open_id=source_sender_open_id, - sender=sender, - fields=fields, - ) - if fields["ready"]: - return { - "intent": INTENT_CREATE_PROJECT, - "canonical_action": "project.create", - "clarification_template": "", - "risk_level": "high", - "fields": fields, - "correction_match": correction_match, - } - return { - "intent": INTENT_CLARIFICATION_REQUIRED, - "canonical_action": "project.create", - "clarification_template": self._build_project_clarification_message(fields["missing_fields"]), - "risk_level": "high", - "fields": fields, - "correction_match": correction_match, - } - - if explicit_task_request: - fields = self._extract_task_intent_fields(text=text, inferred_project_id=inferred_project_id) - if fields["ready"]: - return { - "intent": INTENT_CREATE_TASK, - "canonical_action": "task.create", - "clarification_template": "", - "risk_level": "high", - "fields": fields, - "correction_match": correction_match, - } - return { - "intent": INTENT_CLARIFICATION_REQUIRED, - "canonical_action": "task.create", - "clarification_template": self._build_task_clarification_message(fields["missing_fields"]), - "risk_level": "high", - "fields": fields, - "correction_match": correction_match, - } - - if explicit_external_request: - return { - "intent": INTENT_EXTERNAL_CONNECT, - "canonical_action": "source.connect", - "clarification_template": "外部系统接入默认进入待决策队列。请补充接入目标、授权边界、回流项目和幂等要求。", - "risk_level": "high", - "fields": {"project_id": inferred_project_id}, - "correction_match": correction_match, - } - - if self._is_query_like_question(text): - return { - "intent": INTENT_QUERY, - "canonical_action": None, - "clarification_template": "", - "risk_level": "low", - "fields": {}, - "correction_match": correction_match, - } - - if self._is_obvious_question(text): - return { - "intent": INTENT_QUERY, - "canonical_action": None, - "clarification_template": "", - "risk_level": "low", - "fields": {}, - "correction_match": correction_match, - } - - return { - "intent": INTENT_SIGNAL_INTAKE, - "canonical_action": "signal.enter", - "clarification_template": "", - "risk_level": "medium", - "fields": {"project_id": inferred_project_id}, - "correction_match": correction_match, - } - - @staticmethod - def _extract_project_scope(text: str, *, description: str) -> str: - cleaned = re.sub(r"\s+", " ", str(text or "")).strip() - for pattern in ( - r"(?:范围|scope|面向|目标|用于|负责|服务于)[::\s]*(.+)", - r"(?:描述|背景|场景|做什么)[::\s]*(.+)", - ): - match = re.search(pattern, cleaned, re.IGNORECASE) - if not match: - continue - candidate = re.sub(r"[。;;!!??].*$", "", match.group(1)).strip(" ,,::") - if candidate: - return candidate[:400] - if len(description) >= 12: - return description[:400] - return "" - - def _extract_project_intent_fields(self, message: str) -> dict[str, Any]: - title = extract_project_create_title(message) - if not title: - cleaned = re.sub(r"\s+", " ", str(message or "")).strip() - for pattern in ( - r"(?:名称|名字|项目名)(?:叫|是|为)?[::\s]*(.+)", - r"项目(?:叫|名为|名称叫|名称是|名称为)[::\s]*(.+)", - ): - match = re.search(pattern, cleaned, re.IGNORECASE) - if not match: - continue - candidate = re.sub(r"[,。,.;;!??].*$", "", match.group(1)).strip(" ,,::\"'“”") - if candidate: - title = candidate[:24] - break - description = extract_project_create_description( - instruction=message, - title=title, - ) - project_scope = self._extract_project_scope(message, description=description) - missing_fields: list[str] = [] - if not title: - missing_fields.append("title") - if not description: - missing_fields.append("description") - if not project_scope: - missing_fields.append("project_scope") - return { - "title": title, - "description": description, - "project_scope": project_scope, - "missing_fields": missing_fields, - "ready": not missing_fields, - } - - @staticmethod - def _looks_like_contextual_project_request(message: str) -> bool: - normalized = re.sub(r"\s+", "", str(message or "")).lower() - if not normalized: - return False - return any( - token in normalized - for token in ( - "根据这个", - "根据上面", - "基于这个", - "基于上面", - "按这个", - "按上面", - "汇总以上", - "汇总上面", - "以上内容", - "上面内容", - "这个汇总", - "这个总结", - "刚才内容", - ) - ) - - @staticmethod - def _strip_feishu_mention_prefix(text: str) -> str: - cleaned = re.sub(r"^]*>[^<]*\s*", "", str(text or ""), flags=re.IGNORECASE).strip() - return re.sub(r"\s+", " ", cleaned).strip() - - @staticmethod - def _looks_like_placeholder_project_text(text: str) -> bool: - normalized = re.sub(r"\s+", "", str(text or "")).lower() - if not normalized: - return True - if len(normalized) > 48: - return False - return any( - token in normalized - for token in ( - "创建个项目", - "创建一个项目", - "新建一个项目", - "新增一个项目", - "给我创建个项目", - "项目在项目列表", - "项目在pmflow", - "放进pmflow", - "放到pmflow", - "放进项目列表", - "放到项目列表", - "汇总以上内容", - "根据这个", - "根据上面", - ) - ) - - @staticmethod - def _looks_like_project_request_artifact(text: str) -> bool: - normalized = re.sub(r"\s+", "", str(text or "")).lower() - if not normalized: - return False - return any( - token in normalized - for token in ( - "给我创建个项目", - "给我创建一个项目", - "创建个项目", - "创建一个项目", - "新建一个项目", - "新增一个项目", - "项目在项目列表", - "项目列表@pmflow", - "进项目列表", - "放进项目列表", - "放到项目列表", - "放进pmflow", - "放到pmflow", - "根据这个汇总", - "根据上面汇总", - "汇总以上内容", - ) - ) - - @staticmethod - def _looks_like_unusable_project_context(text: str) -> bool: - normalized = re.sub(r"\s+", "", str(text or "")).lower() - if not normalized: - return True - return any( - token in normalized - for token in ( - "系统处理中出现异常", - "本次未执行", - "请稍后重试", - "requestwasaborted", - "connectionaborted", - "429", - "模型服务异常", - "无法生成可靠回答", - "无法直接给结论", - "没有输出,可能后端有问题", - "让我看看main.py", - ) - ) - - @staticmethod - def _guess_project_title_from_text(text: str) -> str: - cleaned = re.sub(r"\s+", " ", str(text or "")).strip() - if not cleaned: - return "" - normalized = re.sub(r"\s+", "", cleaned).lower() - if "十五五" in cleaned and any(token in cleaned for token in ("医药", "医疗", "器械", "中医药")): - return "十五五医药方向研究" - if "医药" in cleaned and any( - token in normalized - for token in ("方向", "值得关注", "创新药", "中医药", "老龄化", "供应链安全", "出海") - ): - return "医药方向研究" - if "医疗器械" in cleaned and "国产替代" in cleaned: - return "医疗器械国产替代研究" - return "" - - @staticmethod - def _extract_embedded_project_context(message: str) -> str: - cleaned = re.sub(r"\s+", " ", str(message or "")).strip() - if len(cleaned) < 80: - return "" - markers = [] - for pattern in ( - r"(?:根据这个|根据上面|基于这个|基于上面|按这个|按上面|汇总以上内容|汇总上面内容|汇总一下|这个汇总|这个总结).{0,40}(?:创建|新建|建立|建个|建一个).{0,8}项目", - r"(?:给我|帮我|请|麻烦)?(?:创建|新建|建立|建个|建一个|做个|做一个)(?:一个|个)?项目(?:在|进|到)?(?:pmflow|项目列表)", - ): - match = re.search(pattern, cleaned, re.IGNORECASE) - if match: - markers.append(match.start()) - if not markers: - return "" - context = cleaned[: min(markers)].strip(" ,,::") - if len(context) < 80: - return "" - return context[:4000] - - @staticmethod - def _derive_project_title_from_context(*, question: str, answer: str) -> str: - guessed = FeishuSignalPipelineService._guess_project_title_from_text(f"{question}\n{answer}".strip()) - if guessed: - return guessed - - def _normalize(raw: str) -> str: - candidate = re.sub(r"\s+", " ", str(raw or "")).strip(" ,,。.!!??::;;") - candidate = re.sub(r"^(?:请问|麻烦|帮我|我想知道|我想了解|想了解一下|目前|现在|关于|对于|针对)", "", candidate) - candidate = re.sub( - r"(?:还有哪些|有哪些|值得关注什么|值得关注哪些|值得关注|应该投什么|该投什么|该怎么做|怎么做|是什么|是啥|可以吗|吗|呢)$", - "", - candidate, - ) - candidate = re.sub(r"[,,].*$", "", candidate).strip(" ,,。.!!??::;;") - candidate = re.sub(r"(?:里面|里)$", "", candidate).strip(" ,,。.!!??::;;") - candidate = re.sub(r"[。;;!!??].*$", "", candidate).strip(" ,,。.!!??::;;") - normalized = normalize_project_title_candidate(candidate) - if normalized.endswith(("方向", "规划", "创新")): - normalized = normalize_project_title_candidate(f"{normalized}研究") - return normalized - - title = _normalize(question) - if title: - return title - - cleaned_answer = re.sub(r"\s+", " ", str(answer or "")).strip() - for pattern in ( - r"((?:十五五|医药|医疗|器械|公共卫生|老龄化)[^。;;!!??]{0,16}(?:方向|规划|创新|体系))", - r"((?:[A-Za-z0-9\u4e00-\u9fff]{2,18})(?:方向|规划|创新|中台|系统|平台|治理|流程|方案|体系))", - ): - match = re.search(pattern, cleaned_answer) - if not match: - continue - title = _normalize(match.group(1)) - if title: - return title - return "" - - async def _load_recent_chat_turns( - self, - *, - source_chat_id: str, - source_sender_open_id: Optional[str], - sender: str, - limit: int = 3, - ) -> list[dict[str, str]]: - if self.db is None or not source_chat_id: - return [] - conversation_key = self._build_rag_conversation_key( - source_chat_id=source_chat_id, - source_sender_open_id=source_sender_open_id, - sender=sender, - ) - external_session_id = AdaptiveRAGService._external_session_id_from_key(conversation_key) - if external_session_id is None: - return [] - rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) - messages = await rag_service.context_cache.get_session_messages( - tenant_id=self.tenant_id, - session_id=int(external_session_id), - ) - if not messages: - return [] - turns = rag_service.context_cache.messages_to_turns(messages) - return [row for row in turns if isinstance(row, dict)][-limit:] - - async def _load_recent_chat_reply_fallback_text(self, *, source_chat_id: str, limit: int = 5) -> str: - if self.db is None or not source_chat_id: - return "" - rows = ( - await self.db.execute( - select(FeishuReplyOutbox) - .where( - and_( - FeishuReplyOutbox.chat_id == (source_chat_id or "")[:128], - FeishuReplyOutbox.status == "sent", - ) - ) - .order_by(FeishuReplyOutbox.id.desc()) - .limit(limit) - ) - ).scalars().all() - for row in rows: - text = self._strip_feishu_mention_prefix(str(row.message_text or "")) - if not text: - continue - if self._looks_like_unusable_project_context(text): - continue - return text[:4000] - return "" - - async def _maybe_prefill_project_fields_from_embedded_content( - self, - *, - message: str, - fields: dict[str, Any], - ) -> dict[str, Any]: - if fields.get("ready"): - return fields - - embedded_context = self._extract_embedded_project_context(message) - if not embedded_context or self._looks_like_unusable_project_context(embedded_context): - return fields - - current_description = str(fields.get("description") or "").strip() - if ( - self._looks_like_placeholder_project_text(current_description) - or self._looks_like_project_request_artifact(current_description) - ): - current_description = "" - current_scope = str(fields.get("project_scope") or "").strip() - if ( - self._looks_like_placeholder_project_text(current_scope) - or self._looks_like_project_request_artifact(current_scope) - ): - current_scope = "" - - title = str(fields.get("title") or "").strip() or self._guess_project_title_from_text(embedded_context) - description = current_description or embedded_context[:4000] - project_scope = current_scope or self._extract_project_scope(embedded_context, description=description) - if title and description and project_scope: - return { - "title": title, - "description": description, - "project_scope": project_scope, - "missing_fields": [], - "ready": True, - } - - project_info = await self._extract_project_prefill( - f"当前消息里的项目材料:{embedded_context}\n\n用户动作:{message}\n\n请仅基于当前消息里的材料补全项目名称、项目描述、项目范围。" - ) - suggested_name = normalize_project_title_candidate( - str(project_info.get("suggested_name") or "").strip() - ) - if self._looks_like_placeholder_project_text(suggested_name): - suggested_name = "" - title = title or suggested_name - description = description or str(project_info.get("suggested_description") or "").strip()[:4000] - if self._looks_like_unusable_project_context(description): - description = "" - project_scope = project_scope or self._extract_project_scope(embedded_context, description=description) - - missing_fields: list[str] = [] - if not title: - missing_fields.append("title") - if not description: - missing_fields.append("description") - if not project_scope: - missing_fields.append("project_scope") - return { - "title": title, - "description": description, - "project_scope": project_scope, - "missing_fields": missing_fields, - "ready": not missing_fields, - } - - async def _maybe_prefill_project_fields_from_context( - self, - *, - message: str, - source_chat_id: str, - source_sender_open_id: Optional[str], - sender: str, - fields: dict[str, Any], - ) -> dict[str, Any]: - if fields.get("ready"): - return fields - if not source_chat_id or not self._looks_like_contextual_project_request(message): - return fields - - turns = await self._load_recent_chat_turns( - source_chat_id=source_chat_id, - source_sender_open_id=source_sender_open_id, - sender=sender, - limit=3, - ) - context_blocks: list[str] = [] - question_hint = "" - answer_hint = "" - if turns: - anchor = turns[-1] - recent_question = str(anchor.get("question") or "").strip() - recent_answer = str(anchor.get("answer") or "").strip() - if recent_question: - context_blocks.append(f"最近用户问题:{recent_question}") - question_hint = recent_question - if recent_answer and not self._looks_like_unusable_project_context(recent_answer): - context_blocks.append(f"最近系统回答:{recent_answer}") - answer_hint = recent_answer - - if not context_blocks: - reply_fallback = await self._load_recent_chat_reply_fallback_text(source_chat_id=source_chat_id) - if reply_fallback: - context_blocks.append(f"最近系统回答:{reply_fallback}") - answer_hint = reply_fallback - - if not context_blocks: - return fields - - base_description = str(fields.get("description") or "").strip() - if ( - self._looks_like_placeholder_project_text(base_description) - or self._looks_like_project_request_artifact(base_description) - ): - base_description = "" - if not answer_hint and not base_description: - return fields - - heuristic_title = self._derive_project_title_from_context(question=question_hint, answer=answer_hint) - heuristic_description = base_description or answer_hint[:4000] - heuristic_scope = str(fields.get("project_scope") or "").strip() - if ( - self._looks_like_placeholder_project_text(heuristic_scope) - or self._looks_like_project_request_artifact(heuristic_scope) - ): - heuristic_scope = "" - if not heuristic_scope and heuristic_description: - heuristic_scope = self._extract_project_scope( - f"{question_hint}\n\n{answer_hint}".strip(), - description=heuristic_description, - ) - if heuristic_title and heuristic_description and heuristic_scope: - return { - "title": str(fields.get("title") or "").strip() or heuristic_title, - "description": heuristic_description, - "project_scope": heuristic_scope, - "missing_fields": [], - "ready": True, - } - - prefill_source = ( - f"当前建项目请求:{message}\n\n" - + "\n\n".join(context_blocks) - + "\n\n请基于最近上下文补全项目名称、项目描述、项目范围。" - ) - project_info = await self._extract_project_prefill(prefill_source) - current_description = str(fields.get("description") or "").strip() - if ( - self._looks_like_placeholder_project_text(current_description) - or self._looks_like_project_request_artifact(current_description) - ): - current_description = "" - current_scope = str(fields.get("project_scope") or "").strip() - if ( - self._looks_like_placeholder_project_text(current_scope) - or self._looks_like_project_request_artifact(current_scope) - ): - current_scope = "" - title = ( - str(fields.get("title") or "").strip() - or heuristic_title - or normalize_project_title_candidate(str(project_info.get("suggested_name") or "")) - ) - description = current_description or str(project_info.get("suggested_description") or "").strip() - if not description and answer_hint: - description = answer_hint[:4000] - if self._looks_like_unusable_project_context(description): - description = "" - project_scope = current_scope - if not project_scope: - scope_source = f"{description}\n\n{answer_hint}".strip() - project_scope = self._extract_project_scope(scope_source, description=description) - - missing_fields: list[str] = [] - if not title: - missing_fields.append("title") - if not description: - missing_fields.append("description") - if not project_scope: - missing_fields.append("project_scope") - return { - "title": title, - "description": description, - "project_scope": project_scope, - "missing_fields": missing_fields, - "ready": not missing_fields, - } - - def _resolve_project_candidate_fields( - self, - *, - item: FeishuItem, - suggestion: Optional[ProjectSuggestion] = None, - ) -> dict[str, Any]: - payload = item.result_payload if isinstance(item.result_payload, dict) else {} - operation_payload = payload.get("operation_payload") if isinstance(payload.get("operation_payload"), dict) else {} - title = str( - (suggestion.suggested_name if suggestion is not None else None) - or operation_payload.get("title") - or "" - ).strip() - description = str( - (suggestion.suggested_description if suggestion is not None else None) - or operation_payload.get("description") - or "" - ).strip() - project_scope = str(payload.get("project_scope") or "").strip() - if not title or not description: - extracted = self._extract_project_intent_fields(item.raw_message or "") - if not title: - title = str(extracted.get("title") or "").strip() - if not description: - description = str(extracted.get("description") or "").strip() - if not project_scope: - project_scope = str(extracted.get("project_scope") or "").strip() - if not project_scope: - project_scope = self._extract_project_scope(item.raw_message or "", description=description) - - missing_fields: list[str] = [] - if not title: - missing_fields.append("title") - if not description: - missing_fields.append("description") - if not project_scope: - missing_fields.append("project_scope") - return { - "title": title, - "description": description, - "project_scope": project_scope, - "missing_fields": missing_fields, - "ready": not missing_fields, - } - - @staticmethod - def _build_project_clarification_message(missing_fields: list[str]) -> str: - labels = { - "title": "项目名称", - "description": "项目描述", - "project_scope": "项目范围", - } - ordered = [labels[field] for field in missing_fields if field in labels] - suffix = "、".join(ordered) if ordered else "项目名称、项目描述、项目范围" - return f"未执行:当前建项目请求还缺少 {suffix}。请直接补充这三项后我再创建。" - - @classmethod - def _build_project_preview_title( - cls, - *, - title: str, - description: str, - project_scope: str, - ) -> str: - normalized_title = normalize_project_title_candidate(title) - if normalized_title and not cls._looks_like_placeholder_project_text(normalized_title): - return normalized_title - - for candidate in (project_scope, description): - cleaned = str(candidate or "").strip() - if not cleaned or cls._looks_like_placeholder_project_text(cleaned): - continue - normalized = normalize_project_title_candidate(cleaned[:48]) - if normalized: - return normalized - return "项目创建请求" - - @classmethod - def _build_project_preview_summary( - cls, - *, - description: str, - project_scope: str, - blocking_reason: Optional[str], - fallback_text: str, - ) -> str: - normalized_description = str(description or "").strip() - if cls._looks_like_placeholder_project_text(normalized_description): - normalized_description = "" - normalized_scope = str(project_scope or "").strip() - if cls._looks_like_placeholder_project_text(normalized_scope): - normalized_scope = "" - - fragments: list[str] = [] - if normalized_description: - fragments.append(_compact_text(normalized_description, max_len=140)) - if normalized_scope and normalized_scope not in normalized_description: - fragments.append(f"范围:{_compact_text(normalized_scope, max_len=90)}") - if fragments: - return ";".join(fragments[:2]) - if str(blocking_reason or "").strip(): - return str(blocking_reason).strip() - return _compact_text(fallback_text, max_len=140) - - def _extract_task_intent_fields(self, *, text: str, inferred_project_id: Optional[int]) -> dict[str, Any]: - cleaned = re.sub(r"\s+", " ", str(text or "")).strip() - title = "" - quoted_match = re.search(r"[\"'“](.+?)[\"'”]", cleaned) - if quoted_match: - title = quoted_match.group(1).strip() - if not title: - for pattern in ( - r"(?:新增|新建|创建|加一个|加个).{0,8}任务(?:叫|名为|标题是|是|:|:)?\s*(.+)", - r"任务(?:叫|名为|标题是|是|:|:)\s*(.+)", - ): - match = re.search(pattern, cleaned, re.IGNORECASE) - if not match: - continue - candidate = re.sub(r"[。;;!!??].*$", "", match.group(1)).strip(" ,,::") - candidate = re.sub(r"^(?:到|进|放到)\s*项目.*$", "", candidate, flags=re.IGNORECASE).strip(" ,,::") - if candidate: - title = candidate[:120] - break - - description = "" - if title: - description = cleaned.replace(title, "", 1).strip(" ,,::") - description = re.sub(r"^(?:新增|新建|创建|加一个|加个).{0,8}任务", "", description, flags=re.IGNORECASE).strip(" ,,::") - - missing_fields: list[str] = [] - if inferred_project_id is None: - missing_fields.append("project_id") - if not title: - missing_fields.append("title") - return { - "project_id": int(inferred_project_id) if inferred_project_id is not None else None, - "title": title, - "description": description[:5000] or None, - "missing_fields": missing_fields, - "ready": not missing_fields, - } - - @staticmethod - def _build_task_clarification_message(missing_fields: list[str]) -> str: - labels = { - "project_id": "归属项目", - "title": "任务标题", - } - ordered = [labels[field] for field in missing_fields if field in labels] - suffix = "、".join(ordered) if ordered else "归属项目和任务标题" - return f"未执行:当前建任务请求还缺少 {suffix}。请补充后我再创建。" - - @staticmethod - def _build_pending_operation_summary(*, operation_code: str, payload: dict[str, Any]) -> str: - if operation_code == "project.create": - return f"待创建项目:{str(payload.get('title') or '未命名项目').strip()}" - if operation_code == "task.create": - return f"待创建任务:{str(payload.get('title') or '未命名任务').strip()}" - return f"待执行动作:{operation_code}" - - @staticmethod - def _build_pending_operation_next_step(*, operation_code: str) -> str: - if operation_code == "project.create": - return "补齐信息或确认后,系统会创建项目并绑定真实项目 ID。" - if operation_code == "task.create": - return "确认后,系统会在目标项目中创建任务。" - return "补齐上下文后,系统会继续执行该动作。" - - @staticmethod - def _build_gate_source_object(*, intent: str, fields: dict[str, Any], message: str) -> str: - if intent == INTENT_CREATE_PROJECT: - return str(fields.get("title") or fields.get("project_scope") or message) - if intent == INTENT_CREATE_TASK: - return str(fields.get("title") or fields.get("project_id") or message) - return str(fields.get("project_id") or intent or message) - - async def _queue_project_intent_item( - self, - *, - message: str, - sender: str, - source_chat_id: str, - source_sender_open_id: Optional[str], - feishu_message_id: Optional[str], - occurred_at: datetime, - intent_gate: dict[str, Any], - blocking_reason: str, - decision_reason: str, - ) -> dict[str, Any]: - fields = intent_gate.get("fields") if isinstance(intent_gate.get("fields"), dict) else {} - source_object = self._build_gate_source_object( - intent=str(intent_gate.get("intent") or INTENT_CREATE_PROJECT), - fields=fields, - message=message, - ) - dedupe_key = self._build_queue_dedupe_key( - level="project", - intent=str(intent_gate.get("intent") or INTENT_CREATE_PROJECT), - message=message, - source_chat_id=source_chat_id, - matched_project_id=None, - source_object=source_object, - ) - existing_item = await self._find_active_dedupe_item(dedupe_key) - if existing_item is not None: - existing_item.occurrence_count = max(1, int(existing_item.occurrence_count or 1)) + 1 - existing_item.blocking_reason = blocking_reason[:2000] - existing_item.decision_reason = decision_reason[:1000] - existing_item.raw_message = message[:4000] - await self.db.flush() - result = await self._build_existing_item_result(existing_item) - result["clarification_message"] = blocking_reason - return result - - item = FeishuItem( - tenant_id=self.tenant_id, - feishu_message_id=feishu_message_id, - project_score=1.0, - signal_confidence=0.0, - raw_message=message[:4000], - split_from_item_id=None, - level="project", - level_confidence=1.0, - level_reasoning="intent_gate:create_project", - card_type="new_project_candidate", - route_status=ROUTE_STATUS_QUEUED, - matched_project_id=None, - source_chat_id=(source_chat_id or "")[:128] or None, - source_sender=(sender or "")[:120] or None, - source_sender_open_id=(source_sender_open_id or "")[:128] or None, - source_occurred_at=occurred_at.replace(tzinfo=None), - ai_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False)[:8000], - decision_reason=decision_reason[:1000] or None, - blocking_reason=blocking_reason[:2000] or None, - safe_to_auto_execute=False, - auto_execute_state="blocked", - dedupe_key=dedupe_key, - message_class="action_required", - occurrence_count=1, - next_step_text=self._build_pending_operation_next_step(operation_code="project.create"), - queue_visible=True, - result_payload={ - "intent": INTENT_CREATE_PROJECT, - "executor": "explicit_service", - "operation_code": "project.create", - "operation_payload": { - "title": str(fields.get("title") or "").strip(), - "description": str(fields.get("description") or "").strip(), - }, - "project_scope": str(fields.get("project_scope") or "").strip() or None, - "result_summary": self._build_pending_operation_summary( - operation_code="project.create", - payload={ - "title": str(fields.get("title") or "").strip(), - }, - ), - }, - ) - self.db.add(item) - await self.db.flush() - - suggestion = ProjectSuggestion( - tenant_id=self.tenant_id, - feishu_item_id=int(item.id), - suggested_name=(str(fields.get("title") or "").strip() or message[:20] or "新项目建议")[:255], - suggested_description=(str(fields.get("description") or "").strip() or message[:400])[:4000], - suggested_owner=(sender or "待指定")[:120], - suggested_timeline="待确认", - confidence=1.0, - reasoning=json.dumps(["命中 intent gate:create_project,等待确认或补齐字段。"], ensure_ascii=False), - status="pending", - ) - self.db.add(suggestion) - await self.db.flush() - return { - "item_id": int(item.id), - "level": "project", - "project_id": None, - "signal_id": None, - "route_status": ROUTE_STATUS_QUEUED, - "clarification_message": blocking_reason, - } - - async def _queue_command_intent_item( - self, - *, - message: str, - sender: str, - source_chat_id: str, - source_sender_open_id: Optional[str], - feishu_message_id: Optional[str], - occurred_at: datetime, - intent_gate: dict[str, Any], - operation_code: str, - operation_payload: dict[str, Any], - blocking_reason: str, - decision_reason: str, - matched_project_id: Optional[int], - ) -> dict[str, Any]: - fields = intent_gate.get("fields") if isinstance(intent_gate.get("fields"), dict) else {} - source_object = self._build_gate_source_object( - intent=str(intent_gate.get("intent") or ""), - fields=fields, - message=message, - ) - dedupe_key = self._build_queue_dedupe_key( - level="command", - intent=str(intent_gate.get("intent") or ""), - message=message, - source_chat_id=source_chat_id, - matched_project_id=matched_project_id, - source_object=source_object, - ) - existing_item = await self._find_active_dedupe_item(dedupe_key) - if existing_item is not None: - existing_item.occurrence_count = max(1, int(existing_item.occurrence_count or 1)) + 1 - existing_item.blocking_reason = blocking_reason[:2000] - existing_item.decision_reason = decision_reason[:1000] - existing_item.raw_message = message[:4000] - existing_item.result_payload = { - **(existing_item.result_payload if isinstance(existing_item.result_payload, dict) else {}), - "intent": str(intent_gate.get("intent") or ""), - "executor": "explicit_service", - "operation_code": operation_code, - "operation_payload": operation_payload, - "result_summary": self._build_pending_operation_summary( - operation_code=operation_code, - payload=operation_payload, - ), - } - await self.db.flush() - result = await self._build_existing_item_result(existing_item) - result["clarification_message"] = blocking_reason - return result - - item = FeishuItem( - tenant_id=self.tenant_id, - feishu_message_id=feishu_message_id, - project_score=1.0 if matched_project_id is not None else None, - signal_confidence=0.0 if matched_project_id is not None else None, - raw_message=message[:4000], - split_from_item_id=None, - level="command", - level_confidence=1.0, - level_reasoning=f"intent_gate:{str(intent_gate.get('intent') or '')}", - card_type=operation_code.replace(".", "_")[:30], - route_status=ROUTE_STATUS_QUEUED, - matched_project_id=int(matched_project_id) if matched_project_id is not None else None, - source_chat_id=(source_chat_id or "")[:128] or None, - source_sender=(sender or "")[:120] or None, - source_sender_open_id=(source_sender_open_id or "")[:128] or None, - source_occurred_at=occurred_at.replace(tzinfo=None), - ai_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False)[:8000], - decision_reason=decision_reason[:1000] or None, - blocking_reason=blocking_reason[:2000] or None, - safe_to_auto_execute=False, - auto_execute_state="blocked", - dedupe_key=dedupe_key, - message_class="action_required", - occurrence_count=1, - next_step_text=self._build_pending_operation_next_step(operation_code=operation_code), - queue_visible=True, - result_payload={ - "intent": str(intent_gate.get("intent") or ""), - "executor": "explicit_service", - "operation_code": operation_code, - "operation_payload": operation_payload, - "result_summary": self._build_pending_operation_summary( - operation_code=operation_code, - payload=operation_payload, - ), - "project_id": int(matched_project_id) if matched_project_id is not None else None, - }, - ) - self.db.add(item) - await self.db.flush() - return { - "item_id": int(item.id), - "level": "command", - "project_id": int(matched_project_id) if matched_project_id is not None else None, - "signal_id": None, - "route_status": ROUTE_STATUS_QUEUED, - "clarification_message": blocking_reason, - } - - async def _record_committed_operation_item( - self, - *, - message: str, - sender: str, - source_chat_id: str, - source_sender_open_id: Optional[str], - feishu_message_id: Optional[str], - captured_at: Optional[datetime], - intent: str, - operation_code: str, - execution_result: dict[str, Any], - matched_project_id: Optional[int], - ) -> Optional[int]: - occurred_at = captured_at or _utc_now() - if occurred_at.tzinfo is not None: - occurred_at = occurred_at.astimezone(CN_TZ).replace(tzinfo=None) - item = FeishuItem( - tenant_id=self.tenant_id, - feishu_message_id=feishu_message_id, - project_score=1.0 if matched_project_id is not None else None, - signal_confidence=0.0 if matched_project_id is not None else None, - raw_message=message[:4000], - split_from_item_id=None, - level="command", - level_confidence=1.0, - level_reasoning=f"intent_gate:{intent}", - card_type=operation_code.replace(".", "_")[:30], - route_status=ROUTE_STATUS_AUTO_DONE, - matched_project_id=int(matched_project_id) if matched_project_id is not None else None, - source_chat_id=(source_chat_id or "")[:128] or None, - source_sender=(sender or "")[:120] or None, - source_sender_open_id=(source_sender_open_id or "")[:128] or None, - source_occurred_at=occurred_at, - ai_output=json.dumps({"execution_result": execution_result}, ensure_ascii=False)[:8000], - decision_reason="deterministic_operation_committed", - blocking_reason=None, - safe_to_auto_execute=True, - auto_execute_state="done", - message_class="auto_done", - occurrence_count=1, - next_step_text="真实提交已完成,结果已绑定 DB commit。", - queue_visible=False, - processed_at=_utc_now().replace(tzinfo=None), - result_payload={ - "intent": intent, - "executor": "explicit_service", - "operation_code": operation_code, - "result_summary": str(execution_result.get("message") or "").strip() or operation_code, - "operation_result": execution_result.get("result") if isinstance(execution_result.get("result"), dict) else {}, - "project_id": int(matched_project_id) if matched_project_id is not None else None, - "target_url": f"/projects/{int(matched_project_id)}/board" if matched_project_id is not None else None, - }, - ) - self.db.add(item) - await self.db.flush() - return int(item.id) - - async def _execute_controlled_operation( - self, - *, - operator_user: User, - operation_code: str, - payload: dict[str, Any], - trace_id: str, - ) -> dict[str, Any]: - logger.info( - "operation_started", - tenant_id=self.tenant_id, - trace_id=trace_id, - operation_code=operation_code, - payload=payload, - executor="explicit_service", - ) - if operation_code == "project.create": - result = await self._commit_project_create( - operator_user=operator_user, - payload=payload, - trace_id=trace_id, - ) - elif operation_code == "task.create": - result = await self._commit_task_create( - operator_user=operator_user, - payload=payload, - trace_id=trace_id, - ) - else: - raise ValueError("operation_not_supported") - execution = self._wrap_committed_operation(operation_code=operation_code, payload=payload, result=result) - first = execution["results"][0] if execution.get("results") else {} - event_name = "operation_committed" if str(first.get("status") or "") in {"applied", "skipped"} else "operation_failed" - logger.info( - event_name, - tenant_id=self.tenant_id, - trace_id=trace_id, - operation_code=operation_code, - result=first, - executor="explicit_service", - ) - return execution - - @staticmethod - def _wrap_committed_operation(*, operation_code: str, payload: dict[str, Any], result: dict[str, Any]) -> dict[str, Any]: - status = str(result.get("status") or "").strip().lower() - return { - "dry_run": False, - "requested_count": 1, - "applied_count": 1 if status == "applied" else 0, - "skipped_count": 1 if status == "skipped" else 0, - "failed_count": 1 if status == "failed" else 0, - "results": [ - { - "index": 1, - "operation_code": operation_code, - "payload": dict(payload), - **result, - } - ], - } - - async def _commit_project_create( - self, - *, - operator_user: User, - payload: dict[str, Any], - trace_id: str, - ) -> dict[str, Any]: - operator_user_id = _safe_int(getattr(operator_user, "id", None)) - if operator_user_id is None: - raise ValueError("operator_not_found") - - title = normalize_project_title_candidate(str(payload.get("title") or "")) - if not title: - raise ValueError("project title required") - description = str(payload.get("description") or "").strip()[:4000] or None - - existing = ( - await self.db.execute( - select(Project).where( - and_( - Project.tenant_id == self.tenant_id, - or_(func.lower(Project.title) == title.lower(), func.lower(Project.name) == title.lower()), - Project.status != "inactive", - ) - ).limit(1) - ) - ).scalar_one_or_none() - if existing is not None: - return { - "status": "skipped", - "message": "project_already_exists", - "entity_type": "project", - "entity_id": str(existing.id), - "result": { - "project_id": int(existing.id), - "title": str(existing.title or existing.name or "").strip(), - "status": str(existing.status or "").strip(), - }, - } - - await ensure_project_id_sequence(self.db) - project = Project( - tenant_id=self.tenant_id, - name=title, - title=title, - description=description, - owner_id=operator_user_id, - status="active", - domain_id=None, - assigned_role="ai_engineer", - signal_count=0, - ) - self.db.add(project) - await self.db.flush() - await ensure_project_default_members( - self.db, - tenant_id=self.tenant_id, - project_id=int(project.id), - owner_id=int(project.owner_id) if project.owner_id is not None else None, - assigned_role=project.assigned_role, - ) - await log_operation( - self.db, - tenant_id=self.tenant_id, - user_id=operator_user_id, - project_id=int(project.id), - entity_type="project", - entity_id=str(project.id), - action="project.create", - before_data=None, - after_data={ - "title": project.title, - "status": project.status, - "owner_id": int(project.owner_id) if project.owner_id is not None else None, - }, - remark=trace_id[:1000], - ) - await self.db.commit() - await context_cache_service.invalidate_system_context(tenant_id=self.tenant_id) - return { - "status": "applied", - "message": f"project created #{int(project.id)}", - "entity_type": "project", - "entity_id": str(project.id), - "result": { - "project_id": int(project.id), - "title": str(project.title or project.name or "").strip(), - "status": str(project.status or "").strip(), - }, - } - - async def _commit_task_create( - self, - *, - operator_user: User, - payload: dict[str, Any], - trace_id: str, - ) -> dict[str, Any]: - operator_user_id = _safe_int(getattr(operator_user, "id", None)) - if operator_user_id is None: - raise ValueError("operator_not_found") - project_id = _safe_int(payload.get("project_id")) - if project_id is None: - raise ValueError("project_context_missing") - project = ( - await self.db.execute( - select(Project).where( - and_( - Project.tenant_id == self.tenant_id, - Project.id == int(project_id), - ) - ) - ) - ).scalar_one_or_none() - if project is None: - raise ValueError("project_context_missing") - - charter = ( - await self.db.execute( - select(TruthNode).where( - and_( - TruthNode.tenant_id == self.tenant_id, - TruthNode.project_id == int(project_id), - TruthNode.node_type == "charter", - TruthNode.status.in_(tuple(ALIGNED_STATUSES)), - ) - ).order_by(TruthNode.updated_at.desc(), TruthNode.id.desc()) - ) - ).scalars().first() - if charter is None: - raise ValueError("aligned charter required before task.create") - - task = await persist_task( - self.db, - tenant_id=self.tenant_id, - truth_node_id=int(charter.id), - project_id=int(project_id), - draft=build_task_create_draft( - title=str(payload.get("title") or ""), - description=str(payload.get("description") or "").strip() or None, - priority=payload.get("priority"), - ), - actor_user_id=operator_user_id, - action="task.create", - remark=trace_id[:1000], - commit=True, - refresh_workload=False, - ) - await context_cache_service.invalidate_system_context(tenant_id=self.tenant_id) - self._sync_task_rag(task=task) - return { - "status": "applied", - "message": f"task created #{int(task.id)}", - "entity_type": "task", - "entity_id": str(task.id), - "result": { - "task_id": int(task.id), - "title": str(task.title or "").strip(), - "project_id": int(project_id), - }, - } - - def _sync_task_rag(self, *, task: Any) -> None: - try: - content = ( - (str(getattr(task, "description", "") or "")) - + f"\n状态: {str(getattr(task, 'status', '') or '')}" - + f"\n负责人: {int(getattr(task, 'assignee_id', 0) or 0) if getattr(task, 'assignee_id', None) else '未分配'}" - ) - asyncio.create_task( - AdaptiveRAGService.upsert_pmflow_record_async( - tenant_id=self.tenant_id, - record_type="task", - record_id=int(task.id), - project_id=int(task.project_id), - title=str(task.title or "任务"), - content=content, - ) - ) - except Exception: - return - - @staticmethod - def _derive_wrong_interpretation_from_recent_item(item: Optional[FeishuItem]) -> str: - if item is None: - return "unknown" - level = str(item.level or "").strip().lower() - if level == "project": - return "project_candidate" - if level == "signal": - return "signal_intake" - if level == "command": - return str(item.card_type or "command") - return level or "unknown" - - async def _maybe_write_intent_correction_sample( - self, - *, - message: str, - source_chat_id: str, - intent_gate: dict[str, Any], - ) -> None: - if self.db is None or not self._is_correction_message(message): - return - recent_items = await self._load_recent_chat_items(source_chat_id=source_chat_id, limit=3) if source_chat_id else [] - wrong_interpretation = self._derive_wrong_interpretation_from_recent_item(recent_items[0] if recent_items else None) - fields = intent_gate.get("fields") if isinstance(intent_gate.get("fields"), dict) else {} - clarification_template = str(intent_gate.get("clarification_template") or "").strip() - if not clarification_template: - if str(intent_gate.get("intent") or "") == INTENT_CREATE_PROJECT: - clarification_template = self._build_project_clarification_message( - list(fields.get("missing_fields") or []), - ) - elif str(intent_gate.get("intent") or "") == INTENT_CREATE_TASK: - clarification_template = self._build_task_clarification_message( - list(fields.get("missing_fields") or []), - ) - try: - await AdaptiveRAGService(self.db, tenant_id=self.tenant_id).upsert_intent_correction_sample( - user_utterance=message, - wrong_interpretation=wrong_interpretation, - correct_intent=str(intent_gate.get("intent") or INTENT_CLARIFICATION_REQUIRED), - canonical_action=str(intent_gate.get("canonical_action") or "").strip() or "clarify", - clarification_template=clarification_template, - risk_level=str(intent_gate.get("risk_level") or "medium"), - project_id=_safe_int(fields.get("project_id")), - ) - except Exception as exc: - logger.warning(f"intent_correction_write_failed: tenant_id={self.tenant_id}, error={exc}") - - async def _build_healthcheck_capture( - self, - *, - message: str, - feishu_message_id: Optional[str], - process_started_at: float, - ) -> dict[str, Any]: - duration_ms = int((time.perf_counter() - process_started_at) * 1000) - await self._write_processing_log( - feishu_message_id=feishu_message_id, - feishu_item_id=None, - raw_message=message, - ai_raw_output=json.dumps({"intent": INTENT_HEALTHCHECK}, ensure_ascii=False), - project_score=None, - signal_confidence=None, - level="healthcheck", - routing_decision=ROUTING_INTENT_GATE_HEALTHCHECK, - routing_reason="命中 healthcheck,直接返回执行链可用状态,不进入 Inbox。", - reasoning_bullets=[ - "intent gate 已识别为 healthcheck。", - "不涉及写库动作。", - "回执固定由后端生成。", - ], - processing_status="success", - error_message=None, - duration_ms=duration_ms, - normalized_text=message, - route=ROUTING_INTENT_GATE_HEALTHCHECK, - intent=INTENT_HEALTHCHECK, - executor="router", - reply_source="deterministic_reply", - ) - return { - "status": "answered", - "classification": "healthcheck", - "project_scope": "healthcheck", - "linked_project_ids": [], - "signal_id": None, - "created_project_id": None, - "auto_tasks": [], - "ai_trace_id": f"feishu-healthcheck-{(feishu_message_id or 'noid')[:32]}", - "results": [], - "intent": INTENT_HEALTHCHECK, - "direct_reply_required": True, - "direct_reply_text": "healthcheck=ok;执行链在线;仅真实 commit 后才会回“已执行”。", - } - - async def _handle_create_project_gate( - self, - *, - message: str, - sender: str, - source_chat_id: str, - source_sender_open_id: Optional[str], - feishu_message_id: Optional[str], - captured_at: Optional[datetime], - intent_gate: dict[str, Any], - process_started_at: float, - ) -> dict[str, Any]: - fields = intent_gate.get("fields") if isinstance(intent_gate.get("fields"), dict) else {} - clarification_message = str(intent_gate.get("clarification_template") or "").strip() or self._build_project_clarification_message( - list(fields.get("missing_fields") or []), - ) - occurred_at = captured_at or _utc_now() - if occurred_at.tzinfo is None: - occurred_at = occurred_at.replace(tzinfo=CN_TZ) - - operator_user = await self._resolve_command_operator_user(sender=sender) - if not fields.get("ready"): - await self._write_processing_log( - feishu_message_id=feishu_message_id, - feishu_item_id=None, - raw_message=message, - ai_raw_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False), - project_score=1.0, - signal_confidence=0.0, - level="project", - routing_decision=ROUTING_INTENT_GATE_CLARIFICATION, - routing_reason="建项目缺少必填字段,已转待澄清,禁止猜测执行。", - reasoning_bullets=[ - "intent gate 命中 create_project。", - "当前缺少 project.create 必填字段。", - "仅保留结构化待澄清项,不触发执行器。", - ], - processing_status="success", - error_message=None, - duration_ms=int((time.perf_counter() - process_started_at) * 1000), - normalized_text=message, - route=ROUTING_INTENT_GATE_CLARIFICATION, - intent=INTENT_CLARIFICATION_REQUIRED, - executor="explicit_service", - operation_code="project.create", - reply_source="deterministic_reply", - ) - return { - "status": "captured", - "classification": "project", - "project_scope": "project", - "linked_project_ids": [], - "signal_id": None, - "created_project_id": None, - "auto_tasks": [], - "ai_trace_id": f"feishu-project-clarify-{(feishu_message_id or 'noid')[:32]}", - "results": [], - "intent": INTENT_CLARIFICATION_REQUIRED, - "clarification_message": clarification_message, - "direct_reply_required": True, - "direct_reply_text": clarification_message, - } - - if not self._can_execute_high_value_action(operator_user): - queued = await self._queue_project_intent_item( - message=message, - sender=sender, - source_chat_id=source_chat_id, - source_sender_open_id=source_sender_open_id, - feishu_message_id=feishu_message_id, - occurred_at=occurred_at, - intent_gate=intent_gate, - blocking_reason=clarification_message if not fields.get("ready") else "建项目属于高风险动作,当前需要你确认后再执行。", - decision_reason="high_value_action_guard", - ) - await self._write_processing_log( - feishu_message_id=feishu_message_id, - feishu_item_id=_safe_int(queued.get("item_id")), - raw_message=message, - ai_raw_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False), - project_score=1.0, - signal_confidence=0.0, - level="project", - routing_decision=ROUTING_INTENT_GATE_CLARIFICATION, - routing_reason="建项目命中高风险保护,已转待决策/待澄清。", - reasoning_bullets=[ - "intent gate 命中 create_project。", - "当前未满足自动执行条件。", - "仅保留结构化待决策项,不做猜测执行。", - ], - processing_status="success", - error_message=None, - duration_ms=int((time.perf_counter() - process_started_at) * 1000), - normalized_text=message, - route=ROUTING_INTENT_GATE_CLARIFICATION, - intent=INTENT_CLARIFICATION_REQUIRED if not fields.get("ready") else INTENT_CREATE_PROJECT, - executor="explicit_service", - operation_code="project.create", - reply_source="deterministic_reply", - ) - return { - "status": "captured", - "classification": "project", - "project_scope": "project", - "linked_project_ids": [], - "signal_id": None, - "created_project_id": None, - "auto_tasks": [], - "ai_trace_id": f"feishu-project-pending-{queued.get('item_id')}", - "results": [queued], - "intent": INTENT_CLARIFICATION_REQUIRED if not fields.get("ready") else INTENT_CREATE_PROJECT, - "clarification_message": queued.get("clarification_message"), - "direct_reply_required": True, - "direct_reply_text": str(queued.get("clarification_message") or "已转待决策。"), - } - - payload_description = str(fields.get("description") or "").strip() - project_scope = str(fields.get("project_scope") or "").strip() - if project_scope and project_scope not in payload_description: - payload_description = f"{payload_description}\n\n项目范围:{project_scope}".strip() - payload = { - "title": str(fields.get("title") or "").strip(), - "description": payload_description, - } - trace_id = f"intent-project-{self._stable_positive_int(message, sender, feishu_message_id)}" - execution = await self._execute_controlled_operation( - operator_user=operator_user, - operation_code="project.create", - payload=payload, - trace_id=trace_id, - ) - first = execution["results"][0] if execution.get("results") else {} - status = str(first.get("status") or "").strip().lower() - if status not in {"applied", "skipped"}: - raise ValueError(str(first.get("message") or "project.create failed")) - - project_result = first.get("result") if isinstance(first.get("result"), dict) else {} - project_id = _safe_int(project_result.get("project_id") or first.get("entity_id")) - item_id = await self._record_committed_operation_item( - message=message, - sender=sender, - source_chat_id=source_chat_id, - source_sender_open_id=source_sender_open_id, - feishu_message_id=feishu_message_id, - captured_at=captured_at, - intent=INTENT_CREATE_PROJECT, - operation_code="project.create", - execution_result=first, - matched_project_id=project_id, - ) - reply_text = self._build_operation_commit_reply({"execution": {"results": [first], "applied_count": 1 if status == "applied" else 0, "skipped_count": 1 if status == "skipped" else 0, "failed_count": 0}}) - await self._write_processing_log( - feishu_message_id=feishu_message_id, - feishu_item_id=item_id, - raw_message=message, - ai_raw_output=json.dumps({"intent_gate": intent_gate, "execution": execution}, ensure_ascii=False)[:120000], - project_score=1.0, - signal_confidence=0.0, - level="project", - routing_decision=ROUTING_OPERATION_COMMITTED, - routing_reason="建项目已通过 deterministic operation executor 提交,并以真实 DB 结果回执。", - reasoning_bullets=[ - "intent gate 命中 create_project。", - "执行器仅走 project.create。", - "回复绑定真实 commit 结果。", - ], - processing_status="success", - error_message=None, - duration_ms=int((time.perf_counter() - process_started_at) * 1000), - normalized_text=message, - route=ROUTING_OPERATION_COMMITTED, - intent=INTENT_CREATE_PROJECT, - executor="explicit_service", - operation_code="project.create", - operation_result=first if isinstance(first, dict) else None, - reply_source="db_commit", - ) - return { - "status": "captured", - "classification": "project", - "project_scope": "project", - "linked_project_ids": [int(project_id)] if project_id is not None else [], - "signal_id": None, - "created_project_id": int(project_id) if project_id is not None else None, - "auto_tasks": [], - "ai_trace_id": trace_id, - "results": [ - { - "item_id": item_id, - "level": "command", - "project_id": int(project_id) if project_id is not None else None, - "signal_id": None, - "route_status": ROUTE_STATUS_AUTO_DONE, - } - ], - "intent": INTENT_CREATE_PROJECT, - "direct_reply_required": True, - "direct_reply_text": reply_text, - "command_result": {"execution": execution}, - } - - async def _handle_create_task_gate( - self, - *, - message: str, - sender: str, - source_chat_id: str, - source_sender_open_id: Optional[str], - feishu_message_id: Optional[str], - captured_at: Optional[datetime], - intent_gate: dict[str, Any], - process_started_at: float, - ) -> dict[str, Any]: - fields = intent_gate.get("fields") if isinstance(intent_gate.get("fields"), dict) else {} - operation_payload = { - "project_id": _safe_int(fields.get("project_id")), - "title": str(fields.get("title") or "").strip(), - "description": str(fields.get("description") or "").strip() or None, - } - clarification_message = str(intent_gate.get("clarification_template") or "").strip() or self._build_task_clarification_message( - list(fields.get("missing_fields") or []), - ) - occurred_at = captured_at or _utc_now() - if occurred_at.tzinfo is None: - occurred_at = occurred_at.replace(tzinfo=CN_TZ) - - operator_user = await self._resolve_command_operator_user(sender=sender) - if not fields.get("ready"): - await self._write_processing_log( - feishu_message_id=feishu_message_id, - feishu_item_id=None, - raw_message=message, - ai_raw_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False), - project_score=1.0 if fields.get("project_id") is not None else None, - signal_confidence=0.0 if fields.get("project_id") is not None else None, - level="command", - routing_decision=ROUTING_INTENT_GATE_CLARIFICATION, - routing_reason="建任务缺少必填字段,已转待澄清,禁止猜测执行。", - reasoning_bullets=[ - "intent gate 命中 create_task。", - "当前缺少 task.create 必填字段。", - "仅保留结构化待澄清项,不触发执行器。", - ], - processing_status="success", - error_message=None, - duration_ms=int((time.perf_counter() - process_started_at) * 1000), - normalized_text=message, - route=ROUTING_INTENT_GATE_CLARIFICATION, - intent=INTENT_CLARIFICATION_REQUIRED, - executor="explicit_service", - operation_code="task.create", - reply_source="deterministic_reply", - ) - return { - "status": "captured", - "classification": "command", - "project_scope": "command", - "linked_project_ids": [int(fields["project_id"])] if fields.get("project_id") is not None else [], - "signal_id": None, - "created_project_id": None, - "auto_tasks": [], - "ai_trace_id": f"feishu-task-clarify-{(feishu_message_id or 'noid')[:32]}", - "results": [], - "intent": INTENT_CLARIFICATION_REQUIRED, - "clarification_message": clarification_message, - "direct_reply_required": True, - "direct_reply_text": clarification_message, - } - - if not self._can_execute_high_value_action(operator_user): - queued = await self._queue_command_intent_item( - message=message, - sender=sender, - source_chat_id=source_chat_id, - source_sender_open_id=source_sender_open_id, - feishu_message_id=feishu_message_id, - occurred_at=occurred_at, - intent_gate=intent_gate, - operation_code="task.create", - operation_payload={k: v for k, v in operation_payload.items() if v is not None}, - blocking_reason=clarification_message if not fields.get("ready") else "建任务属于高风险动作,当前需要你确认后再执行。", - decision_reason="high_value_action_guard", - matched_project_id=_safe_int(fields.get("project_id")), - ) - await self._write_processing_log( - feishu_message_id=feishu_message_id, - feishu_item_id=_safe_int(queued.get("item_id")), - raw_message=message, - ai_raw_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False), - project_score=1.0 if fields.get("project_id") is not None else None, - signal_confidence=0.0 if fields.get("project_id") is not None else None, - level="command", - routing_decision=ROUTING_INTENT_GATE_CLARIFICATION, - routing_reason="建任务命中高风险保护,已转待决策/待澄清。", - reasoning_bullets=[ - "intent gate 命中 create_task。", - "当前未满足自动执行条件。", - "仅保留结构化待决策项,不做猜测执行。", - ], - processing_status="success", - error_message=None, - duration_ms=int((time.perf_counter() - process_started_at) * 1000), - normalized_text=message, - route=ROUTING_INTENT_GATE_CLARIFICATION, - intent=INTENT_CLARIFICATION_REQUIRED if not fields.get("ready") else INTENT_CREATE_TASK, - executor="explicit_service", - operation_code="task.create", - reply_source="deterministic_reply", - ) - return { - "status": "captured", - "classification": "command", - "project_scope": "command", - "linked_project_ids": [int(fields["project_id"])] if fields.get("project_id") is not None else [], - "signal_id": None, - "created_project_id": None, - "auto_tasks": [], - "ai_trace_id": f"feishu-task-pending-{queued.get('item_id')}", - "results": [queued], - "intent": INTENT_CLARIFICATION_REQUIRED if not fields.get("ready") else INTENT_CREATE_TASK, - "clarification_message": queued.get("clarification_message"), - "direct_reply_required": True, - "direct_reply_text": str(queued.get("clarification_message") or "已转待决策。"), - } - - trace_id = f"intent-task-{self._stable_positive_int(message, sender, feishu_message_id)}" - execution = await self._execute_controlled_operation( - operator_user=operator_user, - operation_code="task.create", - payload={k: v for k, v in operation_payload.items() if v is not None}, - trace_id=trace_id, - ) - first = execution["results"][0] if execution.get("results") else {} - status = str(first.get("status") or "").strip().lower() - if status not in {"applied", "skipped"}: - raise ValueError(str(first.get("message") or "task.create failed")) - project_id = _safe_int(fields.get("project_id")) - item_id = await self._record_committed_operation_item( - message=message, - sender=sender, - source_chat_id=source_chat_id, - source_sender_open_id=source_sender_open_id, - feishu_message_id=feishu_message_id, - captured_at=captured_at, - intent=INTENT_CREATE_TASK, - operation_code="task.create", - execution_result=first, - matched_project_id=project_id, - ) - reply_text = self._build_operation_commit_reply({"execution": {"results": [first], "applied_count": 1 if status == "applied" else 0, "skipped_count": 1 if status == "skipped" else 0, "failed_count": 0}}) - await self._write_processing_log( - feishu_message_id=feishu_message_id, - feishu_item_id=item_id, - raw_message=message, - ai_raw_output=json.dumps({"intent_gate": intent_gate, "execution": execution}, ensure_ascii=False)[:120000], - project_score=1.0 if project_id is not None else None, - signal_confidence=0.0 if project_id is not None else None, - level="command", - routing_decision=ROUTING_OPERATION_COMMITTED, - routing_reason="建任务已通过 deterministic operation executor 提交,并以真实 DB 结果回执。", - reasoning_bullets=[ - "intent gate 命中 create_task。", - "执行器仅走 task.create。", - "回复绑定真实 commit 结果。", - ], - processing_status="success", - error_message=None, - duration_ms=int((time.perf_counter() - process_started_at) * 1000), - normalized_text=message, - route=ROUTING_OPERATION_COMMITTED, - intent=INTENT_CREATE_TASK, - executor="explicit_service", - operation_code="task.create", - operation_result=first if isinstance(first, dict) else None, - reply_source="db_commit", - ) - return { - "status": "captured", - "classification": "command", - "project_scope": "command", - "linked_project_ids": [int(project_id)] if project_id is not None else [], - "signal_id": None, - "created_project_id": None, - "auto_tasks": [], - "ai_trace_id": trace_id, - "results": [ - { - "item_id": item_id, - "level": "command", - "project_id": int(project_id) if project_id is not None else None, - "signal_id": None, - "route_status": ROUTE_STATUS_AUTO_DONE, - } - ], - "intent": INTENT_CREATE_TASK, - "direct_reply_required": True, - "direct_reply_text": reply_text, - "command_result": {"execution": execution}, - } - - async def _handle_external_connect_gate( - self, - *, - message: str, - sender: str, - source_chat_id: str, - source_sender_open_id: Optional[str], - feishu_message_id: Optional[str], - captured_at: Optional[datetime], - intent_gate: dict[str, Any], - process_started_at: float, - ) -> dict[str, Any]: - occurred_at = captured_at or _utc_now() - if occurred_at.tzinfo is None: - occurred_at = occurred_at.replace(tzinfo=CN_TZ) - fields = intent_gate.get("fields") if isinstance(intent_gate.get("fields"), dict) else {} - blocking_reason = str(intent_gate.get("clarification_template") or "").strip() or "外部系统接入默认进入待决策队列。" - queued = await self._queue_command_intent_item( - message=message, - sender=sender, - source_chat_id=source_chat_id, - source_sender_open_id=source_sender_open_id, - feishu_message_id=feishu_message_id, - occurred_at=occurred_at, - intent_gate=intent_gate, - operation_code="source.connect", - operation_payload={"project_id": _safe_int(fields.get("project_id"))}, - blocking_reason=blocking_reason, - decision_reason="external_connect_requires_manual_decision", - matched_project_id=_safe_int(fields.get("project_id")), - ) - await self._write_processing_log( - feishu_message_id=feishu_message_id, - feishu_item_id=_safe_int(queued.get("item_id")), - raw_message=message, - ai_raw_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False), - project_score=1.0 if fields.get("project_id") is not None else None, - signal_confidence=0.0 if fields.get("project_id") is not None else None, - level="command", - routing_decision=ROUTING_INTENT_GATE_EXTERNAL_CONNECT, - routing_reason="外部系统接入被收口到待决策队列,不允许自由执行。", - reasoning_bullets=[ - "intent gate 命中 external_connect。", - "当前仅允许受控工具链执行。", - "系统已转待决策,不做任意 HTTP/shell。", - ], - processing_status="success", - error_message=None, - duration_ms=int((time.perf_counter() - process_started_at) * 1000), - normalized_text=message, - route=ROUTING_INTENT_GATE_EXTERNAL_CONNECT, - intent=INTENT_EXTERNAL_CONNECT, - executor="router", - operation_code="source.connect", - reply_source="deterministic_reply", - ) - return { - "status": "captured", - "classification": "command", - "project_scope": "command", - "linked_project_ids": [int(fields["project_id"])] if fields.get("project_id") is not None else [], - "signal_id": None, - "created_project_id": None, - "auto_tasks": [], - "ai_trace_id": f"feishu-external-{queued.get('item_id')}", - "results": [queued], - "intent": INTENT_EXTERNAL_CONNECT, - "clarification_message": queued.get("clarification_message"), - "direct_reply_required": True, - "direct_reply_text": str(queued.get("clarification_message") or blocking_reason), - } - - async def process_message( - self, - *, - message_content: str, - sender: str, - source_chat_id: str, - source_sender_open_id: Optional[str] = None, - feishu_message_id: Optional[str] = None, - captured_at: Optional[datetime] = None, - chat_type: Optional[str] = None, - ) -> dict[str, Any]: - process_started_at = time.perf_counter() - normalized_chat_type = self._normalize_chat_type(chat_type=chat_type, source_chat_id=source_chat_id) - message = self._normalize_incoming_text(message_content) - if not message: - return self._decorate_capture_result( - {"status": "ignored", "reason": "empty_message", "results": []}, - chat_type=normalized_chat_type, - normalized_text=message, - route=f"{normalized_chat_type}.empty_message", - db_mutations=[], - ) - - if self._is_water_talk_message(message): - duration_ms = int((time.perf_counter() - process_started_at) * 1000) - await self._write_processing_log( - feishu_message_id=feishu_message_id, - feishu_item_id=None, - raw_message=message, - ai_raw_output=None, - project_score=None, - signal_confidence=None, - level=None, - routing_decision=ROUTING_NOISE_FILTERED, - routing_reason="识别为闲聊/问答水话,已过滤且不进入待处理队列。", - reasoning_bullets=[ - "消息缺少可执行交付信息。", - "不满足需求信号的最小结构(目标/对象/动作)。", - "已按噪音消息忽略,不触发入库与排队。", - ], - processing_status="success", - error_message=None, - duration_ms=duration_ms, - ) - await self.db.commit() - return self._decorate_capture_result({ - "status": "ignored", - "reason": ROUTING_NOISE_FILTERED, - "classification": "non_requirement", - "project_scope": "non_requirement", - "linked_project_ids": [], - "signal_id": None, - "created_project_id": None, - "auto_tasks": [], - "ai_trace_id": f"feishu-noise-{(feishu_message_id or 'noid')[:32]}", - "results": [], - }, chat_type=normalized_chat_type, normalized_text=message, route=f"{normalized_chat_type}.noise_filtered", db_mutations=[]) - - if feishu_message_id: - await self._acquire_message_lock(feishu_message_id) - - if feishu_message_id and await self._has_processed_message(feishu_message_id): - duration_ms = int((time.perf_counter() - process_started_at) * 1000) - await self._write_processing_log( - feishu_message_id=feishu_message_id, - feishu_item_id=None, - raw_message=message, - ai_raw_output=None, - project_score=None, - signal_confidence=None, - level=None, - routing_decision=ROUTING_DUPLICATE_FILTERED, - routing_reason="检测到重复飞书消息 ID,已过滤且不再重复执行 AI 处理。", - reasoning_bullets=[ - "消息唯一标识已存在于处理日志中。", - "本次请求被标记为重复消息。", - "系统已跳过后续分类和入库动作。", - ], - processing_status="success", - error_message=None, - duration_ms=duration_ms, - ) - await self.db.commit() - return self._decorate_capture_result({ - "status": "ignored", - "reason": ROUTING_DUPLICATE_FILTERED, - "classification": "duplicate", - "project_scope": "duplicate", - "linked_project_ids": [], - "signal_id": None, - "created_project_id": None, - "auto_tasks": [], - "ai_trace_id": f"feishu-duplicate-{feishu_message_id}", - "results": [], - }, chat_type=normalized_chat_type, normalized_text=message, route=f"{normalized_chat_type}.duplicate_filtered", db_mutations=[]) - - projects = await self._load_active_projects() - chat_context_resolution = { - "project_id": None, - "ambiguous": False, - "candidate_ids": [], - "top_ratio": 0.0, - } - if source_chat_id: - try: - chat_context_resolution = await self._resolve_chat_context_project(source_chat_id=source_chat_id) - except Exception: - chat_context_resolution = { - "project_id": None, - "ambiguous": False, - "candidate_ids": [], - "top_ratio": 0.0, - } - inferred_project_id = _safe_int(chat_context_resolution.get("project_id")) - - followup_capture = await self._maybe_handle_queue_followup( - message=message, - sender=sender, - source_chat_id=source_chat_id, - feishu_message_id=feishu_message_id, - process_started_at=process_started_at, - ) - if followup_capture is not None: - await self.db.commit() - return self._decorate_capture_result( - followup_capture, - chat_type=normalized_chat_type, - normalized_text=message, - route=f"{normalized_chat_type}.followup", - db_mutations=self._summarize_db_mutations(followup_capture), - ) - - lookup_capture = await self._maybe_answer_project_lookup_question( - message=message, - feishu_message_id=feishu_message_id, - process_started_at=process_started_at, - projects=projects, - ) - if lookup_capture is not None: - await self.db.commit() - return self._decorate_capture_result( - lookup_capture, - chat_type=normalized_chat_type, - normalized_text=message, - route=f"{normalized_chat_type}.project_lookup", - db_mutations=[], - ) - - intent_gate = await self._resolve_intent_gate( - message=message, - inferred_project_id=inferred_project_id, - source_chat_id=source_chat_id, - source_sender_open_id=source_sender_open_id, - sender=sender, - ) - logger.info( - "intent_resolved", - tenant_id=self.tenant_id, - feishu_message_id=feishu_message_id, - intent=str(intent_gate.get("intent") or ""), - canonical_action=str(intent_gate.get("canonical_action") or ""), - normalized_text=message[:400], - ) - await self._maybe_write_intent_correction_sample( - message=message, - source_chat_id=source_chat_id, - intent_gate=intent_gate, - ) - - gate_intent = str(intent_gate.get("intent") or INTENT_SIGNAL_INTAKE) - if gate_intent == INTENT_SMALLTALK: - duration_ms = int((time.perf_counter() - process_started_at) * 1000) - await self._write_processing_log( - feishu_message_id=feishu_message_id, - feishu_item_id=None, - raw_message=message, - ai_raw_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False), - project_score=None, - signal_confidence=None, - level="smalltalk", - routing_decision=ROUTING_NOISE_FILTERED, - routing_reason="intent gate 识别为 smalltalk/短确认,不进入 Inbox。", - reasoning_bullets=[ - "消息不包含可执行主数据动作。", - "命中 short confirmation / smalltalk。", - "系统直接忽略,不写通知。", - ], - processing_status="success", - error_message=None, - duration_ms=duration_ms, - ) - await self.db.commit() - return self._decorate_capture_result( - { - "status": "ignored", - "reason": ROUTING_NOISE_FILTERED, - "classification": "smalltalk", - "project_scope": "smalltalk", - "linked_project_ids": [], - "signal_id": None, - "created_project_id": None, - "auto_tasks": [], - "ai_trace_id": f"feishu-smalltalk-{(feishu_message_id or 'noid')[:32]}", - "results": [], - }, - chat_type=normalized_chat_type, - normalized_text=message, - route=f"{normalized_chat_type}.intent_gate.smalltalk", - db_mutations=[], - ) - - if gate_intent == INTENT_HEALTHCHECK: - healthcheck_capture = await self._build_healthcheck_capture( - message=message, - feishu_message_id=feishu_message_id, - process_started_at=process_started_at, - ) - await self.db.commit() - return self._decorate_capture_result( - healthcheck_capture, - chat_type=normalized_chat_type, - normalized_text=message, - route=f"{normalized_chat_type}.intent_gate.healthcheck", - db_mutations=[], - ) - - if gate_intent == INTENT_QUERY: - rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) - conversation_key = self._build_rag_conversation_key( - source_chat_id=source_chat_id, - source_sender_open_id=source_sender_open_id, - sender=sender, - ) - rag_result = await rag_service.answer_question( - question=message, - asked_by=None, - asked_via="feishu", - project_id=inferred_project_id, - source_chat_id=source_chat_id, - conversation_key=conversation_key, - ) - qa_capture = await self._build_direct_qa_capture( - message=message, - rag_result=rag_result, - inferred_project_id=inferred_project_id, - feishu_message_id=feishu_message_id, - process_started_at=process_started_at, - route_reason="intent gate 判定为 query,直接走 RAG 直答通道,不进入决策队列。", - intent_label=INTENT_QUERY, - ) - await self.db.commit() - return self._decorate_capture_result( - qa_capture, - chat_type=normalized_chat_type, - normalized_text=message, - route=f"{normalized_chat_type}.intent_gate.query", - db_mutations=[], - ) - - if gate_intent == INTENT_CREATE_PROJECT or ( - gate_intent == INTENT_CLARIFICATION_REQUIRED and str(intent_gate.get("canonical_action") or "") == "project.create" - ): - project_capture = await self._handle_create_project_gate( - message=message, - sender=sender, - source_chat_id=source_chat_id, - source_sender_open_id=source_sender_open_id, - feishu_message_id=feishu_message_id, - captured_at=captured_at, - intent_gate=intent_gate, - process_started_at=process_started_at, - ) - await self.db.commit() - return self._decorate_capture_result( - project_capture, - chat_type=normalized_chat_type, - normalized_text=message, - route=f"{normalized_chat_type}.intent_gate.project_create", - db_mutations=self._summarize_db_mutations(project_capture), - ) - - if gate_intent == INTENT_CREATE_TASK or ( - gate_intent == INTENT_CLARIFICATION_REQUIRED and str(intent_gate.get("canonical_action") or "") == "task.create" - ): - task_capture = await self._handle_create_task_gate( - message=message, - sender=sender, - source_chat_id=source_chat_id, - source_sender_open_id=source_sender_open_id, - feishu_message_id=feishu_message_id, - captured_at=captured_at, - intent_gate=intent_gate, - process_started_at=process_started_at, - ) - await self.db.commit() - return self._decorate_capture_result( - task_capture, - chat_type=normalized_chat_type, - normalized_text=message, - route=f"{normalized_chat_type}.intent_gate.task_create", - db_mutations=self._summarize_db_mutations(task_capture), - ) - - if gate_intent == INTENT_EXTERNAL_CONNECT: - external_capture = await self._handle_external_connect_gate( - message=message, - sender=sender, - source_chat_id=source_chat_id, - source_sender_open_id=source_sender_open_id, - feishu_message_id=feishu_message_id, - captured_at=captured_at, - intent_gate=intent_gate, - process_started_at=process_started_at, - ) - await self.db.commit() - return self._decorate_capture_result( - external_capture, - chat_type=normalized_chat_type, - normalized_text=message, - route=f"{normalized_chat_type}.intent_gate.external_connect", - db_mutations=self._summarize_db_mutations(external_capture), - ) - - command_capture = await self._maybe_block_legacy_command_request( - message=message, - sender=sender, - source_chat_id=source_chat_id, - source_sender_open_id=source_sender_open_id, - feishu_message_id=feishu_message_id, - captured_at=captured_at, - inferred_project_id=inferred_project_id, - projects=projects, - process_started_at=process_started_at, - ) - if command_capture is not None: - await self.db.commit() - return self._decorate_capture_result( - command_capture, - chat_type=normalized_chat_type, - normalized_text=message, - route=f"{normalized_chat_type}.command", - db_mutations=self._summarize_db_mutations(command_capture), - ) - - rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) - conversation_key = self._build_rag_conversation_key( - source_chat_id=source_chat_id, - source_sender_open_id=source_sender_open_id, - sender=sender, - ) - - sidecar_rag_result: Optional[dict[str, Any]] = None - intent_for_response = "statement" - force_signal_fastpath = False - rag_question_part = message - - if self._is_obvious_question(message): - rag_result = await rag_service.answer_question( - question=rag_question_part, - asked_by=None, - asked_via="feishu", - project_id=inferred_project_id, - source_chat_id=source_chat_id, - conversation_key=conversation_key, - ) - if self._is_requirement_like_question(message): - sidecar_rag_result = rag_result - intent_for_response = "question_requirement" - message = self._extract_requirement_statement_from_question(message) - if not message: - message = self._normalize_incoming_text(message_content) - force_signal_fastpath = True - else: - qa_capture = await self._build_direct_qa_capture( - message=message, - rag_result=rag_result, - inferred_project_id=inferred_project_id, - feishu_message_id=feishu_message_id, - process_started_at=process_started_at, - route_reason="明显问句直接走 RAG 直答通道,不进入决策队列。", - intent_label="question_obvious", - ) - await self.db.commit() - return self._decorate_capture_result( - qa_capture, - chat_type=normalized_chat_type, - normalized_text=message, - route=f"{normalized_chat_type}.qa_direct", - db_mutations=[], - ) - else: - intent_result = await rag_service.classify_intent(message) - intent = str(intent_result.get("intent") or "statement").lower() - intent_for_response = intent - rag_question_part = str(intent_result.get("question_part") or "").strip() or message - rag_statement_part = str(intent_result.get("statement_part") or "").strip() - - if intent == "question": - rag_result = await rag_service.answer_question( - question=rag_question_part, - asked_by=None, - asked_via="feishu", - project_id=inferred_project_id, - source_chat_id=source_chat_id, - conversation_key=conversation_key, - ) - if self._is_requirement_like_question(message): - sidecar_rag_result = rag_result - intent_for_response = "question_requirement" - message = self._extract_requirement_statement_from_question(message) - if not message: - message = self._normalize_incoming_text(message_content) - force_signal_fastpath = True - else: - qa_capture = await self._build_direct_qa_capture( - message=message, - rag_result=rag_result, - inferred_project_id=inferred_project_id, - feishu_message_id=feishu_message_id, - process_started_at=process_started_at, - route_reason="识别为问答消息,已走 RAG 直答通道,不进入决策队列。", - intent_label=intent, - ) - await self.db.commit() - return self._decorate_capture_result( - qa_capture, - chat_type=normalized_chat_type, - normalized_text=message, - route=f"{normalized_chat_type}.qa_direct", - db_mutations=[], - ) - - if intent == "mixed": - mixed_rag_result = await rag_service.answer_question( - question=rag_question_part, - asked_by=None, - asked_via="feishu", - project_id=inferred_project_id, - source_chat_id=source_chat_id, - conversation_key=conversation_key, - ) - if rag_statement_part: - message = rag_statement_part - sidecar_rag_result = mixed_rag_result - - try: - if force_signal_fastpath: - level_result = self._heuristic_level_fallback(message) - else: - level_result = await self._classify_level(message=message, projects=projects) - project_score = _safe_float(level_result.get("project_score"), 0.0) - level = _normalize_level_value( - level_result.get("level"), - default="project" if project_score >= 0.6 else "signal", - ) - level_confidence = _safe_float(level_result.get("level_confidence"), project_score) - level_reasoning = str(level_result.get("level_reasoning") or "").strip() - - split_items = [] - seen_split_items: set[tuple[str, str]] = set() - raw_split_items = level_result.get("split_items") - should_split = bool(level_result.get("should_split")) - if should_split and isinstance(raw_split_items, list): - for item in raw_split_items[:2]: - if not isinstance(item, dict): - continue - item_level = _normalize_level_value(item.get("level"), default=level) - item_content = str(item.get("content") or "").strip() - if item_level in {"project", "signal"} and item_content: - dedupe_key = (item_level, item_content) - if dedupe_key in seen_split_items: - continue - seen_split_items.add(dedupe_key) - split_items.append({"level": item_level, "content": item_content}) - if not split_items: - split_items = [{"level": level if level in {"project", "signal"} else "signal", "content": message}] - - routing_policy = await self._load_routing_policy() - occurred_at = captured_at or _utc_now() - if occurred_at.tzinfo is None: - occurred_at = occurred_at.replace(tzinfo=CN_TZ) - - results: list[dict[str, Any]] = [] - parent_item_id: Optional[int] = None - for idx, split in enumerate(split_items): - item_result = await self._process_one_item( - content=split["content"], - level=split["level"], - sender=sender, - source_chat_id=source_chat_id, - source_sender_open_id=source_sender_open_id, - feishu_message_id=feishu_message_id, - occurred_at=occurred_at, - level_result=level_result, - level_confidence=level_confidence, - level_reasoning=level_reasoning, - project_score=project_score, - cold_start_enabled=bool(routing_policy["cold_start_enabled"]), - signal_auto_enter_threshold=float(routing_policy["signal_auto_enter_threshold"]), - project_auto_create_threshold=float(routing_policy["project_auto_create_threshold"]), - project_uncertain_threshold=float(routing_policy["project_uncertain_threshold"]), - chat_context_auto_link_enabled=bool(routing_policy["chat_context_auto_link_enabled"]), - projects=projects, - split_from_item_id=parent_item_id if idx > 0 else None, - chat_type=normalized_chat_type, - chat_context_resolution=chat_context_resolution, - ) - if idx == 0 and item_result.get("item_id"): - parent_item_id = int(item_result["item_id"]) - results.append(item_result) - - await self.db.commit() - - signal_ids = [int(x["signal_id"]) for x in results if x.get("signal_id")] - linked_projects = sorted( - { - int(x["project_id"]) - for x in results - if x.get("project_id") is not None - } - ) - classification = "split" if len(results) > 1 else (results[0].get("level") or "signal") - clarification_message = next( - ( - str(row.get("clarification_message") or "").strip() - for row in results - if isinstance(row, dict) and str(row.get("clarification_message") or "").strip() - ), - "", - ) - direct_reply_text: Optional[str] = None - if sidecar_rag_result: - sidecar_answer = str(sidecar_rag_result.get("answer") or "").strip() - if sidecar_answer: - direct_reply_text = ( - self._build_rag_reply_text( - answer=sidecar_answer, - sources=sidecar_rag_result.get("sources") or [], - ) - + "\n\n我已回答你的问题,并把需求部分记录为信号进入处理队列。" - ) - return self._decorate_capture_result({ - "status": "captured", - "classification": classification, - "project_scope": classification, - "linked_project_ids": linked_projects, - "signal_id": str(signal_ids[0]) if signal_ids else None, - "created_project_id": linked_projects[0] if linked_projects else None, - "auto_tasks": [], - "ai_trace_id": f"feishu-item-{results[0].get('item_id')}" if results else None, - "results": results, - "intent": intent_for_response, - "rag_result": sidecar_rag_result, - "direct_reply_required": bool(direct_reply_text), - "direct_reply_text": direct_reply_text, - "clarification_message": clarification_message or None, - }, chat_type=normalized_chat_type, normalized_text=message, route=f"{normalized_chat_type}.{classification}", db_mutations=self._summarize_db_mutations({"results": results, "signal_id": str(signal_ids[0]) if signal_ids else None, "created_project_id": linked_projects[0] if linked_projects else None})) - except IntegrityError as exc: - await self.db.rollback() - if feishu_message_id and self._is_duplicate_integrity_error(exc): - duration_ms = int((time.perf_counter() - process_started_at) * 1000) - await self._write_processing_log( - feishu_message_id=feishu_message_id, - feishu_item_id=None, - raw_message=message, - ai_raw_output=None, - project_score=None, - signal_confidence=None, - level=None, - routing_decision=ROUTING_DUPLICATE_FILTERED, - routing_reason="唯一约束命中,判定为重复飞书消息,已跳过重复处理。", - reasoning_bullets=[ - "数据库唯一约束阻止了重复入库。", - "当前请求被收敛到 duplicate 模板回执。", - "后续不再触发 AI 分类与入库动作。", - ], - processing_status="success", - error_message=None, - duration_ms=duration_ms, - ) - await self.db.commit() - return self._decorate_capture_result({ - "status": "ignored", - "reason": ROUTING_DUPLICATE_FILTERED, - "classification": "duplicate", - "project_scope": "duplicate", - "linked_project_ids": [], - "signal_id": None, - "created_project_id": None, - "auto_tasks": [], - "ai_trace_id": f"feishu-duplicate-{feishu_message_id}", - "results": [], - }, chat_type=normalized_chat_type, normalized_text=message, route=f"{normalized_chat_type}.duplicate_integrity", db_mutations=[]) - - duration_ms = int((time.perf_counter() - process_started_at) * 1000) - await self._write_processing_log( - feishu_message_id=feishu_message_id, - feishu_item_id=None, - raw_message=message, - ai_raw_output=None, - project_score=None, - signal_confidence=None, - level=None, - routing_decision=ROUTING_FAILED, - routing_reason="数据库完整性校验失败,已停止当前消息处理。", - reasoning_bullets=[ - f"异常类型:{exc.__class__.__name__}", - "可能原因:唯一约束/外键约束冲突。", - "请结合 SQL 日志排查数据一致性问题。", - ], - processing_status="failed", - error_message=str(exc)[:2000] or "integrity_error", - duration_ms=duration_ms, - ) - await self.db.commit() - raise - except Exception as exc: - await self.db.rollback() - duration_ms = int((time.perf_counter() - process_started_at) * 1000) - await self._write_processing_log( - feishu_message_id=feishu_message_id, - feishu_item_id=None, - raw_message=message, - ai_raw_output=None, - project_score=None, - signal_confidence=None, - level=None, - routing_decision=ROUTING_FAILED, - routing_reason="AI 处理链路执行失败,已停止当前消息处理并写入错误日志。", - reasoning_bullets=[ - f"异常类型:{exc.__class__.__name__}", - f"异常信息:{str(exc)[:120] or '未知错误'}", - "请在日志详情中查看完整错误并重试。", - ], - processing_status="failed", - error_message=str(exc)[:2000] or "unknown_error", - duration_ms=duration_ms, - ) - await self.db.commit() - raise - - @staticmethod - def _is_duplicate_integrity_error(exc: IntegrityError) -> bool: - message = str(exc or "").lower() - if "feishu_items" not in message: - return False - duplicate_hints = ( - "uq_feishu_items_tenant_message_root", - "uq_feishu_items_tenant_message_split", - "unique constraint", - "duplicate key value", - "is not unique", - ) - return any(hint in message for hint in duplicate_hints) - - @staticmethod - def _build_rag_reply_text(*, answer: str, sources: list[dict[str, Any]]) -> str: - clean_answer = str(answer or "").strip() or "我未找到足够证据,建议补充更多上下文。" - labels: list[str] = [] - deep_link = "" - for source in sources[:4]: - if not isinstance(source, dict): - continue - label = str(source.get("label") or source.get("source") or "").strip() - if label and label not in labels: - labels.append(label) - if not deep_link: - meta = source.get("metadata") if isinstance(source.get("metadata"), dict) else {} - target_url = str(meta.get("target_url") or "").strip() - if not target_url: - project_id = meta.get("project_id") - if project_id is not None: - try: - target_url = f"/projects/{int(project_id)}/board" - except Exception: - target_url = "" - if target_url.startswith("/"): - deep_link = target_url - source_text = " / ".join(labels) if labels else "未命中可引用来源" - if deep_link: - return f"{clean_answer}\n\n来源: {source_text}\nView details → {deep_link}" - return f"{clean_answer}\n\n来源: {source_text}" - - @staticmethod - def _normalize_chat_type(*, chat_type: Optional[str], source_chat_id: str) -> str: - text = str(chat_type or "").strip().lower() - if text in {"p2p", "single", "single_chat", "dm", "direct"}: - return "p2p" - if text in {"group", "chat", "group_chat"}: - return "group" - return "group" if source_chat_id else "p2p" - - @staticmethod - def _normalize_incoming_text(message_content: Optional[str]) -> str: - text = str(message_content or "").replace("\r\n", "\n").replace("\r", "\n") - if not text: - return "" - text = FEISHU_BOT_MENTION_RE.sub(" ", text) - text = FEISHU_QUOTE_LINE_RE.sub(" ", text) - text = FEISHU_QUOTE_PREFIX_RE.sub("", text) - text = FEISHU_NOISE_TOKEN_RE.sub(" ", text) - text = re.sub(r"@所有人|@everyone", " ", text, flags=re.IGNORECASE) - text = re.sub(r"[ \t]+", " ", text) - text = re.sub(r"\n{2,}", "\n", text) - text = re.sub(r"\s+", " ", text).strip() - return text[:2000] - - @classmethod - def _is_obvious_question(cls, message: str) -> bool: - text = str(message or "").strip() - if not text: - return False - if cls._is_query_like_question(message): - return True - if cls._is_legacy_business_command(text): - return False - if cls._looks_like_project_lookup_question(text): - return True - return bool(OBVIOUS_QUESTION_RE.search(text)) - - @classmethod - def _is_query_like_question(cls, message: str) -> bool: - text = str(message or "").strip() - if not text: - return False - normalized = re.sub(r"\s+", "", text.lower()) - has_question_structure = bool(OBVIOUS_QUESTION_RE.search(text) or QUERY_INTENT_RE.search(text)) - if not has_question_structure: - return False - has_operation_verb = bool(LEGACY_COMMAND_VERB_RE.search(text)) - imperative_tokens = ("帮我", "请", "麻烦", "直接", "立刻", "马上", "执行", "处理", "安排") - has_imperative = any(token in normalized for token in imperative_tokens) - return not has_operation_verb and not has_imperative - - @staticmethod - def _decorate_capture_result( - result: dict[str, Any], - *, - chat_type: str, - normalized_text: str, - route: str, - db_mutations: list[dict[str, Any]], - ) -> dict[str, Any]: - payload = dict(result or {}) - direct_reply_text = str(payload.get("direct_reply_text") or "").strip() - if direct_reply_text: - payload["direct_reply_text"] = FeishuSignalPipelineService._normalize_direct_reply_text(direct_reply_text) - clarification_message = str(payload.get("clarification_message") or "").strip() - if clarification_message: - payload["clarification_message"] = FeishuSignalPipelineService._normalize_direct_reply_text(clarification_message) - followup_reply = str(payload.get("followup_reply") or "").strip() - if followup_reply: - payload["followup_reply"] = FeishuSignalPipelineService._normalize_direct_reply_text(followup_reply) - payload["chat_type"] = chat_type - payload["normalized_text"] = normalized_text - payload["route"] = route - payload["db_mutations"] = db_mutations - payload.setdefault("results", []) - return payload - - @staticmethod - def _normalize_direct_reply_text(text: str) -> str: - cleaned = str(text or "").strip() - if cleaned.startswith("```"): - cleaned = cleaned.replace("```", "").strip() - if cleaned.lower().startswith("json"): - cleaned = cleaned[4:].strip() - if not cleaned: - return "系统处理中出现异常,本次未执行。请稍后重试,或补充更明确的目标、对象和动作。" - lowered = cleaned.lower() - provider_error_signals = ( - "request was aborted", - "request aborted", - "connection aborted", - "operation aborted", - "context canceled", - "context cancelled", - "cancelled", - "canceled", - "rate limit", - "too many requests", - "quota", - "insufficient_quota", - "insufficient quota", - "authentication failed", - "service unavailable", - "upstream error", - "bad gateway", - "gateway timeout", - "internal server error", - "connection reset", - "econnreset", - "socket hang up", - ) - if any(flag in lowered for flag in provider_error_signals): - return "系统处理中出现异常,本次未执行。请稍后重试,或补充更明确的目标、对象和动作。" - return cleaned[:500] - - @staticmethod - def _summarize_db_mutations(result: dict[str, Any]) -> list[dict[str, Any]]: - if not isinstance(result, dict): - return [] - mutations: list[dict[str, Any]] = [] - command_result = result.get("command_result") if isinstance(result.get("command_result"), dict) else None - execution = command_result.get("execution") if isinstance(command_result, dict) else None - execution_results = execution.get("results") if isinstance(execution, dict) else None - if isinstance(execution_results, list): - for row in execution_results[:6]: - if not isinstance(row, dict): - continue - mutations.append( - { - "operation_code": str(row.get("operation_code") or ""), - "status": str(row.get("status") or ""), - "entity_type": str(row.get("entity_type") or ""), - "entity_id": str(row.get("entity_id") or ""), - } - ) - if mutations: - return mutations - - rows = result.get("results") - if isinstance(rows, list): - for row in rows[:6]: - if not isinstance(row, dict): - continue - mutations.append( - { - "item_id": row.get("item_id"), - "level": row.get("level"), - "route_status": row.get("route_status"), - "project_id": row.get("project_id"), - "signal_id": row.get("signal_id"), - } - ) - return mutations - - async def _build_direct_qa_capture( - self, - *, - message: str, - rag_result: dict[str, Any], - inferred_project_id: Optional[int], - feishu_message_id: Optional[str], - process_started_at: float, - route_reason: str, - intent_label: str, - ) -> dict[str, Any]: - duration_ms = int((time.perf_counter() - process_started_at) * 1000) - await self._write_processing_log( - feishu_message_id=feishu_message_id, - feishu_item_id=None, - raw_message=message, - ai_raw_output=json.dumps({"intent": intent_label, "rag": rag_result}, ensure_ascii=False)[:4000], - project_score=None, - signal_confidence=None, - level="question", - routing_decision=ROUTING_DIRECT_QA, - routing_reason=route_reason, - reasoning_bullets=[ - f"intent={intent_label}", - "命中 question 路由,跳过 signal/project 处理。", - "回答已记录 QA 训练数据。", - ], - processing_status="success", - error_message=None, - duration_ms=duration_ms, - ) - return { - "status": "answered", - "classification": "question", - "project_scope": "question", - "linked_project_ids": [int(inferred_project_id)] if inferred_project_id is not None else [], - "signal_id": None, - "created_project_id": int(inferred_project_id) if inferred_project_id is not None else None, - "auto_tasks": [], - "ai_trace_id": f"feishu-qa-{(feishu_message_id or 'noid')[:32]}", - "results": [], - "rag_result": rag_result, - "direct_reply_required": True, - "direct_reply_text": self._build_rag_reply_text( - answer=str(rag_result.get("answer") or ""), - sources=rag_result.get("sources") or [], - ), - } - - async def _maybe_handle_queue_followup( - self, - *, - message: str, - sender: str, - source_chat_id: str, - feishu_message_id: Optional[str], - process_started_at: float, - ) -> Optional[dict[str, Any]]: - if not source_chat_id: - return None - wants_continue = self._looks_like_continue_followup(message) - wants_status = bool(FOLLOWUP_STATUS_RE.search(message)) - if not wants_continue and not wants_status: - return None - - recent_items = await self._load_recent_chat_items(source_chat_id=source_chat_id) - if not recent_items: - reply_text = "当前群里还没有可追踪的飞书处理项。" - decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED - reason = "群上下文中不存在可追踪的最近处理项。" - classification = "question" - status = "answered" - mutations: list[dict[str, Any]] = [] - results: list[dict[str, Any]] = [] - elif wants_continue: - pending_items = [item for item in recent_items if self._normalize_route_status(item.route_status) == ROUTE_STATUS_QUEUED] - if len(pending_items) != 1: - reply_text = self._build_followup_pending_clarification(pending_items or recent_items[:3]) - decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED - reason = "群上下文中存在多个待处理项,无法自动确定继续处理目标。" - classification = "command" - status = "captured" - mutations = [] - results = [] - else: - operator_user = await self._resolve_command_operator_user(sender=sender) - if operator_user is None: - reply_text = "未执行:当前租户下没有可用操作者,无法继续处理该项。" - decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED - reason = "继续处理被阻断:当前租户下没有可用操作者。" - classification = "command" - status = "captured" - mutations = [] - results = [] - else: - target_item = pending_items[0] - if (target_item.level or "").lower() == "project": - suggestion = await self._get_project_suggestion(int(target_item.id)) - if suggestion is None: - reply_text = "未执行:最近待处理项目候选缺少建议记录,无法继续处理。" - decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED - reason = "继续处理被阻断:最近待处理项目候选缺少建议记录。" - classification = "command" - status = "captured" - mutations = [] - results = [] - else: - candidate_fields = self._resolve_project_candidate_fields(item=target_item, suggestion=suggestion) - if not candidate_fields["ready"]: - reply_text = ( - str(target_item.blocking_reason or "").strip() - or self._build_project_clarification_message(candidate_fields["missing_fields"]) - ) - decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED - reason = "继续处理被阻断:最近待处理建项目项仍缺少必填字段。" - classification = "command" - status = "captured" - mutations = [] - results = [] - else: - confirm_result = await self.confirm_item(item_id=int(target_item.id), user_id=int(operator_user.id)) - reply_text = self._build_followup_confirmation_reply(target_item=target_item, confirm_result=confirm_result) - decision = ROUTING_QUEUE_FOLLOWUP_CONFIRM - reason = "命中继续处理指令,已按最近唯一待处理项继续执行。" - classification = "command" - status = "captured" - mutations = self._summarize_db_mutations( - { - "results": [ - { - "item_id": int(target_item.id), - "level": target_item.level, - "route_status": ROUTE_STATUS_AUTO_DONE, - "project_id": confirm_result.get("project_id"), - "signal_id": confirm_result.get("signal_id"), - } - ] - } - ) - results = [ - { - "item_id": int(target_item.id), - "level": target_item.level, - "route_status": ROUTE_STATUS_AUTO_DONE, - "project_id": _safe_int(confirm_result.get("project_id")), - "signal_id": _safe_int(confirm_result.get("signal_id")), - } - ] - else: - signal = await self._get_signal_by_item(int(target_item.id)) - if (target_item.level or "").lower() == "signal" and not (signal and (signal.matched_project_id or target_item.matched_project_id)): - reply_text = "未执行:最近待处理信号仍缺少明确项目归属,请先补充项目名或项目 ID。" - decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED - reason = "继续处理被阻断:最近待处理信号缺少项目归属。" - classification = "command" - status = "captured" - mutations = [] - results = [] - else: - confirm_result = await self.confirm_item(item_id=int(target_item.id), user_id=int(operator_user.id)) - reply_text = self._build_followup_confirmation_reply(target_item=target_item, confirm_result=confirm_result) - decision = ROUTING_QUEUE_FOLLOWUP_CONFIRM - reason = "命中继续处理指令,已按最近唯一待处理项继续执行。" - classification = "command" - status = "captured" - mutations = self._summarize_db_mutations( - { - "results": [ - { - "item_id": int(target_item.id), - "level": target_item.level, - "route_status": ROUTE_STATUS_AUTO_DONE, - "project_id": confirm_result.get("project_id"), - "signal_id": confirm_result.get("signal_id"), - } - ] - } - ) - results = [ - { - "item_id": int(target_item.id), - "level": target_item.level, - "route_status": ROUTE_STATUS_AUTO_DONE, - "project_id": _safe_int(confirm_result.get("project_id")), - "signal_id": _safe_int(confirm_result.get("signal_id")), - } - ] - else: - target_item = self._select_followup_status_target(message=message, recent_items=recent_items) - reply_text = await self._build_followup_status_reply(target_item) - decision = ROUTING_QUEUE_FOLLOWUP_QUERY - reason = "命中进度/状态追问,已直接基于真实 DB 返回最近处理项状态。" - classification = "question" - status = "answered" - mutations = [] - results = [] - - await self._write_processing_log( - feishu_message_id=feishu_message_id, - feishu_item_id=None, - raw_message=message, - ai_raw_output=json.dumps( - { - "followup_reply": reply_text, - "recent_item_ids": [int(item.id) for item in recent_items[:3]], - "wants_continue": wants_continue, - "wants_status": wants_status, - }, - ensure_ascii=False, - )[:4000], - project_score=None, - signal_confidence=None, - level="followup", - routing_decision=decision, - routing_reason=reason, - reasoning_bullets=[ - "follow-up 查询/指令命中最近飞书上下文。", - "回复内容只基于真实 DB 状态,不使用自由生成。", - f"recent_items={len(recent_items)}", - ], - processing_status="success", - error_message=None, - duration_ms=int((time.perf_counter() - process_started_at) * 1000), - ) - - latest = recent_items[0] if recent_items else None - linked_project_ids: list[int] = [] - if results and results[0].get("project_id") is not None: - linked_project_ids.append(int(results[0]["project_id"])) - if latest and latest.matched_project_id is not None: - linked_project_ids.append(int(latest.matched_project_id)) - linked_project_ids = sorted(set(linked_project_ids)) - return { - "status": status, - "classification": classification, - "project_scope": classification, - "linked_project_ids": linked_project_ids, - "signal_id": str(results[0].get("signal_id")) if results else None, - "created_project_id": str(results[0].get("project_id")) if results else None, - "auto_tasks": [], - "ai_trace_id": f"feishu-followup-{(feishu_message_id or 'noid')[:32]}", - "results": results, - "direct_reply_required": True, - "direct_reply_text": reply_text, - "db_mutations": mutations, - } - - async def _load_recent_chat_items(self, *, source_chat_id: str, limit: int = 5) -> list[FeishuItem]: - rows = ( - await self.db.execute( - select(FeishuItem) - .where( - and_( - FeishuItem.tenant_id == self.tenant_id, - FeishuItem.source_chat_id == (source_chat_id or "")[:128], - ) - ) - .order_by(FeishuItem.id.desc()) - .limit(limit) - ) - ).scalars().all() - return list(rows) - - @staticmethod - def _extract_item_operation_code(item: FeishuItem) -> str: - payload = item.result_payload if isinstance(item.result_payload, dict) else {} - return str(payload.get("operation_code") or "").strip().lower() - - @classmethod - def _select_followup_status_target(cls, *, message: str, recent_items: list[FeishuItem]) -> FeishuItem: - if not recent_items: - raise ValueError("recent_items_empty") - normalized = re.sub(r"\s+", "", str(message or "")).lower() - wants_project_create = any(token in normalized for token in ("创建", "建好", "建成", "项目")) - wants_source_status = any(token in normalized for token in ("信号源", "web搜索", "webhook", "source")) - - if wants_source_status: - for item in recent_items: - operation_code = cls._extract_item_operation_code(item) - if operation_code == "source.connect" or "信号源" in str(item.raw_message or ""): - return item - - if wants_project_create: - for item in recent_items: - operation_code = cls._extract_item_operation_code(item) - level = str(item.level or "").strip().lower() - card_type = str(item.card_type or "").strip().lower() - if ( - level == "project" - or operation_code == "project.create" - or card_type in {"project_create", "new_project", "new_project_candidate"} - ): - return item - - return recent_items[0] - - @staticmethod - def _build_followup_pending_clarification(items: list[FeishuItem]) -> str: - if not items: - return "当前群没有待处理项可继续。" - fragments = [] - for item in items[:3]: - level = str(item.level or "").lower() - label = "项目候选" if level == "project" else ("命令" if level == "command" else "信号") - fragments.append(f"{label}#{int(item.id)}") - return f"当前群最近有多个待处理项:{'、'.join(fragments)}。请先明确要继续哪一条。" - - def _build_followup_confirmation_reply(self, *, target_item: FeishuItem, confirm_result: dict[str, Any]) -> str: - level = str(target_item.level or "").lower() - if level == "project": - project_id = confirm_result.get("project_id") - return f"已继续处理:项目候选#{int(target_item.id)} 已确认并创建项目#{project_id}。" - if level == "command": - project_id = confirm_result.get("project_id") - task_id = confirm_result.get("task_id") - if task_id and project_id: - return f"已继续处理:任务#{task_id} 已在项目#{project_id} 中创建。" - if project_id: - return f"已继续处理:命令队列项#{int(target_item.id)} 已落到项目#{project_id}。" - return f"已继续处理:命令队列项#{int(target_item.id)} 已确认。" - signal_id = confirm_result.get("signal_id") - project_id = confirm_result.get("project_id") - if signal_id and project_id: - return f"已继续处理:信号#{signal_id} 已确认并关联项目#{project_id}。" - return f"已继续处理:队列项#{int(target_item.id)} 已确认。" - - async def _build_followup_status_reply(self, item: FeishuItem) -> str: - route_status = self._normalize_route_status(item.route_status) - level = str(item.level or "").strip().lower() - if level == "project": - suggestion = await self._get_project_suggestion(int(item.id)) - project_id = suggestion.created_project_id if suggestion and suggestion.created_project_id else item.matched_project_id - if route_status == ROUTE_STATUS_AUTO_DONE and project_id: - return f"最近一条项目候选已确认,项目#{int(project_id)} 已落库。" - if route_status == ROUTE_STATUS_UNDONE: - return "最近一条项目候选已撤销,系统没有保留该次自动结果。" - return "最近一条项目候选仍在待确认,系统还没有落正式项目。" - - if level == "command": - payload = item.result_payload if isinstance(item.result_payload, dict) else {} - operation_code = str(payload.get("operation_code") or "").strip() - project_id = _safe_int(payload.get("project_id") or item.matched_project_id) - summary = str(payload.get("result_summary") or "").strip() - if route_status == ROUTE_STATUS_AUTO_DONE: - return summary or ( - f"最近一条命令已执行完成,归属项目#{int(project_id)}。" - if project_id is not None - else "最近一条命令已执行完成。" - ) - if route_status == ROUTE_STATUS_UNDONE: - return "最近一条命令结果已撤销。" - if route_status == ROUTE_STATUS_QUEUED: - return ( - f"最近一条命令 {operation_code or '待处理动作'} 已入队待确认。" - if operation_code - else "最近一条命令已入队待确认。" - ) - return "最近一条命令仍在处理中。" - - signal = await self._get_signal_by_item(int(item.id)) - signal_id = int(signal.id) if signal is not None else None - project_id = int(signal.matched_project_id or item.matched_project_id) if (signal and (signal.matched_project_id or item.matched_project_id)) else None - if route_status == ROUTE_STATUS_AUTO_DONE and signal_id and project_id: - return f"最近一条信号#{signal_id} 已确认并关联项目#{project_id}。" - if route_status == ROUTE_STATUS_QUEUED and signal_id and project_id: - return f"最近一条信号#{signal_id} 已入队待确认,候选项目#{project_id}。" - if route_status == ROUTE_STATUS_QUEUED and signal_id: - return f"最近一条信号#{signal_id} 已入队待确认,但项目归属仍不明确。" - if route_status == ROUTE_STATUS_UNDONE: - return "最近一条信号结果已撤销。" - return "最近一条飞书处理项仍在处理中,尚未得到可确认的 DB 状态。" - - @staticmethod - def _looks_like_project_create_command(message: str) -> bool: - return bool(LEGACY_COMMAND_PROJECT_CREATE_RE.search(str(message or ""))) - - @classmethod - def _looks_like_project_lookup_question(cls, message: str) -> bool: - text = str(message or "").strip() - if not text: - return False - if cls._is_legacy_business_command(text): - return False - normalized = re.sub(r"\s+", "", text.lower()) - if PROJECT_LIST_QUERY_RE.search(text): - return True - if PROJECT_ID_QUERY_RE.search(text): - return True - if "项目" not in normalized: - return False - return any(token in normalized for token in ("有吗", "有没有", "有无", "是否有", "是否存在", "存在吗", "是什么", "哪些")) - - @classmethod - def _is_legacy_business_command(cls, message: str) -> bool: - text = str(message or "").strip() - if not text: - return False - if cls._is_query_like_question(message): - return False - if cls._looks_like_project_create_command(text): - return True - return bool(LEGACY_COMMAND_VERB_RE.search(text) and LEGACY_COMMAND_OBJECT_RE.search(text)) - - async def _maybe_block_legacy_command_request( - self, - *, - message: str, - sender: str, - source_chat_id: str, - source_sender_open_id: Optional[str], - feishu_message_id: Optional[str], - captured_at: Optional[datetime], - inferred_project_id: Optional[int], - projects: list[dict[str, Any]], - process_started_at: float, - ) -> Optional[dict[str, Any]]: - if not self._is_legacy_business_command(message): - return None - - _ = sender, source_chat_id, source_sender_open_id, captured_at, inferred_project_id, projects - return await self._build_legacy_command_blocked_capture( - message=message, - feishu_message_id=feishu_message_id, - duration_ms=int((time.perf_counter() - process_started_at) * 1000), - reason="旧 command-first 规划链路已下线,请改用 GLM 分析后直接调用显式接口。", - ) - - async def _maybe_answer_project_lookup_question( - self, - *, - message: str, - feishu_message_id: Optional[str], - process_started_at: float, - projects: list[dict[str, Any]], - ) -> Optional[dict[str, Any]]: - if not self._looks_like_project_lookup_question(message): - return None - - linked_project_ids: list[int] = [] - reply_text = "" - id_match = PROJECT_ID_QUERY_RE.search(message) - if id_match: - target_id = int(id_match.group(1)) - matched = next((item for item in projects if _safe_int(item.get("id")) == target_id), None) - if matched is None: - reply_text = f"项目#{target_id} 不存在,或当前租户下不可见。" - else: - linked_project_ids = [target_id] - reply_text = self._format_project_lookup_hit_rows([matched], prefix="查到了") - elif PROJECT_LIST_QUERY_RE.search(message): - if not projects: - reply_text = "当前没有可见项目。" - else: - reply_text = self._format_project_lookup_hit_rows(projects[:8], prefix="当前项目") - linked_project_ids = [int(item["id"]) for item in projects[:8] if _safe_int(item.get("id"))] - else: - keyword = self._extract_project_lookup_keyword(message) - matched_rows = self._match_projects_by_keyword(projects=projects, keyword=keyword) - if matched_rows: - linked_project_ids = [int(item["id"]) for item in matched_rows if _safe_int(item.get("id"))] - reply_text = self._format_project_lookup_hit_rows(matched_rows[:5], prefix="查到了") - elif keyword: - reply_text = f"没有查到包含“{keyword}”的项目。" - else: - reply_text = "这条问题缺少可检索的项目关键词。" - - duration_ms = int((time.perf_counter() - process_started_at) * 1000) - await self._write_processing_log( - feishu_message_id=feishu_message_id, - feishu_item_id=None, - raw_message=message, - ai_raw_output=json.dumps({"deterministic_lookup": True, "linked_project_ids": linked_project_ids}, ensure_ascii=False), - project_score=None, - signal_confidence=None, - level="question", - routing_decision=ROUTING_DIRECT_QA, - routing_reason="命中项目查询问句,已直接基于真实 DB 返回结果,跳过 LLM 分流。", - reasoning_bullets=[ - "命中 deterministic project lookup 规则。", - "直接查询 projects 表,不走 signal/project 分流。", - "回复内容只基于真实 DB 结果。", - ], - processing_status="success", - error_message=None, - duration_ms=duration_ms, - ) - return { - "status": "answered", - "classification": "question", - "project_scope": "question", - "linked_project_ids": linked_project_ids, - "signal_id": None, - "created_project_id": None, - "auto_tasks": [], - "ai_trace_id": f"feishu-project-lookup-{(feishu_message_id or 'noid')[:32]}", - "results": [], - "intent": "question", - "direct_reply_required": True, - "direct_reply_text": reply_text, - } - - async def _build_legacy_command_blocked_capture( - self, - *, - message: str, - feishu_message_id: Optional[str], - duration_ms: int, - reason: str, - ) -> dict[str, Any]: - await self._write_processing_log( - feishu_message_id=feishu_message_id, - feishu_item_id=None, - raw_message=message, - ai_raw_output=None, - project_score=None, - signal_confidence=None, - level="command", - routing_decision=ROUTING_LEGACY_COMMAND_BLOCKED, - routing_reason=reason, - reasoning_bullets=[ - "命中 command-first 路由,但当前缺少安全执行所需的上下文。", - "系统未执行任何写库动作。", - "回执只基于确定性拦截结果,不使用 LLM 概括。", - ], - processing_status="success", - error_message=None, - duration_ms=duration_ms, - ) - return { - "status": "captured", - "classification": "command", - "project_scope": "command", - "linked_project_ids": [], - "signal_id": None, - "created_project_id": None, - "auto_tasks": [], - "ai_trace_id": None, - "results": [], - "intent": "command", - "direct_reply_required": True, - "direct_reply_text": f"未执行:{reason}", - "command_result": None, - } - - async def _resolve_command_operator_user(self, *, sender: str) -> Optional[User]: - sender_id = await self._resolve_user_id_by_name(sender) - if sender_id is not None: - sender_user = await self._load_active_user_by_id(user_id=int(sender_id)) - if sender_user is not None: - return sender_user - - manager = ( - await self.db.execute( - select(User).where( - and_( - User.tenant_id == self.tenant_id, - User.is_active.is_(True), - User.status == "active", - User.role.in_(["BOSS", "ADMIN", "PM"]), - ) - ).order_by(User.id.asc()).limit(1) - ) - ).scalar_one_or_none() - if manager is not None: - return manager - return await self._load_active_user_by_id(user_id=await self._fallback_user_id()) - - async def _load_active_user_by_id(self, user_id: Optional[int]) -> Optional[User]: - if user_id is None: - return None - return ( - await self.db.execute( - select(User).where( - and_( - User.tenant_id == self.tenant_id, - User.id == int(user_id), - User.is_active.is_(True), - User.status == "active", - ) - ).limit(1) - ) - ).scalar_one_or_none() - - async def _record_command_context_item( - self, - *, - message: str, - sender: str, - source_chat_id: str, - source_sender_open_id: Optional[str], - feishu_message_id: Optional[str], - captured_at: Optional[datetime], - matched_project_id: Optional[int], - route_status: str, - command_result: dict[str, Any], - ) -> Optional[int]: - if matched_project_id is None and not source_chat_id: - return None - occurred_at = captured_at or _utc_now() - if occurred_at.tzinfo is not None: - occurred_at = occurred_at.astimezone(CN_TZ).replace(tzinfo=None) - item = FeishuItem( - tenant_id=self.tenant_id, - feishu_message_id=feishu_message_id, - project_score=1.0 if matched_project_id is not None else None, - signal_confidence=0.0 if matched_project_id is not None else None, - raw_message=message[:4000], - split_from_item_id=None, - level="command", - level_confidence=1.0, - level_reasoning="legacy_command_first_execution", - card_type="command", - route_status=self._normalize_route_status(route_status or ROUTE_STATUS_AUTO_DONE)[:30], - matched_project_id=int(matched_project_id) if matched_project_id is not None else None, - source_chat_id=(source_chat_id or "")[:128] or None, - source_sender=(sender or "")[:120] or None, - source_sender_open_id=(source_sender_open_id or "")[:128] or None, - source_occurred_at=occurred_at, - ai_output=json.dumps({"command_result": command_result}, ensure_ascii=False)[:8000], - message_class="auto_done", - safe_to_auto_execute=True, - auto_execute_state="done", - result_payload={ - "intent": "command", - "executor": "legacy_command_planner", - "operation_code": None, - "result_summary": self._build_operation_commit_reply(command_result), - "command_result": command_result, - "project_id": int(matched_project_id) if matched_project_id is not None else None, - "target_url": f"/projects/{int(matched_project_id)}/board" if matched_project_id is not None else None, - }, - queue_visible=False, - processed_at=_utc_now().replace(tzinfo=None), - ) - self.db.add(item) - await self.db.flush() - return int(item.id) - - @staticmethod - def _resolve_command_route_status(command_result: dict[str, Any]) -> str: - status = str( - command_result.get("command_status") - or command_result.get("run_status") - or command_result.get("execution_status") - or "" - ).strip().lower() - if status in {"completed", "partial", "planned"}: - return ROUTE_STATUS_AUTO_DONE - return ROUTE_STATUS_FAILED - - @staticmethod - def _collect_command_project_ids(command_result: dict[str, Any]) -> list[int]: - project_ids: set[int] = set() - project_info = command_result.get("project") if isinstance(command_result, dict) else None - if isinstance(project_info, dict): - project_id = project_info.get("id") - try: - if project_id is not None and int(project_id) > 0: - project_ids.add(int(project_id)) - except (TypeError, ValueError): - pass - execution = command_result.get("execution") if isinstance(command_result, dict) else None - results = execution.get("results") if isinstance(execution, dict) else None - if isinstance(results, list): - for row in results: - if not isinstance(row, dict): - continue - if str(row.get("entity_type") or "").strip() != "project": - continue - payload = row.get("result") if isinstance(row.get("result"), dict) else {} - candidate = payload.get("project_id") or row.get("entity_id") - try: - if candidate is not None and int(candidate) > 0: - project_ids.add(int(candidate)) - except (TypeError, ValueError): - continue - return sorted(project_ids) - - @staticmethod - def _extract_created_project_id(command_result: dict[str, Any]) -> Optional[int]: - execution = command_result.get("execution") if isinstance(command_result, dict) else None - results = execution.get("results") if isinstance(execution, dict) else None - if not isinstance(results, list): - return None - for row in results: - if not isinstance(row, dict): - continue - if str(row.get("operation_code") or "").strip() != "project.create": - continue - payload = row.get("result") if isinstance(row.get("result"), dict) else {} - candidate = payload.get("project_id") or row.get("entity_id") - try: - if candidate is not None and int(candidate) > 0: - return int(candidate) - except (TypeError, ValueError): - continue - return None - - @classmethod - def _build_operation_commit_reply(cls, command_result: dict[str, Any]) -> str: - execution = command_result.get("execution") if isinstance(command_result, dict) else {} - results = execution.get("results") if isinstance(execution, dict) else [] - if not isinstance(results, list) or not results: - return "未执行:未识别到可安全落库的业务操作。" - - fragments = [cls._format_operation_result_fragment(row) for row in results[:3] if isinstance(row, dict)] - fragments = [item for item in fragments if item] - applied_count = int((execution or {}).get("applied_count") or 0) - skipped_count = int((execution or {}).get("skipped_count") or 0) - failed_count = int((execution or {}).get("failed_count") or 0) - - if applied_count <= 0 and skipped_count <= 0 and failed_count > 0: - first_failed = next((row for row in results if isinstance(row, dict) and str(row.get("status") or "") == "failed"), {}) - message = str(first_failed.get("message") or "执行失败").strip() - return f"未执行:{message}" - if not fragments: - return "已接收指令,但未生成可展示的执行回执。" - - prefix = "未重复执行:" if applied_count <= 0 and skipped_count > 0 else "已执行完成:" - reply = f"{prefix}{';'.join(fragments[:2])}" - if failed_count > 0 and applied_count > 0: - reply = f"{reply};另有 {failed_count} 项失败" - return reply + "。" - - @staticmethod - def _format_operation_result_fragment(row: dict[str, Any]) -> str: - operation_code = str(row.get("operation_code") or "").strip() - status = str(row.get("status") or "").strip().lower() - message = str(row.get("message") or "").strip() - payload = row.get("result") if isinstance(row.get("result"), dict) else {} - if status == "failed": - return "" - - if operation_code.startswith("project."): - project_id = payload.get("project_id") or row.get("entity_id") - title = str(payload.get("title") or payload.get("project_title") or "").strip() - status_text = str(payload.get("status") or "").strip() - label = f"项目#{project_id}" - if title: - label = f"{label}「{title}」" - if operation_code == "project.create" and status == "applied": - return f"{label}已创建,状态 {status_text or 'active'}" - if operation_code == "project.create" and status == "skipped": - return f"{label}已存在,状态 {status_text or 'active'}" - if status_text: - return f"{label}状态已更新为 {status_text}" - return f"{label}{message or '已处理'}" - - if operation_code == "task.create": - return f"任务#{row.get('entity_id') or ''}已创建".strip() - if operation_code == "task.assign": - return f"任务#{row.get('entity_id') or ''}已分配".strip() - if operation_code == "task.prioritize": - return f"任务#{row.get('entity_id') or ''}优先级已更新".strip() - if operation_code == "task.status_transition": - return f"任务#{row.get('entity_id') or ''}状态已更新".strip() - if operation_code == "personnel.workload_update": - return f"成员#{row.get('entity_id') or ''}工时已更新".strip() - if operation_code == "notification.create": - return f"通知#{row.get('entity_id') or ''}已创建".strip() - return message or operation_code - - @staticmethod - def _extract_project_lookup_keyword(message: str) -> str: - text = str(message or "").strip() - if not text: - return "" - cleaned = re.sub( - r"(请问|麻烦问下|问下|现在|当前|目前|系统里|系统中|项目里|项目中|有没有|有无|有吗|是否有|是否存在|存在吗|这个|那个|一个|个|项目|列表|清单|有|吗|呢|呀|啊|么)", - " ", - text, - flags=re.IGNORECASE, - ) - cleaned = re.sub(r"[,。,.;;::!??()()\\[\\]【】]+", " ", cleaned) - cleaned = re.sub(r"\s+", " ", cleaned).strip() - return cleaned[:40] - - @staticmethod - def _match_projects_by_keyword(*, projects: list[dict[str, Any]], keyword: str) -> list[dict[str, Any]]: - normalized_keyword = re.sub(r"\s+", "", str(keyword or "")).lower() - if len(normalized_keyword) < 2: - return [] - matched: list[dict[str, Any]] = [] - for item in projects: - title = re.sub(r"\s+", "", str(item.get("title") or item.get("name") or "")).lower() - if normalized_keyword and normalized_keyword in title: - matched.append(item) - return matched - - @staticmethod - def _format_project_lookup_hit_rows(rows: list[dict[str, Any]], *, prefix: str) -> str: - if not rows: - return "当前没有可见项目。" - fragments: list[str] = [] - for item in rows[:8]: - project_id = item.get("id") - title = str(item.get("title") or item.get("name") or "").strip() or f"项目#{project_id}" - status = str(item.get("status") or "").strip() or "unknown" - fragments.append(f"#{project_id} {title}({status})") - return f"{prefix}:{'、'.join(fragments)}。" - - @classmethod - def _should_hide_pending_item_from_inbox(cls, item: FeishuItem) -> bool: - if str(item.decision_reason or "").strip() == "missing_required_fields": - return True - raw_message = str(item.raw_message or "").strip() - if not raw_message: - return False - normalized = re.sub(r"\s+", "", raw_message).lower() - if cls._is_water_talk_message(raw_message): - return True - if cls._is_healthcheck_message(raw_message): - return True - if cls._is_short_confirmation_message(raw_message): - return True - if cls._looks_like_project_lookup_question(raw_message): - return True - if "长连接" in normalized and any(token in normalized for token in ("已通", "都已通", "正常", "在线", "已恢复")): - return True - return False - - async def list_queue(self, *, limit: int = 30) -> dict[str, Any]: - routing_policy = await self._load_routing_policy() - pending_rows = ( - await self.db.execute( - select(FeishuItem) - .where( - and_( - FeishuItem.tenant_id == self.tenant_id, - FeishuItem.queue_visible.is_(True), - FeishuItem.route_status.in_((ROUTE_STATUS_QUEUED, "pending")), - ) - ) - .order_by(FeishuItem.created_at.desc()) - .limit(limit) - ) - ).scalars().all() - pending_rows = self._dedupe_queue_items(pending_rows) - pending_rows = [row for row in pending_rows if not self._should_hide_pending_item_from_inbox(row)] - - # DB 列是 timestamp without time zone,比较值必须使用 naive 北京时间,避免 asyncpg aware/naive 报错。 - today_start = _utc_now().astimezone(CN_TZ).replace( - hour=0, - minute=0, - second=0, - microsecond=0, - tzinfo=None, - ) - auto_rows = ( - await self.db.execute( - select(FeishuItem) - .where( - and_( - FeishuItem.tenant_id == self.tenant_id, - FeishuItem.route_status.in_((ROUTE_STATUS_AUTO_DONE, ROUTE_STATUS_UNDONE, "auto_processed", "confirmed")), - FeishuItem.processed_at.is_not(None), - FeishuItem.processed_at >= today_start, - ) - ) - .order_by(FeishuItem.processed_at.desc()) - .limit(limit) - ) - ).scalars().all() - auto_rows = self._dedupe_queue_items(auto_rows) - auto_rows = [row for row in auto_rows if not self._is_water_talk_message(row.raw_message or "")] - - pending_items = [await self._serialize_item(row) for row in pending_rows] - pending_items.sort( - key=lambda x: ( - -int(x.get("priority_rank") or 0), - str(x.get("created_at") or ""), - ) - ) - auto_items = [await self._serialize_item(row) for row in auto_rows] - - signal_auto = len([x for x in auto_items if x.get("level") == "signal"]) - project_auto = len( - [ - x for x in auto_items - if x.get("level") == "project" - or ( - x.get("level") == "command" - and isinstance(x.get("command"), dict) - and str(x["command"].get("operation_code") or "") == "project.create" - ) - ] - ) - return { - "pending_count": len(pending_items), - "pending_items": pending_items, - "auto_processed_items": auto_items, - "auto_summary": { - "signals_auto_entered_today": signal_auto, - "projects_auto_created_today": project_auto, - }, - "cold_start": bool(routing_policy["cold_start_enabled"]), - } - - async def get_item_detail(self, item_id: int) -> dict[str, Any]: - item = await self._get_item(item_id) - if not item: - raise ValueError("item_not_found") - return await self._serialize_item(item, include_raw=True, include_history=True) - - async def update_item( - self, - *, - item_id: int, - payload: dict[str, Any], - user_id: int, - ) -> dict[str, Any]: - item = await self._get_item(item_id) - if not item: - raise ValueError("item_not_found") - - changed = False - if (item.level or "").lower() == "project": - suggestion = await self._get_project_suggestion(item.id) - if not suggestion: - raise ValueError("project_suggestion_not_found") - for field in ("suggested_name", "suggested_description", "suggested_owner", "suggested_timeline"): - if field in payload: - new_value = str(payload.get(field) or "").strip() or None - if getattr(suggestion, field) != new_value: - setattr(suggestion, field, new_value) - changed = True - if changed: - suggestion.status = "pending" - else: - signal = await self._get_signal_by_item(item.id) - if not signal: - raise ValueError("signal_not_found") - editable_fields = ( - "title", - "signal_type", - "priority", - "summary", - "recommended_action", - "matched_project_id", - ) - for field in editable_fields: - if field not in payload: - continue - old_value = getattr(signal, field) - raw_new = payload.get(field) - if field == "matched_project_id": - new_value = int(raw_new) if raw_new is not None else None - else: - new_value = str(raw_new).strip() if raw_new is not None else None - if new_value == "": - new_value = None - if old_value == new_value: - continue - setattr(signal, field, new_value) - self.db.add( - SignalEdit( - tenant_id=self.tenant_id, - signal_id=int(signal.id), - field=field, - old_value=None if old_value is None else str(old_value), - new_value=None if new_value is None else str(new_value), - edited_by=int(user_id), - edited_at=_utc_now().replace(tzinfo=None), - ) - ) - changed = True - - if changed: - await self.db.commit() - return await self.get_item_detail(item_id) - - async def dismiss_item(self, *, item_id: int, reason: str) -> dict[str, Any]: - item = await self._get_item(item_id) - if not item: - raise ValueError("item_not_found") - item.route_status = ROUTE_STATUS_DISMISSED - item.queue_visible = False - item.dismissed_reason = (reason or "").strip()[:300] or "用户手动忽略" - item.processed_at = _utc_now().replace(tzinfo=None) - item.message_class = "ignored" - item.auto_execute_state = "dismissed" - - if (item.level or "").lower() == "project": - suggestion = await self._get_project_suggestion(item.id) - if suggestion: - suggestion.status = "dismissed" - else: - signal = await self._get_signal_by_item(item.id) - if signal: - signal.processed = True - signal.status = "已关闭" - - await self.db.commit() - return {"id": str(item.id), "status": item.route_status} - - async def confirm_item(self, *, item_id: int, user_id: int, action: Optional[str] = None) -> dict[str, Any]: - item = await self._get_item(item_id) - if not item: - raise ValueError("item_not_found") - - level = (item.level or "").lower() - result: dict[str, Any] = {"id": str(item.id), "level": level} - if level == "project": - suggestion = await self._get_project_suggestion(item.id) - if not suggestion: - raise ValueError("project_suggestion_not_found") - candidate_fields = self._resolve_project_candidate_fields(item=item, suggestion=suggestion) - if not candidate_fields["ready"]: - raise ValueError( - str(item.blocking_reason or "").strip() - or self._build_project_clarification_message(candidate_fields["missing_fields"]) - ) - operator_user = await self._load_active_user_by_id(user_id=int(user_id)) - if operator_user is None: - raise ValueError("operator_not_found") - execution = await self._execute_controlled_operation( - operator_user=operator_user, - operation_code="project.create", - payload={ - "title": candidate_fields["title"], - "description": candidate_fields["description"], - }, - trace_id=f"confirm-project-{int(item.id)}-{int(user_id)}", - ) - first = execution["results"][0] if execution.get("results") else {} - status = str(first.get("status") or "").strip().lower() - if status not in {"applied", "skipped"}: - raise ValueError(str(first.get("message") or "project_create_failed")) - project_result = first.get("result") if isinstance(first.get("result"), dict) else {} - project_id = _safe_int(project_result.get("project_id") or first.get("entity_id")) - suggestion.status = "confirmed" - suggestion.created_project_id = int(project_id) if project_id is not None else None - item.matched_project_id = int(project_id) if project_id is not None else item.matched_project_id - item.result_payload = { - "intent": INTENT_CREATE_PROJECT, - "executor": "explicit_service", - "operation_code": "project.create", - "result_summary": f"已创建项目 #{project_id}" if project_id else "已创建项目", - "operation_result": project_result, - "target_url": f"/projects/{project_id}/board" if project_id else None, - "project_id": project_id, - } - result["project_id"] = str(project_id) if project_id is not None else None - elif level == "command": - operator_user = await self._load_active_user_by_id(user_id=int(user_id)) - if operator_user is None: - raise ValueError("operator_not_found") - payload = item.result_payload if isinstance(item.result_payload, dict) else {} - operation_code = str(payload.get("operation_code") or "").strip() - operation_payload = payload.get("operation_payload") if isinstance(payload.get("operation_payload"), dict) else {} - if operation_code not in {"task.create"}: - raise ValueError("command_operation_not_supported") - execution = await self._execute_controlled_operation( - operator_user=operator_user, - operation_code=operation_code, - payload=operation_payload, - trace_id=f"confirm-command-{int(item.id)}-{int(user_id)}", - ) - first = execution["results"][0] if execution.get("results") else {} - status = str(first.get("status") or "").strip().lower() - if status not in {"applied", "skipped"}: - raise ValueError(str(first.get("message") or "command_execution_failed")) - project_id = _safe_int(operation_payload.get("project_id") or payload.get("project_id") or item.matched_project_id) - task_id = _safe_int(first.get("entity_id")) - item.matched_project_id = int(project_id) if project_id is not None else item.matched_project_id - item.result_payload = { - **payload, - "executor": "explicit_service", - "result_summary": f"已创建任务 #{task_id}" if task_id is not None else "已创建任务", - "operation_result": first.get("result") if isinstance(first.get("result"), dict) else {}, - "target_url": f"/projects/{project_id}/board" if project_id else None, - "project_id": project_id, - } - result["project_id"] = str(project_id) if project_id is not None else None - result["task_id"] = str(task_id) if task_id is not None else None - else: - signal = await self._get_signal_by_item(item.id) - if not signal: - raise ValueError("signal_not_found") - matched_project_id = signal.matched_project_id or item.matched_project_id - if not matched_project_id: - raise ValueError("signal_missing_project") - await self._ensure_signal_link(signal_id=int(signal.id), project_id=int(matched_project_id)) - signal.processed = False - signal.status = "待处理" - if action in {"create_charter", "trigger_alert"}: - signal.recommended_action = action - result["signal_id"] = str(signal.id) - result["project_id"] = str(matched_project_id) - - item.route_status = ROUTE_STATUS_AUTO_DONE - item.queue_visible = False - item.processed_at = _utc_now().replace(tzinfo=None) - item.message_class = "auto_done" - item.safe_to_auto_execute = False - item.auto_execute_state = "done" - item.blocking_reason = None - item.next_step_text = "你已授权继续执行,结果已落地,可进入结果页查看。" - item.undoable_until = _build_undo_deadline(item.processed_at) if level in {"project", "signal"} else None - if level == "project": - created_project_id = _safe_int(result.get("project_id")) - item.result_payload = { - **(item.result_payload if isinstance(item.result_payload, dict) else {}), - "result_count": 1, - "impact_objects": [f"项目#{created_project_id}"] if created_project_id else [], - } - elif level == "signal": - project_id = _safe_int(result.get("project_id")) - signal_id = _safe_int(result.get("signal_id")) - item.result_payload = { - "result_summary": f"已确认信号 #{signal_id} 并关联项目 #{project_id}" if project_id and signal_id else "已确认信号并完成入库", - "result_count": 1, - "impact_objects": [f"信号#{signal_id}"] if signal_id else [], - "target_url": f"/projects/{project_id}/board" if project_id else None, - "project_id": project_id, - } - else: - payload = item.result_payload if isinstance(item.result_payload, dict) else {} - item.result_payload = { - **payload, - "result_count": 1, - } - await self.db.commit() - return result - - async def undo_auto_processed(self, *, item_id: int) -> dict[str, Any]: - item = await self._get_item(item_id) - if not item: - raise ValueError("item_not_found") - if self._normalize_route_status(item.route_status) != ROUTE_STATUS_AUTO_DONE: - raise ValueError("item_not_auto_processed") - - level = (item.level or "").lower() - if level == "project": - suggestion = await self._get_project_suggestion(item.id) - if suggestion and suggestion.created_project_id: - project = ( - await self.db.execute( - select(Project).where( - and_( - Project.tenant_id == self.tenant_id, - Project.id == int(suggestion.created_project_id), - ) - ) - ) - ).scalar_one_or_none() - if project: - project.status = "inactive" - if suggestion: - suggestion.status = "pending" - elif level == "signal": - signal = await self._get_signal_by_item(item.id) - if signal: - await self.db.execute( - delete(ProjectSignalLink).where( - and_( - ProjectSignalLink.tenant_id == self.tenant_id, - ProjectSignalLink.signal_id == int(signal.id), - ) - ) - ) - signal.status = "待处理" - signal.processed = False - - item.route_status = ROUTE_STATUS_UNDONE - item.queue_visible = False - item.processed_at = _utc_now().replace(tzinfo=None) - item.message_class = "auto_done" - item.auto_execute_state = "undone" - item.undoable_until = None - existing_payload = item.result_payload if isinstance(item.result_payload, dict) else {} - item.result_payload = { - **existing_payload, - "result_summary": "该结果已撤销", - "undone": True, - } - await self.db.commit() - return {"id": str(item.id), "status": item.route_status} - - async def _process_one_item( - self, - *, - content: str, - level: str, - sender: str, - source_chat_id: str, - source_sender_open_id: Optional[str], - feishu_message_id: Optional[str], - occurred_at: datetime, - level_result: dict[str, Any], - level_confidence: float, - level_reasoning: str, - project_score: float, - cold_start_enabled: bool, - signal_auto_enter_threshold: float, - project_auto_create_threshold: float, - project_uncertain_threshold: float, - chat_context_auto_link_enabled: bool, - projects: list[dict[str, Any]], - split_from_item_id: Optional[int], - chat_type: str, - chat_context_resolution: dict[str, Any], - ) -> dict[str, Any]: - item_started_at = time.perf_counter() - level_val = "project" if level == "project" else "signal" - matched_project_hint = _safe_int(chat_context_resolution.get("project_id")) - dedupe_key = self._build_queue_dedupe_key( - level=level_val, - intent=INTENT_SIGNAL_INTAKE if level_val == "signal" else INTENT_CREATE_PROJECT, - message=content, - source_chat_id=source_chat_id, - matched_project_id=matched_project_hint, - source_object=content, - ) - existing_item = await self._find_active_dedupe_item(dedupe_key) - if existing_item is not None: - existing_item.occurrence_count = max(1, int(existing_item.occurrence_count or 1)) + 1 - existing_item.raw_message = content[:4000] - existing_item.source_sender = (sender or "")[:120] or None - existing_item.source_sender_open_id = (source_sender_open_id or "")[:128] or None - existing_item.source_chat_id = (source_chat_id or "")[:128] or None - existing_item.source_occurred_at = occurred_at.replace(tzinfo=None) - existing_item.level_reasoning = level_reasoning[:1000] - existing_item.ai_output = json.dumps( - { - "chat_type": chat_type, - "normalized_text": content, - "level_result": level_result, - }, - ensure_ascii=False, - )[:8000] - await self.db.flush() - return await self._build_existing_item_result(existing_item) - - item = FeishuItem( - tenant_id=self.tenant_id, - feishu_message_id=feishu_message_id, - project_score=project_score, - signal_confidence=max(0.0, 1 - project_score), - raw_message=content[:4000], - split_from_item_id=split_from_item_id, - level=level_val, - level_confidence=level_confidence, - level_reasoning=level_reasoning[:1000], - card_type="signal" if level_val == "signal" else "new_project", - route_status=ROUTE_STATUS_CLASSIFIED, - source_chat_id=(source_chat_id or "")[:128] or None, - source_sender=(sender or "")[:120] or None, - source_sender_open_id=(source_sender_open_id or "")[:128] or None, - source_occurred_at=occurred_at.replace(tzinfo=None), - ai_output=json.dumps( - { - "chat_type": chat_type, - "normalized_text": content, - "level_result": level_result, - }, - ensure_ascii=False, - )[:8000], - decision_reason=level_reasoning[:1000] or None, - safe_to_auto_execute=False, - auto_execute_state="blocked", - dedupe_key=dedupe_key, - message_class="action_required", - occurrence_count=1, - next_step_text="AI 正在校验上下文,确认后会继续推进后续动作。", - queue_visible=True, - ) - self.db.add(item) - await self.db.flush() - - if level_val == "project": - project_info = await self._extract_project_prefill(content) - suggestion = ProjectSuggestion( - tenant_id=self.tenant_id, - feishu_item_id=int(item.id), - suggested_name=(project_info.get("suggested_name") or content[:20] or "新项目建议")[:255], - suggested_description=(project_info.get("suggested_description") or content[:400])[:4000], - suggested_owner=(project_info.get("suggested_owner") or sender or "待分配")[:120], - suggested_timeline=(project_info.get("suggested_timeline") or "待确认")[:120], - confidence=_safe_float(project_info.get("confidence"), level_confidence), - reasoning=json.dumps(_normalize_reasoning(project_info.get("reasoning")), ensure_ascii=False), - status="pending", - ) - self.db.add(suggestion) - await self.db.flush() - project_id = await self._route_project_item( - item=item, - suggestion=suggestion, - sender=sender, - project_score=project_score, - cold_start_enabled=cold_start_enabled, - project_auto_create_threshold=project_auto_create_threshold, - ) - routing_decision, routing_reason, reasoning_bullets = self._build_project_routing_log( - project_score=project_score, - cold_start_enabled=cold_start_enabled, - project_auto_create_threshold=project_auto_create_threshold, - project_uncertain_threshold=project_uncertain_threshold, - ) - await self._write_processing_log( - feishu_message_id=feishu_message_id, - feishu_item_id=int(item.id), - raw_message=content, - ai_raw_output=json.dumps( - { - "level_classification": level_result, - "project_prefill": project_info, - }, - ensure_ascii=False, - ), - project_score=project_score, - signal_confidence=max(0.0, 1 - project_score), - level="project", - routing_decision=routing_decision, - routing_reason=routing_reason, - reasoning_bullets=reasoning_bullets, - processing_status="success", - error_message=None, - duration_ms=int((time.perf_counter() - item_started_at) * 1000), - ) - return { - "item_id": int(item.id), - "level": "project", - "project_id": project_id, - "signal_id": None, - "route_status": item.route_status, - } - - simple_signal_request = self._is_simple_signal_source_request(content) - signal_info = await self._extract_signal(content, projects=projects) - matched_project_id = signal_info.get("matched_project_id") - if matched_project_id is not None: - try: - matched_project_id = int(matched_project_id) - except (TypeError, ValueError): - matched_project_id = None - - confidence = _safe_float(signal_info.get("confidence"), max(0.55, 1 - project_score)) - if simple_signal_request: - confidence = max(confidence, min(0.95, signal_auto_enter_threshold + 0.05)) - inferred_project_id: Optional[int] = None - clarification_message: Optional[str] = None - if (chat_context_auto_link_enabled or simple_signal_request) and matched_project_id is None and source_chat_id: - inferred_project_id = _safe_int(chat_context_resolution.get("project_id")) - if inferred_project_id is not None: - matched_project_id = int(inferred_project_id) - elif bool(chat_context_resolution.get("ambiguous")): - candidate_ids = [int(x) for x in (chat_context_resolution.get("candidate_ids") or [])[:3] if _safe_int(x)] - if candidate_ids: - clarification_message = ( - f"当前群最近同时涉及项目#{'、#'.join([str(pid) for pid in candidate_ids])}," - "请明确这条消息归属哪个项目。" - ) - - if matched_project_id is None and self._should_promote_signal_to_project_candidate( - content=content, - signal_info=signal_info, - confidence=confidence, - signal_auto_enter_threshold=signal_auto_enter_threshold, - simple_signal_request=simple_signal_request, - ): - promoted_project_score = max(float(project_score or 0.0), float(project_uncertain_threshold)) - item.level = "project" - item.project_score = promoted_project_score - item.signal_confidence = max(0.0, 1 - promoted_project_score) - item.card_type = "new_project_candidate" - item.matched_project_id = None - - project_info = await self._extract_project_prefill(content) - suggestion = ProjectSuggestion( - tenant_id=self.tenant_id, - feishu_item_id=int(item.id), - suggested_name=(project_info.get("suggested_name") or content[:20] or "新项目建议")[:255], - suggested_description=(project_info.get("suggested_description") or content[:400])[:4000], - suggested_owner=(project_info.get("suggested_owner") or sender or "待分配")[:120], - suggested_timeline=(project_info.get("suggested_timeline") or "待确认")[:120], - confidence=_safe_float(project_info.get("confidence"), confidence), - reasoning=json.dumps(_normalize_reasoning(project_info.get("reasoning")), ensure_ascii=False), - status="pending", - ) - self.db.add(suggestion) - await self.db.flush() - - project_id = await self._route_project_item( - item=item, - suggestion=suggestion, - sender=sender, - project_score=promoted_project_score, - cold_start_enabled=cold_start_enabled, - project_auto_create_threshold=project_auto_create_threshold, - ) - routing_decision, routing_reason, reasoning_bullets = self._build_project_routing_log( - project_score=promoted_project_score, - cold_start_enabled=cold_start_enabled, - project_auto_create_threshold=project_auto_create_threshold, - project_uncertain_threshold=project_uncertain_threshold, - ) - await self._write_processing_log( - feishu_message_id=feishu_message_id, - feishu_item_id=int(item.id), - raw_message=content, - ai_raw_output=json.dumps( - { - "level_classification": level_result, - "signal_extraction": signal_info, - "promoted_to_project_candidate": True, - "simple_signal_request": simple_signal_request, - "project_prefill": project_info, - }, - ensure_ascii=False, - ), - project_score=promoted_project_score, - signal_confidence=confidence, - level="project", - routing_decision=routing_decision, - routing_reason=routing_reason, - reasoning_bullets=reasoning_bullets, - processing_status="success", - error_message=None, - duration_ms=int((time.perf_counter() - item_started_at) * 1000), - ) - return { - "item_id": int(item.id), - "level": "project", - "project_id": project_id, - "signal_id": None, - "route_status": item.route_status, - } - - signal = Signal( - tenant_id=self.tenant_id, - source="Feishu", - content=f"[{sender or '未知用户'}]: {content[:800]}", - impact_score=max(0.1, min(1.0, confidence)), - priority=self._to_signal_priority(str(signal_info.get("priority") or "P2")), - processed=False, - strategic_analysis=json.dumps( - { - "source_context": { - "group_id": source_chat_id, - "sender": sender, - "sender_open_id": source_sender_open_id, - }, - "ai_analysis": { - "classification": "signal", - "reason": signal_info.get("summary"), - }, - }, - ensure_ascii=False, - ), - tasks_generated="[]", - feishu_item_id=int(item.id), - matched_project_id=matched_project_id, - title=(signal_info.get("title") or content[:40] or "未命名信号")[:200], - signal_type=self._normalize_signal_type(signal_info.get("type")), - summary=(signal_info.get("summary") or "")[:3000] or None, - reasoning=json.dumps(_normalize_reasoning(signal_info.get("reasoning")), ensure_ascii=False), - confidence=confidence, - recommended_action=self._normalize_recommended_action(signal_info.get("recommended_action")), - status="待处理", - ) - self.db.add(signal) - await self.db.flush() - - project_id = await self._route_signal_item( - item=item, - signal=signal, - matched_project_id=matched_project_id, - confidence=confidence, - cold_start_enabled=cold_start_enabled, - signal_auto_enter_threshold=signal_auto_enter_threshold, - ) - routing_decision, routing_reason, reasoning_bullets = self._build_signal_routing_log( - confidence=confidence, - cold_start_enabled=cold_start_enabled, - matched_project_id=matched_project_id, - signal_auto_enter_threshold=signal_auto_enter_threshold, - ) - await self._write_processing_log( - feishu_message_id=feishu_message_id, - feishu_item_id=int(item.id), - raw_message=content, - ai_raw_output=json.dumps( - { - "level_classification": level_result, - "signal_extraction": signal_info, - "simple_signal_request": simple_signal_request, - "chat_context_inferred_project_id": inferred_project_id, - "chat_context_resolution": chat_context_resolution, - "clarification_message": clarification_message, - }, - ensure_ascii=False, - ), - project_score=project_score, - signal_confidence=confidence, - level="signal", - routing_decision=routing_decision, - routing_reason=routing_reason, - reasoning_bullets=reasoning_bullets, - processing_status="success", - error_message=None, - duration_ms=int((time.perf_counter() - item_started_at) * 1000), - ) - return { - "item_id": int(item.id), - "level": "signal", - "project_id": project_id, - "signal_id": int(signal.id), - "route_status": item.route_status, - "clarification_message": clarification_message, - } - - async def _route_project_item( - self, - *, - item: FeishuItem, - suggestion: ProjectSuggestion, - sender: str, - project_score: float, - cold_start_enabled: bool, - project_auto_create_threshold: float, - ) -> Optional[int]: - _ = project_auto_create_threshold - high_score = project_score >= max(project_auto_create_threshold, 0.7) - item.card_type = "new_project" if high_score else "new_project_candidate" - item.route_status = ROUTE_STATUS_QUEUED - item.queue_visible = True - item.message_class = "action_required" - item.safe_to_auto_execute = False - item.auto_execute_state = "blocked" - item.blocking_reason = ( - "当前处于冷启动保护期,建项目默认需要你确认后再执行。" - if cold_start_enabled - else "建项目属于高风险动作,默认需要你授权后再执行。" - ) - item.decision_reason = ( - "intent gate/自由分类均不再直接 auto create,避免误把自然语言写成项目主数据。" - ) - item.next_step_text = "确认后,系统会走受控 project.create,并绑定真实项目 ID。" - return None - - async def _route_signal_item( - self, - *, - item: FeishuItem, - signal: Signal, - matched_project_id: Optional[int], - confidence: float, - cold_start_enabled: bool, - signal_auto_enter_threshold: float, - ) -> Optional[int]: - item.matched_project_id = int(matched_project_id) if matched_project_id else None - gate = kernel_gate_execution( - confidence=float(confidence or 0.0), - risk_level=("medium" if matched_project_id else "high"), - correction_action="none", - ) - decision_trace = build_decision_trace( - intent=INTENT_SIGNAL_INTAKE, - confidence=float(confidence or 0.0), - route_tier="rule", - route_function="_route_signal_item", - gate_action=gate.action, - gate_code=gate.gate_code, - correction_action="none", - correction_keywords=[], - complexity_reasons=[], - ) - if cold_start_enabled or gate.action != "auto_execute" or confidence < signal_auto_enter_threshold or not matched_project_id: - item.card_type = "signal" - item.route_status = ROUTE_STATUS_QUEUED - item.queue_visible = True - item.message_class = "action_required" - item.safe_to_auto_execute = False - item.auto_execute_state = "blocked" - if not matched_project_id: - item.blocking_reason = "当前缺少明确项目归属,继续自动执行会产生错误副作用。" - item.next_step_text = "补齐项目归属后,AI 将自动把信号入库并继续后续链路。" - item.decision_reason = "上下文尚不完整,系统暂停在需你决策队列。" - elif cold_start_enabled: - item.blocking_reason = "当前处于冷启动保护期,信号入库暂不自动执行。" - item.next_step_text = "确认后,AI 将继续完成信号入库并推进后续动作。" - item.decision_reason = "保守自动化已拦截自动入库,避免早期误关联。" - elif gate.action == "reject": - item.blocking_reason = gate.reason - item.next_step_text = "请补充更明确的执行对象后重试,或人工确认继续处理。" - item.decision_reason = "共享决策内核判定置信度不足,自动执行被拒绝。" - else: - item.blocking_reason = gate.reason or "当前置信度不足以自动入库,需要你拍板后再继续。" - item.next_step_text = "确认后,AI 将把信号入库并进入后续生成流程。" - item.decision_reason = "信号虽然可执行,但归属判断仍存在不确定性。" - item.result_payload = { - "result_summary": "信号已进入待确认队列。", - "result_count": 0, - "impact_objects": [signal.title or f"信号#{signal.id}"], - "decision_trace": decision_trace, - } - return matched_project_id - - await self._ensure_signal_link(signal_id=int(signal.id), project_id=int(matched_project_id)) - item.card_type = "signal_auto" - item.route_status = ROUTE_STATUS_AUTO_DONE - item.queue_visible = False - item.processed_at = _utc_now().replace(tzinfo=None) - item.message_class = "auto_done" - item.safe_to_auto_execute = True - item.auto_execute_state = "done" - item.blocking_reason = None - item.decision_reason = "低风险且上下文完整,AI 已自动完成信号入库。" - item.next_step_text = "下一步可基于该信号继续生成 charter 或分发任务。" - item.undoable_until = _build_undo_deadline(item.processed_at) - item.result_payload = { - "result_summary": f"已自动入库信号 {signal.title or '未命名信号'}", - "result_count": 1, - "impact_objects": [signal.title or f"信号#{signal.id}", f"项目#{int(matched_project_id)}"], - "target_url": f"/projects/{int(matched_project_id)}/board", - "project_id": int(matched_project_id), - "decision_trace": decision_trace, - } - await self._notify_signal_auto_entered( - project_id=int(matched_project_id), - signal=signal, - ) - return int(matched_project_id) - - async def _extract_project_prefill(self, message: str) -> dict[str, Any]: - prompt = PROMPT_FEISHU_PROJECT_PREFILL.format(message=message[:1500]) - try: - raw = await self.llm.generate( - prompt, - db=self.db, - tenant_id=self.tenant_id, - function_name="feishu_project_prefill", - ) - parsed = _safe_json_object(raw) - if not parsed: - raise ValueError("project_prefill_empty_json") - parsed["reasoning"] = _normalize_reasoning(parsed.get("reasoning")) - return _validate_schema(_ProjectPrefillSchema, parsed, context="project_prefill") - except Exception as exc: - logger.warning(f"feishu_project_prefill_fallback: error={exc}") - fallback = self._heuristic_project_prefill_fallback(message) - return _validate_schema(_ProjectPrefillSchema, fallback, context="project_prefill_fallback") - - async def _extract_signal(self, message: str, *, projects: list[dict[str, Any]]) -> dict[str, Any]: - if self._is_simple_signal_source_request(message): - fallback = self._heuristic_signal_fallback(message=message, projects=projects) - return _validate_schema(_SignalExtractionSchema, fallback, context="signal_extraction_fastpath") - - prompt = PROMPT_FEISHU_SIGNAL_EXTRACTION.format( - message=message[:1500], - projects_json=json.dumps(projects, ensure_ascii=False), - ) - try: - raw = await self.llm.generate( - prompt, - db=self.db, - tenant_id=self.tenant_id, - function_name="feishu_signal_extraction", - ) - parsed = _safe_json_object(raw) - if not parsed: - raise ValueError("signal_extraction_empty_json") - parsed["reasoning"] = _normalize_reasoning(parsed.get("reasoning")) - return _validate_schema(_SignalExtractionSchema, parsed, context="signal_extraction") - except Exception as exc: - logger.warning(f"feishu_signal_extraction_fallback: error={exc}") - fallback = self._heuristic_signal_fallback(message=message, projects=projects) - return _validate_schema(_SignalExtractionSchema, fallback, context="signal_extraction_fallback") - - async def _classify_level(self, *, message: str, projects: list[dict[str, Any]]) -> dict[str, Any]: - if self._is_simple_signal_source_request(message): - fallback = self._heuristic_level_fallback(message) - return _validate_schema(_LevelClassificationSchema, fallback, context="level_classification_fastpath") - - prompt = PROMPT_FEISHU_LEVEL_CLASSIFICATION.format( - message=message[:1500], - projects_json=json.dumps(projects, ensure_ascii=False), - ) - try: - raw = await self.llm.generate( - prompt, - db=self.db, - tenant_id=self.tenant_id, - function_name="feishu_level_classification", - ) - parsed = _safe_json_object(raw) - if not parsed: - raise ValueError("level_classification_empty_json") - - # 兼容不同模型输出别名,避免只返回总分/中文 level 时被误降级到 signal。 - bi_raw = _first_non_empty(parsed, ("build_intent_score", "buildIntentScore", "intent_score", "build_intent")) - sc_raw = _first_non_empty(parsed, ("scope_score", "scopeScore", "scope")) - tl_raw = _first_non_empty(parsed, ("timeline_score", "timelineScore", "timeline")) - ow_raw = _first_non_empty(parsed, ("owner_score", "ownerScore", "owner")) - provided_project_score_raw = _first_non_empty(parsed, ("project_score", "projectScore", "score")) - - bi = _safe_float(bi_raw, 0.0) - sc = _safe_float(sc_raw, 0.0) - tl = _safe_float(tl_raw, 0.0) - ow = _safe_float(ow_raw, 0.0) - weighted = round((bi * 0.4) + (sc * 0.25) + (tl * 0.2) + (ow * 0.15), 4) - - component_count = sum(value is not None for value in (bi_raw, sc_raw, tl_raw, ow_raw)) - has_project_score = provided_project_score_raw is not None - provided_project_score = _safe_float(provided_project_score_raw, 0.0) - - final_project_score = weighted - if component_count < 4 and has_project_score: - final_project_score = provided_project_score - elif component_count > 0 and has_project_score: - # 当模型给出了总分与维度分,优先采用更保守的较高值,降低误判为 signal 的风险。 - final_project_score = max(weighted, provided_project_score) - final_project_score = max(0.0, min(1.0, final_project_score)) - - default_level = "project" if final_project_score >= 0.6 else "signal" - normalized_split_items: list[dict[str, str]] = [] - raw_split_items = parsed.get("split_items") - if isinstance(raw_split_items, list): - for item in raw_split_items[:2]: - if not isinstance(item, dict): - continue - item_level = _normalize_level_value(item.get("level"), default=default_level) - item_content = str(item.get("content") or "").strip() - if not item_content: - continue - normalized_split_items.append({"level": item_level, "content": item_content}) - - level_reasoning = str(parsed.get("level_reasoning") or "").strip() or "模型返回缺少理由,已按结构化兜底。" - payload: dict[str, Any] = { - "build_intent_score": max(0.0, min(1.0, bi)), - "scope_score": max(0.0, min(1.0, sc)), - "timeline_score": max(0.0, min(1.0, tl)), - "owner_score": max(0.0, min(1.0, ow)), - "project_score": final_project_score, - "level": _normalize_level_value(parsed.get("level"), default=default_level), - "level_confidence": max( - 0.0, - min(1.0, _safe_float(parsed.get("level_confidence"), max(final_project_score, 1 - final_project_score))), - ), - "level_reasoning": level_reasoning[:600], - "should_split": _safe_bool(parsed.get("should_split"), False), - "split_items": normalized_split_items, - } - if self._is_simple_signal_source_request(message): - payload["project_score"] = min(_safe_float(payload.get("project_score"), final_project_score), 0.35) - payload["level"] = "signal" - payload["should_split"] = False - payload["split_items"] = [] - payload["level_reasoning"] = ( - f"{payload['level_reasoning']};命中简单需求模式,按信号处理避免误升级为项目。" - )[:600] - return _validate_schema(_LevelClassificationSchema, payload, context="level_classification") - except Exception as exc: - logger.warning(f"feishu_level_classification_fallback: error={exc}") - fallback = self._heuristic_level_fallback(message) - return _validate_schema(_LevelClassificationSchema, fallback, context="level_classification_fallback") - - def _heuristic_level_fallback(self, message: str) -> dict[str, Any]: - text = message or "" - build_intent_score = 1.0 if PROJECT_INTENT_RE.search(text) else 0.2 - scope_score = 0.75 if ("系统" in text or "平台" in text or "功能" in text) else 0.35 - timeline_score = 0.8 if TIMELINE_RE.search(text) else 0.1 - owner_score = 0.7 if OWNER_RE.search(text) else 0.15 - project_score = round( - (build_intent_score * 0.4) - + (scope_score * 0.25) - + (timeline_score * 0.2) - + (owner_score * 0.15), - 4, - ) - has_project_intent = bool(PROJECT_INTENT_RE.search(text)) - has_signal_fact = bool(SIGNAL_FACT_RE.search(text)) - should_split = bool(has_project_intent and has_signal_fact) - simple_signal_request = self._is_simple_signal_source_request(text) - if simple_signal_request: - project_score = min(project_score, 0.35) - should_split = False - level = "signal" - level_reasoning = "命中简单需求规则,按信号处理,避免误升级项目。" - else: - level = "project" if project_score >= 0.6 else "signal" - level_reasoning = "基于关键词与上下文规则的兜底判定。" - return { - "build_intent_score": build_intent_score, - "scope_score": scope_score, - "timeline_score": timeline_score, - "owner_score": owner_score, - "project_score": project_score, - "level": level, - "level_confidence": max(project_score, 1 - project_score), - "level_reasoning": level_reasoning, - "should_split": should_split, - "split_items": ( - [ - {"level": "project", "content": text}, - {"level": "signal", "content": text}, - ] - if should_split - else [] - ), - } - - @staticmethod - def _guess_matched_project_id_from_text(message: str, projects: list[dict[str, Any]]) -> Optional[int]: - normalized_message = str(message or "").strip().lower() - if not normalized_message: - return None - for row in projects: - if not isinstance(row, dict): - continue - pid_raw = row.get("id") - title = str(row.get("name") or row.get("title") or "").strip() - if not title or len(title) < 2: - continue - try: - pid = int(pid_raw) if pid_raw is not None else None - except Exception: - pid = None - if pid is None: - continue - if title.lower() in normalized_message: - return pid - return None - - def _heuristic_signal_fallback(self, *, message: str, projects: list[dict[str, Any]]) -> dict[str, Any]: - text = str(message or "").strip() - normalized = text.lower() - cleaned = re.sub(r"\s+", " ", text).strip() - summary = cleaned[:280] if cleaned else "飞书消息(空内容)" - - signal_type = "新需求" - priority = "P2" - recommended_action = "enter_only" - if any(k in normalized for k in ("故障", "告警", "宕机", "报错", "bug", "风险")): - signal_type = "项目风险" - priority = "P1" - recommended_action = "trigger_alert" - elif any(k in normalized for k in ("反馈", "吐槽", "体验", "投诉")): - signal_type = "用户反馈" - priority = "P2" - elif any(k in normalized for k in ("竞品", "对标", "发布", "上线")): - signal_type = "竞品动态" - priority = "P2" - - title_seed = cleaned or "飞书需求信号" - title = title_seed[:48] - if not title: - title = "飞书需求信号" - confidence = 0.78 if signal_type in {"项目风险", "用户反馈"} else 0.72 - - return { - "title": title, - "type": signal_type, - "priority": priority, - "matched_project_id": self._guess_matched_project_id_from_text(text, projects), - "summary": summary, - "reasoning": ["LLM 返回异常,已按关键词规则生成兜底信号结构。"], - "confidence": confidence, - "recommended_action": recommended_action, - } - - @staticmethod - def _heuristic_project_prefill_fallback(message: str) -> dict[str, Any]: - text = str(message or "").strip() - cleaned = re.sub(r"\s+", " ", text).strip() - suggested_name = cleaned[:30] if cleaned else "飞书新需求项目" - if len(suggested_name) < 4: - suggested_name = "飞书新需求项目" - suggested_description = ( - f"由飞书需求自动生成的项目草案:{cleaned[:200]}" - if cleaned - else "由飞书需求自动生成的项目草案,待补充目标、范围与里程碑。" - ) - return { - "suggested_name": suggested_name, - "suggested_description": suggested_description, - "suggested_owner": "待指定", - "suggested_timeline": "待评估", - "confidence": 0.62, - "reasoning": ["LLM 返回异常,已按规则生成项目预填草案。"], - } - - async def _load_routing_policy(self) -> dict[str, float | bool]: - integration = ( - await self.db.execute( - select(Integration).where( - and_( - Integration.tenant_id == self.tenant_id, - Integration.type == "feishu", - ) - ) - ) - ).scalar_one_or_none() - config: dict[str, Any] = {} - if integration is not None: - if isinstance(integration.config, dict): - config = integration.config - elif isinstance(integration.config, str): - config = _safe_json_object(integration.config) - - cold_start_enabled = bool(config.get("cold_start_enabled")) - chat_context_auto_link_enabled = bool(config.get("chat_context_auto_link_enabled", False)) - - signal_auto_enter_threshold = min( - 1.0, - max( - 0.0, - _safe_float( - config.get("signal_auto_enter_threshold"), - DEFAULT_SIGNAL_AUTO_ENTER_THRESHOLD, - ), - ), - ) - project_auto_create_threshold = min( - 1.0, - max( - 0.0, - _safe_float( - config.get("project_auto_create_threshold"), - DEFAULT_PROJECT_AUTO_CREATE_THRESHOLD, - ), - ), - ) - project_uncertain_threshold = min( - project_auto_create_threshold, - max( - 0.0, - _safe_float( - config.get("project_uncertain_threshold"), - DEFAULT_PROJECT_UNCERTAIN_THRESHOLD, - ), - ), - ) - return { - "cold_start_enabled": cold_start_enabled, - "signal_auto_enter_threshold": signal_auto_enter_threshold, - "project_auto_create_threshold": project_auto_create_threshold, - "project_uncertain_threshold": project_uncertain_threshold, - "chat_context_auto_link_enabled": chat_context_auto_link_enabled, - } - - async def _load_active_projects(self) -> list[dict[str, Any]]: - rows = ( - await self.db.execute( - select(Project).where( - and_( - Project.tenant_id == self.tenant_id, - Project.status != "inactive", - ) - ) - ) - ).scalars().all() - return [ - { - "id": int(p.id), - "name": p.name, - "title": p.title or p.name, - "status": p.status, - "description": p.description or "", - } - for p in rows - ] - - async def _create_project_from_suggestion( - self, - suggestion: ProjectSuggestion, - *, - actor_user_id: Optional[int], - ) -> Project: - owner_id = await self._resolve_user_id_by_name(suggestion.suggested_owner) or actor_user_id - project_name = (suggestion.suggested_name or "新项目").strip()[:255] or "新项目" - project_desc = (suggestion.suggested_description or "").strip()[:4000] or None - await ensure_project_id_sequence(self.db) - project = Project( - tenant_id=self.tenant_id, - name=project_name, - title=project_name, - description=project_desc, - owner_id=owner_id, - status="active", - domain_id=None, - assigned_role="ai_engineer", - signal_count=0, - ) - self.db.add(project) - await self.db.flush() - await ensure_project_default_members( - self.db, - tenant_id=self.tenant_id, - project_id=int(project.id), - owner_id=int(project.owner_id) if project.owner_id is not None else None, - assigned_role=project.assigned_role, - ) - return project - - async def _ensure_signal_link(self, *, signal_id: int, project_id: int) -> None: - existed = ( - await self.db.execute( - select(ProjectSignalLink.id).where( - and_( - ProjectSignalLink.tenant_id == self.tenant_id, - ProjectSignalLink.project_id == project_id, - ProjectSignalLink.signal_id == signal_id, - ) - ) - ) - ).scalar_one_or_none() - if existed is not None: - return - row = ProjectSignalLink( - tenant_id=self.tenant_id, - project_id=int(project_id), - signal_id=int(signal_id), - link_type="feishu_signal", - relevance_score=0.8, - ) - self.db.add(row) - - async def _notify_project_created(self, *, project: Project) -> None: - if not project.owner_id: - return - project_name = project.title or project.name or f"项目#{project.id}" - await NotificationService(self.db).create_notification( - tenant_id=self.tenant_id, - user_id=int(project.owner_id), - category="requirement", - title=f"创建项目:{project_name}", - content=f"AI 已根据飞书消息自动创建项目 [{project_name}],可直接进入 SSTC 看板。", - source="feishu", - entity_type="project", - entity_id=int(project.id), - target_url=f"/projects/{project.id}/board", - kind="auto_done", - priority="high", - status="auto_done", - recommended_action="view_result", - dedupe_key=f"feishu-project-auto:{int(project.id)}", - payload={ - "message_class": "auto_done", - "summary": f"AI 已自动创建项目 {project_name}", - "ai_plan": "下一步可进入项目看板补齐 charter 与执行任务。", - "project_id": int(project.id), - "project_name": project_name, - "result_count": 1, - "impact_objects": [project_name], - "can_undo": True, - "undo_deadline": _build_undo_deadline(_utc_now().replace(tzinfo=None)).isoformat(), - }, - commit=False, - ) - - async def _notify_signal_auto_entered(self, *, project_id: int, signal: Signal) -> None: - project = ( - await self.db.execute( - select(Project).where( - and_( - Project.tenant_id == self.tenant_id, - Project.id == project_id, - ) - ) - ) - ).scalar_one_or_none() - if not project or not project.owner_id: - return - project_name = project.title or project.name or f"项目#{project.id}" - await NotificationService(self.db).create_notification( - tenant_id=self.tenant_id, - user_id=int(project.owner_id), - category="requirement", - title=f"信号已自动入库:{signal.title or '未命名信号'}", - content="高置信度信号已自动进入项目信号源,可直接继续生成宪章。", - source="feishu", - entity_type="signal", - entity_id=int(signal.id), - target_url=f"/projects/{project_id}/board", - kind="auto_done", - priority=str(signal.priority or "medium"), - status="auto_done", - recommended_action="view_result", - dedupe_key=f"feishu-signal-auto:{int(signal.id)}:{int(project_id)}", - payload={ - "message_class": "auto_done", - "summary": f"AI 已把信号 {signal.title or f'#{signal.id}'} 自动入库到 {project_name}", - "ai_plan": "下一步可继续生成 charter、任务或通知负责人。", - "project_id": int(project_id), - "project_name": project_name, - "result_count": 1, - "impact_objects": [signal.title or f"信号#{signal.id}", project_name], - "can_undo": True, - "undo_deadline": _build_undo_deadline(_utc_now().replace(tzinfo=None)).isoformat(), - }, - commit=False, - ) - - async def _load_project_name(self, project_id: Optional[int]) -> Optional[str]: - if project_id is None: - return None - project = ( - await self.db.execute( - select(Project).where( - and_( - Project.tenant_id == self.tenant_id, - Project.id == int(project_id), - ) - ) - ) - ).scalar_one_or_none() - if project is None: - return None - return str(project.title or project.name or "").strip() or f"项目#{int(project_id)}" - - @staticmethod - def _resolve_message_class(item: FeishuItem) -> str: - value = str(item.message_class or "").strip().lower() - if value in {"action_required", "auto_running", "auto_done", "info", "chat", "query", "ignored"}: - return value - route_status = FeishuSignalPipelineService._normalize_route_status(item.route_status) - if route_status == ROUTE_STATUS_QUEUED: - return "action_required" - if route_status == ROUTE_STATUS_AUTO_RUNNING: - return "auto_running" - if route_status in {ROUTE_STATUS_AUTO_DONE, ROUTE_STATUS_UNDONE}: - return "auto_done" - return "info" - - @staticmethod - def _resolve_auto_execute_state(item: FeishuItem) -> str: - value = str(item.auto_execute_state or "").strip().lower() - if value: - return value - route_status = FeishuSignalPipelineService._normalize_route_status(item.route_status) - if route_status == ROUTE_STATUS_AUTO_DONE: - return "done" - if route_status == ROUTE_STATUS_QUEUED: - return "blocked" - if route_status == ROUTE_STATUS_UNDONE: - return "undone" - if route_status == ROUTE_STATUS_FAILED: - return "failed" - return "idle" - - async def _serialize_item( - self, - item: FeishuItem, - *, - include_raw: bool = False, - include_history: bool = False, - ) -> dict[str, Any]: - result_payload = item.result_payload if isinstance(item.result_payload, dict) else {} - ai_output = _safe_dict(item.ai_output) - project_name = await self._load_project_name(_safe_int(item.matched_project_id)) - row: dict[str, Any] = { - "id": str(item.id), - "feishu_message_id": item.feishu_message_id, - "level": item.level, - "card_type": item.card_type, - "route_status": self._normalize_route_status(item.route_status), - "project_score": item.project_score, - "signal_confidence": item.signal_confidence, - "level_confidence": item.level_confidence, - "level_reasoning": item.level_reasoning, - "matched_project_id": item.matched_project_id, - "project_name": project_name, - "sender": item.source_sender, - "created_at": item.created_at.isoformat() if item.created_at else None, - "processed_at": item.processed_at.isoformat() if item.processed_at else None, - "updated_at": item.updated_at.isoformat() if item.updated_at else None, - "decision_reason": item.decision_reason, - "blocking_reason": item.blocking_reason, - "safe_to_auto_execute": bool(item.safe_to_auto_execute), - "auto_execute_state": self._resolve_auto_execute_state(item), - "dedupe_key": item.dedupe_key, - "message_class": self._resolve_message_class(item), - "occurrence_count": int(item.occurrence_count or 1), - "next_step_text": item.next_step_text, - "undoable_until": item.undoable_until.isoformat() if item.undoable_until else None, - "result_payload": result_payload or None, - "display_kind": self._resolve_message_class(item), - "target_url": result_payload.get("target_url") if isinstance(result_payload, dict) else None, - "priority_rank": 0, - } - if not row["target_url"] and item.matched_project_id: - row["target_url"] = f"/projects/{int(item.matched_project_id)}/board" - - if include_raw: - row["raw_message"] = item.raw_message - - if (item.level or "").lower() == "project": - suggestion = await self._get_project_suggestion(item.id) - operation_payload = result_payload.get("operation_payload") if isinstance(result_payload.get("operation_payload"), dict) else {} - project_fields = { - "title": str( - (suggestion.suggested_name if suggestion is not None else None) - or operation_payload.get("title") - or "" - ).strip(), - "description": str( - (suggestion.suggested_description if suggestion is not None else None) - or operation_payload.get("description") - or "" - ).strip(), - "project_scope": str(result_payload.get("project_scope") or "").strip(), - "missing_fields": [], - "ready": False, - } - if ( - self._looks_like_placeholder_project_text(project_fields["title"]) - or self._looks_like_placeholder_project_text(project_fields["description"]) - or self._looks_like_placeholder_project_text(project_fields["project_scope"]) - ): - project_fields = await self._maybe_prefill_project_fields_from_context( - message=str(item.raw_message or ""), - source_chat_id=str(item.source_chat_id or ""), - source_sender_open_id=str(item.source_sender_open_id or "") or None, - sender=str(item.source_sender or ""), - fields=project_fields, - ) - if suggestion: - created_project_id = _safe_int(suggestion.created_project_id) - created_project_name = await self._load_project_name(created_project_id) - row["project_suggestion"] = { - "id": str(suggestion.id), - "suggested_name": suggestion.suggested_name, - "suggested_description": suggestion.suggested_description, - "suggested_owner": suggestion.suggested_owner, - "suggested_timeline": suggestion.suggested_timeline, - "confidence": suggestion.confidence, - "reasoning": _normalize_reasoning(suggestion.reasoning), - "status": suggestion.status, - "created_project_id": str(created_project_id) if created_project_id else None, - } - row["project_name"] = created_project_name or row["project_name"] - row["matched_project_id"] = created_project_id or row["matched_project_id"] - row["priority"] = "high" if (item.project_score or 0) >= 0.85 else "medium" - row["priority_rank"] = 4 if (item.project_score or 0) >= 0.85 else 3 - if created_project_id and not row["target_url"]: - row["target_url"] = f"/projects/{created_project_id}/board" - row["title"] = self._build_project_preview_title( - title=str(project_fields.get("title") or ""), - description=str(project_fields.get("description") or ""), - project_scope=str(project_fields.get("project_scope") or ""), - ) - row["summary"] = self._build_project_preview_summary( - description=str(project_fields.get("description") or ""), - project_scope=str(project_fields.get("project_scope") or ""), - blocking_reason=item.blocking_reason, - fallback_text=str(ai_output.get("normalized_text") or item.raw_message or ""), - ) - row["detail_summary"] = row["summary"] - if not row.get("priority"): - row["priority"] = "high" if (item.project_score or 0) >= 0.85 else "medium" - row["priority_rank"] = 4 if (item.project_score or 0) >= 0.85 else 3 - return row - - if (item.level or "").lower() == "command": - operation_code = str(result_payload.get("operation_code") or "").strip() - row["title"] = ( - str(result_payload.get("result_summary") or "").strip() - or operation_code - or "受控动作" - ) - row["summary"] = str(item.blocking_reason or row["title"] or "").strip() - row["priority"] = "high" if row["display_kind"] == "action_required" else "medium" - row["priority_rank"] = 4 if row["display_kind"] == "action_required" else 2 - if not row.get("next_step_text"): - row["next_step_text"] = self._build_pending_operation_next_step(operation_code=operation_code) - if result_payload: - row["command"] = { - "operation_code": operation_code or None, - "executor": str(result_payload.get("executor") or "").strip() or None, - "operation_payload": result_payload.get("operation_payload") if isinstance(result_payload.get("operation_payload"), dict) else None, - "operation_result": result_payload.get("operation_result") if isinstance(result_payload.get("operation_result"), dict) else None, - } - return row - - signal = await self._get_signal_by_item(item.id) - if signal: - matched_project_id = _safe_int(signal.matched_project_id) or _safe_int(item.matched_project_id) - matched_project_name = await self._load_project_name(matched_project_id) - row["signal"] = { - "id": str(signal.id), - "title": signal.title, - "signal_type": signal.signal_type, - "priority": signal.priority, - "summary": signal.summary, - "reasoning": _normalize_reasoning(signal.reasoning), - "confidence": signal.confidence, - "recommended_action": signal.recommended_action, - "matched_project_id": signal.matched_project_id, - "status": signal.status, - } - row["title"] = signal.title or "未命名信号" - row["summary"] = signal.summary - row["priority"] = signal.priority - row["priority_rank"] = PROJECT_PRIORITY_ORDER.get(str(signal.priority or "").upper(), 1) - row["matched_project_id"] = matched_project_id - row["project_name"] = matched_project_name or row["project_name"] - if matched_project_id and not row["target_url"]: - row["target_url"] = f"/projects/{matched_project_id}/board" - if not row.get("next_step_text"): - if row["display_kind"] == "auto_done": - row["next_step_text"] = "下一步可继续生成 charter、任务或通知负责人。" - elif not matched_project_id: - row["next_step_text"] = "补齐项目归属后,AI 将自动继续后续链路。" - if not row.get("summary"): - row["summary"] = _compact_text(signal.content or item.raw_message, max_len=140) - if include_history: - history_rows = ( - await self.db.execute( - select(SignalEdit) - .where( - and_( - SignalEdit.tenant_id == self.tenant_id, - SignalEdit.signal_id == int(signal.id), - ) - ) - .order_by(SignalEdit.edited_at.desc()) - .limit(20) - ) - ).scalars().all() - row["edit_history"] = [ - { - "field": h.field, - "old_value": h.old_value, - "new_value": h.new_value, - "edited_by": str(h.edited_by) if h.edited_by else None, - "edited_at": h.edited_at.isoformat() if h.edited_at else None, - } - for h in history_rows - ] - return row - - async def _resolve_user_id_by_name(self, name: Optional[str]) -> Optional[int]: - key = (name or "").strip() - if not key: - return None - user = ( - await self.db.execute( - select(User).where( - and_( - User.tenant_id == self.tenant_id, - User.is_active.is_(True), - func.lower(User.name) == key.lower(), - ) - ) - ) - ).scalar_one_or_none() - if user: - return int(user.id) - fuzzy = ( - await self.db.execute( - select(User).where( - and_( - User.tenant_id == self.tenant_id, - User.is_active.is_(True), - User.name.ilike(f"%{key}%"), - ) - ).limit(1) - ) - ).scalar_one_or_none() - return int(fuzzy.id) if fuzzy else None - - async def _fallback_user_id(self) -> Optional[int]: - user = ( - await self.db.execute( - select(User.id).where( - and_( - User.tenant_id == self.tenant_id, - User.is_active.is_(True), - ) - ).limit(1) - ) - ).scalar_one_or_none() - return int(user) if user else None - - async def _resolve_chat_context_project(self, *, source_chat_id: str) -> dict[str, Any]: - chat_id = (source_chat_id or "").strip() - if not chat_id: - return {"project_id": None, "ambiguous": False, "candidate_ids": [], "top_ratio": 0.0} - - recent_rows = ( - await self.db.execute( - select(FeishuItem.matched_project_id) - .where( - and_( - FeishuItem.tenant_id == self.tenant_id, - FeishuItem.source_chat_id == chat_id, - FeishuItem.matched_project_id.is_not(None), - FeishuItem.route_status.in_((ROUTE_STATUS_AUTO_DONE, "auto_processed", "confirmed")), - ) - ) - .order_by(FeishuItem.id.desc()) - .limit(20) - ) - ).all() - project_ids = [int(row[0]) for row in recent_rows if row and row[0] is not None] - if not project_ids: - return {"project_id": None, "ambiguous": False, "candidate_ids": [], "top_ratio": 0.0} - - unique_ids = set(project_ids) - candidate_ids = sorted(unique_ids, key=lambda pid: (-project_ids.count(pid), pid)) - if len(unique_ids) == 1: - return {"project_id": int(project_ids[0]), "ambiguous": False, "candidate_ids": candidate_ids, "top_ratio": 1.0} - - top_pid = max(unique_ids, key=lambda pid: project_ids.count(pid)) - top_count = project_ids.count(top_pid) - ratio = top_count / len(project_ids) - if top_count >= 2 and ratio >= 0.6: - return {"project_id": int(top_pid), "ambiguous": False, "candidate_ids": candidate_ids, "top_ratio": ratio} - return {"project_id": None, "ambiguous": True, "candidate_ids": candidate_ids, "top_ratio": ratio} - - async def _infer_project_id_from_chat_context(self, *, source_chat_id: str) -> Optional[int]: - resolution = await self._resolve_chat_context_project(source_chat_id=source_chat_id) - return _safe_int(resolution.get("project_id")) - - async def _get_item(self, item_id: int) -> Optional[FeishuItem]: - return ( - await self.db.execute( - select(FeishuItem).where( - and_( - FeishuItem.tenant_id == self.tenant_id, - FeishuItem.id == int(item_id), - ) - ) - ) - ).scalar_one_or_none() - - async def _get_project_suggestion(self, item_id: int) -> Optional[ProjectSuggestion]: - return ( - await self.db.execute( - select(ProjectSuggestion).where( - and_( - ProjectSuggestion.tenant_id == self.tenant_id, - ProjectSuggestion.feishu_item_id == int(item_id), - ) - ) - ) - ).scalar_one_or_none() - - async def _get_signal_by_item(self, item_id: int) -> Optional[Signal]: - return ( - await self.db.execute( - select(Signal).where( - and_( - Signal.tenant_id == self.tenant_id, - Signal.feishu_item_id == int(item_id), - ) - ) - ) - ).scalar_one_or_none() - - def _dedupe_queue_items(self, rows: list[FeishuItem]) -> list[FeishuItem]: - deduped: list[FeishuItem] = [] - seen_signal_keys: set[str] = set() - aggregated: dict[str, FeishuItem] = {} - for row in rows: - explicit_key = str(row.dedupe_key or "").strip() - if explicit_key: - existing = aggregated.get(explicit_key) - if existing is None: - aggregated[explicit_key] = row - deduped.append(row) - else: - existing.occurrence_count = max(1, int(existing.occurrence_count or 1)) + max( - 1, int(row.occurrence_count or 1) - ) - continue - level = str(row.level or "").lower() - if level != "signal": - deduped.append(row) - continue - message_id = str(row.feishu_message_id or "").strip() - key = message_id or f"{str(row.raw_message or '')[:120]}::{str(row.source_sender or '')}" - if key in seen_signal_keys: - continue - seen_signal_keys.add(key) - deduped.append(row) - return deduped - - @staticmethod - def _is_water_talk_message(message: str) -> bool: - content = str(message or "").strip().lower() - if not content: - return True - normalized = re.sub(r"\s+", "", content) - question_hints = ( - "?", - "?", - "吗", - "怎么", - "如何", - "为什么", - "谁", - "多少", - "是否", - "有没有", - "几号", - "几点", - "周几", - ) - water_keywords = ( - "你是谁", - "你是誰", - "什么身份", - "身份确认", - "今天什么日期", - "今天几号", - "当前日期", - "日期疑问", - "询问日期", - "现在几点", - "几点了", - "今天周几", - "今天星期几", - "说话", - "在吗", - "在不在", - ) - requirement_hints = ( - "需求", - "优化", - "调整", - "修复", - "bug", - "故障", - "报错", - "项目", - "页面", - "接口", - "功能", - "排版", - "样式", - "任务", - "立项", - "上线", - "配置", - ) - if any(k in normalized for k in water_keywords) and not any(h in normalized for h in requirement_hints): - return True - if any(k in normalized for k in question_hints): - return False - return False - - @staticmethod - def _build_rag_conversation_key( - *, - source_chat_id: Optional[str], - source_sender_open_id: Optional[str], - sender: Optional[str], - ) -> str: - chat = str(source_chat_id or "").strip() - if not chat: - return "" - actor = str(source_sender_open_id or "").strip() - if not actor: - actor = str(sender or "").strip().lower() - actor = re.sub(r"\s+", "", actor) - actor = actor[:96] if actor else "unknown" - return f"feishu:{chat}:{actor}" - - @staticmethod - def _is_requirement_like_question(message: str) -> bool: - text = str(message or "").strip() - if not text: - return False - normalized = re.sub(r"\s+", "", text.lower()) - has_question = ( - "?" in normalized - or "?" in normalized - or any( - cue in normalized - for cue in ("吗", "是否", "是不是", "要不要", "能不能", "可不可以", "需不需要", "有没有必要") - ) - ) - if not has_question: - return False - if INDEPENDENT_REQUIREMENT_RE.search(normalized): - return True - if SIMPLE_SIGNAL_REQUEST_RE.search(normalized): - return True - has_verb = bool(QUESTION_REQUIREMENT_VERB_RE.search(normalized)) - has_object = bool(QUESTION_REQUIREMENT_OBJECT_RE.search(normalized)) - return has_verb and has_object - - @staticmethod - def _extract_requirement_statement_from_question(message: str) -> str: - text = str(message or "").strip() - if not text: - return "" - candidate = re.sub(r"^(请问|问下|想问下|我想问下|麻烦问下|请教下)\s*", "", text, flags=re.IGNORECASE).strip() - candidate = re.sub(r"\s*[??!!。\.]+\s*$", "", candidate).strip() - candidate = re.sub(r"(吗|呢|呀|吧)\s*$", "", candidate).strip() - replacements = ( - (r"能不能", "需要"), - (r"可不可以", "需要"), - (r"要不要", "需要"), - (r"需不需要", "需要"), - (r"有没有必要", "需要"), - (r"是不是", ""), - (r"是否", ""), - ) - for pattern, repl in replacements: - candidate = re.sub(pattern, repl, candidate, flags=re.IGNORECASE) - candidate = re.sub(r"\s+", " ", candidate).strip(" ,,::") - return candidate or text - - async def _has_processed_message(self, feishu_message_id: str) -> bool: - key = (feishu_message_id or "").strip() - if not key: - return False - existed_in_items = ( - await self.db.execute( - select(FeishuItem.id).where( - and_( - FeishuItem.tenant_id == self.tenant_id, - FeishuItem.feishu_message_id == key, - ) - ).limit(1) - ) - ).scalar_one_or_none() - if existed_in_items is not None: - return True - existed = ( - await self.db.execute( - select(AIProcessingLog.id).where( - and_( - AIProcessingLog.tenant_id == self.tenant_id, - AIProcessingLog.feishu_message_id == key, - or_( - AIProcessingLog.level.is_(None), - AIProcessingLog.level != "ingress", - ), - or_( - AIProcessingLog.routing_decision.is_(None), - AIProcessingLog.routing_decision != "event_received", - ), - ) - ).limit(1) - ) - ).scalar_one_or_none() - return existed is not None - - async def _acquire_message_lock(self, feishu_message_id: str) -> None: - key = (feishu_message_id or "").strip() - if not key: - return - try: - bind = self.db.get_bind() - dialect_name = str(getattr(getattr(bind, "dialect", None), "name", "") or "").lower() - except Exception: - dialect_name = "" - if dialect_name not in {"postgresql", "postgres"}: - return - try: - await self.db.execute( - text("SELECT pg_advisory_xact_lock(hashtext(:key))"), - {"key": key}, - ) - except Exception as exc: - logger.warning(f"feishu_pipeline_message_lock_failed: key={key[:32]}, error={exc}") - - async def _write_processing_log( - self, - *, - feishu_message_id: Optional[str], - feishu_item_id: Optional[int], - raw_message: Optional[str], - ai_raw_output: Optional[str], - project_score: Optional[float], - signal_confidence: Optional[float], - level: Optional[str], - routing_decision: str, - routing_reason: Optional[str], - reasoning_bullets: Optional[list[str]], - processing_status: str, - error_message: Optional[str], - duration_ms: Optional[int], - normalized_text: Optional[str] = None, - route: Optional[str] = None, - intent: Optional[str] = None, - executor: Optional[str] = None, - operation_code: Optional[str] = None, - operation_result: Optional[dict[str, Any]] = None, - reply_source: Optional[str] = None, - ) -> None: - normalized_route = (route or routing_decision or "")[:80] or None - normalized_intent = (intent or level or "")[:40] or None - normalized_executor = (executor or ("explicit_service" if operation_code else "router"))[:40] or None - normalized_reply_source = ( - reply_source - or ("db_commit" if operation_code else "deterministic_reply") - ) - self.db.add( - AIProcessingLog( - tenant_id=self.tenant_id, - feishu_message_id=(feishu_message_id or "")[:128] or None, - feishu_item_id=int(feishu_item_id) if feishu_item_id is not None else None, - raw_message=(raw_message or "")[:8000] or None, - ai_raw_output=(ai_raw_output or "")[:120000] or None, - project_score=project_score, - signal_confidence=signal_confidence, - level=(level or "")[:20] or None, - normalized_text=(normalized_text or raw_message or "")[:8000] or None, - route=normalized_route, - intent=normalized_intent, - executor=normalized_executor, - operation_code=(operation_code or "")[:80] or None, - operation_result=operation_result if isinstance(operation_result, dict) else None, - reply_source=(normalized_reply_source or "")[:40] or None, - routing_decision=(routing_decision or ROUTING_FAILED)[:64], - routing_reason=(routing_reason or "")[:1000] or None, - reasoning_bullets=[str(x).strip()[:120] for x in (reasoning_bullets or [])[:3]], - processing_status=(processing_status or "success")[:20], - error_message=(error_message or "")[:4000] or None, - duration_ms=max(0, int(duration_ms)) if duration_ms is not None else None, - ) - ) - - def _build_project_routing_log( - self, - *, - project_score: float, - cold_start_enabled: bool, - project_auto_create_threshold: float, - project_uncertain_threshold: float, - ) -> tuple[str, str, list[str]]: - _ = project_auto_create_threshold - if cold_start_enabled: - return ( - ROUTING_PENDING_COLD_START, - "当前处于冷启动期,项目候选会统一进入待处理队列。", - [ - "冷启动期开启,自动创建策略暂缓。", - f"project_score={project_score:.2f},仅用于排序参考。", - "等待人工确认后再执行创建动作。", - ], - ) - if project_score >= project_uncertain_threshold: - return ( - ROUTING_PENDING_UNCERTAIN_PROJECT, - "项目分数较高,但建项已收口到待决策链,不再自动创建。", - [ - f"project_score={project_score:.2f},高于 {project_uncertain_threshold:.2f}。", - "即使意图强,也必须等待受控 project.create。", - "已进入待处理队列等待确认。", - ], - ) - return ( - ROUTING_PENDING_LOW_CONFIDENCE, - "项目分数偏低,暂不自动创建,进入待处理队列。", - [ - f"project_score={project_score:.2f},低于 {project_uncertain_threshold:.2f}。", - "消息更接近执行信号而非完整立项需求。", - "需要人工补充信息后再决策。", - ], - ) - - def _build_signal_routing_log( - self, - *, - confidence: float, - cold_start_enabled: bool, - matched_project_id: Optional[int], - signal_auto_enter_threshold: float, - ) -> tuple[str, str, list[str]]: - if cold_start_enabled: - return ( - ROUTING_PENDING_COLD_START, - "当前处于冷启动期,信号统一进入待处理队列。", - [ - "冷启动期开启,自动入库策略暂缓。", - f"signal_confidence={confidence:.2f},仅用于排序参考。", - "等待人工审核后再入库。", - ], - ) - if confidence < signal_auto_enter_threshold: - return ( - ROUTING_PENDING_LOW_CONFIDENCE, - "信号置信度低于自动入库阈值,已进入待处理队列。", - [ - f"signal_confidence={confidence:.2f},低于 {signal_auto_enter_threshold:.2f} 阈值。", - "当前信息不足以支持自动决策。", - "需要人工确认后再执行后续动作。", - ], - ) - if not matched_project_id: - return ( - ROUTING_PENDING_UNCERTAIN_PROJECT, - "置信度达标但项目归属不确定,转人工确认。", - [ - f"signal_confidence={confidence:.2f},达到自动判定线。", - "未识别到明确项目归属。", - "已进入待处理队列等待项目确认。", - ], - ) - return ( - ROUTING_AUTO_ENTER_SIGNAL, - "信号置信度达标且项目归属明确,系统已自动入库。", - [ - f"signal_confidence={confidence:.2f},高于 {signal_auto_enter_threshold:.2f} 阈值。", - f"已匹配项目 #{int(matched_project_id)}。", - "已自动建立信号关联并发送通知。", - ], - ) - - @staticmethod - def _normalize_recommended_action(value: Any) -> str: - action = str(value or "enter_only").strip().lower() - if action not in {"enter_only", "create_charter", "trigger_alert"}: - return "enter_only" - return action - - @staticmethod - def _normalize_signal_type(value: Any) -> str: - signal_type = str(value or "新需求").strip() - if signal_type in SIGNAL_TYPE_OPTIONS: - return signal_type - return "新需求" - - @staticmethod - def _to_signal_priority(value: str) -> str: - key = str(value or "").strip().upper() - if key in {"P0", "HIGH"}: - return "high" - if key in {"P1", "MEDIUM"}: - return "medium" - if key in {"P2", "LOW"}: - return "low" - if key == "P3": - return "low" - return "medium" - - def _should_promote_signal_to_project_candidate( - self, - *, - content: str, - signal_info: dict[str, Any], - confidence: float, - signal_auto_enter_threshold: float, - simple_signal_request: bool, - ) -> bool: - _ = (content, signal_info, confidence, signal_auto_enter_threshold, simple_signal_request) - # 项目创建只能通过 intent gate:create_project + 受控 operation executor 落库; - # 不再把 signal 自由升级为 project candidate。 - return False - - @staticmethod - def _is_simple_signal_source_request(message: str) -> bool: - text = re.sub(r"\s+", "", str(message or "")) - if len(text) < 6: - return False - if PROJECT_COMPLEXITY_RE.search(text): - return False - if SIMPLE_SIGNAL_REQUEST_RE.search(text): - return True - action_tokens = ("新增", "增加", "实现", "支持", "修复", "优化", "接入", "改造", "调整") - object_tokens = ("功能", "能力", "页面", "接口", "流程", "按钮", "提醒", "识别", "字段", "规则", "逻辑") - return ( - len(text) <= 180 - and any(token in text for token in action_tokens) - and any(token in text for token in object_tokens) - ) +__all__ = ["FeishuSignalPipelineService"] diff --git a/backend/app/services/feishu_signal_pipeline/__init__.py b/backend/app/services/feishu_signal_pipeline/__init__.py new file mode 100644 index 00000000..71765942 --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/__init__.py @@ -0,0 +1,5 @@ +"""Feishu signal pipeline package.""" + +from .service import FeishuSignalPipelineService + +__all__ = ["FeishuSignalPipelineService"] diff --git a/backend/app/services/feishu_signal_pipeline/mixin_command_commit_project.py b/backend/app/services/feishu_signal_pipeline/mixin_command_commit_project.py new file mode 100644 index 00000000..071be92d --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_command_commit_project.py @@ -0,0 +1,98 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * + + +class FeishuPipelineCommandCommitProjectMixin: + async def _commit_project_create( + self, + *, + operator_user: User, + payload: dict[str, Any], + trace_id: str, + ) -> dict[str, Any]: + operator_user_id = _safe_int(getattr(operator_user, "id", None)) + if operator_user_id is None: + raise ValueError("operator_not_found") + + title = normalize_project_title_candidate(str(payload.get("title") or "")) + if not title: + raise ValueError("project title required") + description = str(payload.get("description") or "").strip()[:4000] or None + + existing = ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == self.tenant_id, + or_(func.lower(Project.title) == title.lower(), func.lower(Project.name) == title.lower()), + Project.status != "inactive", + ) + ).limit(1) + ) + ).scalar_one_or_none() + if existing is not None: + return { + "status": "skipped", + "message": "project_already_exists", + "entity_type": "project", + "entity_id": str(existing.id), + "result": { + "project_id": int(existing.id), + "title": str(existing.title or existing.name or "").strip(), + "status": str(existing.status or "").strip(), + }, + } + + await ensure_project_id_sequence(self.db) + project = Project( + tenant_id=self.tenant_id, + name=title, + title=title, + description=description, + owner_id=operator_user_id, + status="active", + domain_id=None, + assigned_role="ai_engineer", + signal_count=0, + ) + self.db.add(project) + await self.db.flush() + await ensure_project_default_members( + self.db, + tenant_id=self.tenant_id, + project_id=int(project.id), + owner_id=int(project.owner_id) if project.owner_id is not None else None, + assigned_role=project.assigned_role, + ) + await log_operation( + self.db, + tenant_id=self.tenant_id, + user_id=operator_user_id, + project_id=int(project.id), + entity_type="project", + entity_id=str(project.id), + action="project.create", + before_data=None, + after_data={ + "title": project.title, + "status": project.status, + "owner_id": int(project.owner_id) if project.owner_id is not None else None, + }, + remark=trace_id[:1000], + ) + await self.db.commit() + await context_cache_service.invalidate_system_context(tenant_id=self.tenant_id) + return { + "status": "applied", + "message": f"project created #{int(project.id)}", + "entity_type": "project", + "entity_id": str(project.id), + "result": { + "project_id": int(project.id), + "title": str(project.title or project.name or "").strip(), + "status": str(project.status or "").strip(), + }, + } + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_command_commit_task.py b/backend/app/services/feishu_signal_pipeline/mixin_command_commit_task.py new file mode 100644 index 00000000..674d72d1 --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_command_commit_task.py @@ -0,0 +1,78 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * + + +class FeishuPipelineCommandCommitTaskMixin: + async def _commit_task_create( + self, + *, + operator_user: User, + payload: dict[str, Any], + trace_id: str, + ) -> dict[str, Any]: + operator_user_id = _safe_int(getattr(operator_user, "id", None)) + if operator_user_id is None: + raise ValueError("operator_not_found") + project_id = _safe_int(payload.get("project_id")) + if project_id is None: + raise ValueError("project_context_missing") + project = ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == int(project_id), + ) + ) + ) + ).scalar_one_or_none() + if project is None: + raise ValueError("project_context_missing") + + charter = ( + await self.db.execute( + select(TruthNode).where( + and_( + TruthNode.tenant_id == self.tenant_id, + TruthNode.project_id == int(project_id), + TruthNode.node_type == "charter", + TruthNode.status.in_(tuple(ALIGNED_STATUSES)), + ) + ).order_by(TruthNode.updated_at.desc(), TruthNode.id.desc()) + ) + ).scalars().first() + if charter is None: + raise ValueError("aligned charter required before task.create") + + task = await persist_task( + self.db, + tenant_id=self.tenant_id, + truth_node_id=int(charter.id), + project_id=int(project_id), + draft=build_task_create_draft( + title=str(payload.get("title") or ""), + description=str(payload.get("description") or "").strip() or None, + priority=payload.get("priority"), + ), + actor_user_id=operator_user_id, + action="task.create", + remark=trace_id[:1000], + commit=True, + refresh_workload=False, + ) + await context_cache_service.invalidate_system_context(tenant_id=self.tenant_id) + self._sync_task_rag(task=task) + return { + "status": "applied", + "message": f"task created #{int(task.id)}", + "entity_type": "task", + "entity_id": str(task.id), + "result": { + "task_id": int(task.id), + "title": str(task.title or "").strip(), + "project_id": int(project_id), + }, + } + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_command_exec_core.py b/backend/app/services/feishu_signal_pipeline/mixin_command_exec_core.py new file mode 100644 index 00000000..a06e08ec --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_command_exec_core.py @@ -0,0 +1,147 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * + + +class FeishuPipelineCommandExecCoreMixin: + async def _record_committed_operation_item( + self, + *, + message: str, + sender: str, + source_chat_id: str, + source_sender_open_id: Optional[str], + feishu_message_id: Optional[str], + captured_at: Optional[datetime], + intent: str, + operation_code: str, + execution_result: dict[str, Any], + matched_project_id: Optional[int], + ) -> Optional[int]: + occurred_at = captured_at or _utc_now() + if occurred_at.tzinfo is not None: + occurred_at = occurred_at.astimezone(CN_TZ).replace(tzinfo=None) + item = FeishuItem( + tenant_id=self.tenant_id, + feishu_message_id=feishu_message_id, + project_score=1.0 if matched_project_id is not None else None, + signal_confidence=0.0 if matched_project_id is not None else None, + raw_message=message[:4000], + split_from_item_id=None, + level="command", + level_confidence=1.0, + level_reasoning=f"intent_gate:{intent}", + card_type=operation_code.replace(".", "_")[:30], + route_status=ROUTE_STATUS_AUTO_DONE, + matched_project_id=int(matched_project_id) if matched_project_id is not None else None, + source_chat_id=(source_chat_id or "")[:128] or None, + source_sender=(sender or "")[:120] or None, + source_sender_open_id=(source_sender_open_id or "")[:128] or None, + source_occurred_at=occurred_at, + ai_output=json.dumps({"execution_result": execution_result}, ensure_ascii=False)[:8000], + decision_reason="deterministic_operation_committed", + blocking_reason=None, + safe_to_auto_execute=True, + auto_execute_state="done", + message_class="auto_done", + occurrence_count=1, + next_step_text="真实提交已完成,结果已绑定 DB commit。", + queue_visible=False, + processed_at=_utc_now().replace(tzinfo=None), + result_payload={ + "intent": intent, + "executor": "explicit_service", + "operation_code": operation_code, + "result_summary": str(execution_result.get("message") or "").strip() or operation_code, + "operation_result": execution_result.get("result") if isinstance(execution_result.get("result"), dict) else {}, + "project_id": int(matched_project_id) if matched_project_id is not None else None, + "target_url": f"/projects/{int(matched_project_id)}/board" if matched_project_id is not None else None, + }, + ) + self.db.add(item) + await self.db.flush() + return int(item.id) + + async def _execute_controlled_operation( + self, + *, + operator_user: User, + operation_code: str, + payload: dict[str, Any], + trace_id: str, + ) -> dict[str, Any]: + logger.info( + "operation_started", + tenant_id=self.tenant_id, + trace_id=trace_id, + operation_code=operation_code, + payload=payload, + executor="explicit_service", + ) + if operation_code == "project.create": + result = await self._commit_project_create( + operator_user=operator_user, + payload=payload, + trace_id=trace_id, + ) + elif operation_code == "task.create": + result = await self._commit_task_create( + operator_user=operator_user, + payload=payload, + trace_id=trace_id, + ) + else: + raise ValueError("operation_not_supported") + execution = self._wrap_committed_operation(operation_code=operation_code, payload=payload, result=result) + first = execution["results"][0] if execution.get("results") else {} + event_name = "operation_committed" if str(first.get("status") or "") in {"applied", "skipped"} else "operation_failed" + logger.info( + event_name, + tenant_id=self.tenant_id, + trace_id=trace_id, + operation_code=operation_code, + result=first, + executor="explicit_service", + ) + return execution + + @staticmethod + def _wrap_committed_operation(*, operation_code: str, payload: dict[str, Any], result: dict[str, Any]) -> dict[str, Any]: + status = str(result.get("status") or "").strip().lower() + return { + "dry_run": False, + "requested_count": 1, + "applied_count": 1 if status == "applied" else 0, + "skipped_count": 1 if status == "skipped" else 0, + "failed_count": 1 if status == "failed" else 0, + "results": [ + { + "index": 1, + "operation_code": operation_code, + "payload": dict(payload), + **result, + } + ], + } + + def _sync_task_rag(self, *, task: Any) -> None: + try: + content = ( + (str(getattr(task, "description", "") or "")) + + f"\n状态: {str(getattr(task, 'status', '') or '')}" + + f"\n负责人: {int(getattr(task, 'assignee_id', 0) or 0) if getattr(task, 'assignee_id', None) else '未分配'}" + ) + asyncio.create_task( + AdaptiveRAGService.upsert_pmflow_record_async( + tenant_id=self.tenant_id, + record_type="task", + record_id=int(task.id), + project_id=int(task.project_id), + title=str(task.title or "任务"), + content=content, + ) + ) + except Exception: + return + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_context_prefill_a.py b/backend/app/services/feishu_signal_pipeline/mixin_context_prefill_a.py new file mode 100644 index 00000000..f50331f1 --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_context_prefill_a.py @@ -0,0 +1,279 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * + + +class FeishuPipelineContextPrefillAMixin: + @staticmethod + def _looks_like_placeholder_project_text(text: str) -> bool: + normalized = re.sub(r"\s+", "", str(text or "")).lower() + if not normalized: + return True + if len(normalized) > 48: + return False + return any( + token in normalized + for token in ( + "创建个项目", + "创建一个项目", + "新建一个项目", + "新增一个项目", + "给我创建个项目", + "项目在项目列表", + "项目在pmflow", + "放进pmflow", + "放到pmflow", + "放进项目列表", + "放到项目列表", + "汇总以上内容", + "根据这个", + "根据上面", + ) + ) + + @staticmethod + def _looks_like_project_request_artifact(text: str) -> bool: + normalized = re.sub(r"\s+", "", str(text or "")).lower() + if not normalized: + return False + return any( + token in normalized + for token in ( + "给我创建个项目", + "给我创建一个项目", + "创建个项目", + "创建一个项目", + "新建一个项目", + "新增一个项目", + "项目在项目列表", + "项目列表@pmflow", + "进项目列表", + "放进项目列表", + "放到项目列表", + "放进pmflow", + "放到pmflow", + "根据这个汇总", + "根据上面汇总", + "汇总以上内容", + ) + ) + + @staticmethod + def _looks_like_unusable_project_context(text: str) -> bool: + normalized = re.sub(r"\s+", "", str(text or "")).lower() + if not normalized: + return True + return any( + token in normalized + for token in ( + "系统处理中出现异常", + "本次未执行", + "请稍后重试", + "requestwasaborted", + "connectionaborted", + "429", + "模型服务异常", + "无法生成可靠回答", + "无法直接给结论", + "没有输出,可能后端有问题", + "让我看看main.py", + ) + ) + + @staticmethod + def _guess_project_title_from_text(text: str) -> str: + cleaned = re.sub(r"\s+", " ", str(text or "")).strip() + if not cleaned: + return "" + normalized = re.sub(r"\s+", "", cleaned).lower() + if "十五五" in cleaned and any(token in cleaned for token in ("医药", "医疗", "器械", "中医药")): + return "十五五医药方向研究" + if "医药" in cleaned and any( + token in normalized + for token in ("方向", "值得关注", "创新药", "中医药", "老龄化", "供应链安全", "出海") + ): + return "医药方向研究" + if "医疗器械" in cleaned and "国产替代" in cleaned: + return "医疗器械国产替代研究" + return "" + + @staticmethod + def _extract_embedded_project_context(message: str) -> str: + cleaned = re.sub(r"\s+", " ", str(message or "")).strip() + if len(cleaned) < 80: + return "" + markers = [] + for pattern in ( + r"(?:根据这个|根据上面|基于这个|基于上面|按这个|按上面|汇总以上内容|汇总上面内容|汇总一下|这个汇总|这个总结).{0,40}(?:创建|新建|建立|建个|建一个).{0,8}项目", + r"(?:给我|帮我|请|麻烦)?(?:创建|新建|建立|建个|建一个|做个|做一个)(?:一个|个)?项目(?:在|进|到)?(?:pmflow|项目列表)", + ): + match = re.search(pattern, cleaned, re.IGNORECASE) + if match: + markers.append(match.start()) + if not markers: + return "" + context = cleaned[: min(markers)].strip(" ,,::") + if len(context) < 80: + return "" + return context[:4000] + + @staticmethod + def _derive_project_title_from_context(*, question: str, answer: str) -> str: + guessed = FeishuSignalPipelineService._guess_project_title_from_text(f"{question}\n{answer}".strip()) + if guessed: + return guessed + + def _normalize(raw: str) -> str: + candidate = re.sub(r"\s+", " ", str(raw or "")).strip(" ,,。.!!??::;;") + candidate = re.sub(r"^(?:请问|麻烦|帮我|我想知道|我想了解|想了解一下|目前|现在|关于|对于|针对)", "", candidate) + candidate = re.sub( + r"(?:还有哪些|有哪些|值得关注什么|值得关注哪些|值得关注|应该投什么|该投什么|该怎么做|怎么做|是什么|是啥|可以吗|吗|呢)$", + "", + candidate, + ) + candidate = re.sub(r"[,,].*$", "", candidate).strip(" ,,。.!!??::;;") + candidate = re.sub(r"(?:里面|里)$", "", candidate).strip(" ,,。.!!??::;;") + candidate = re.sub(r"[。;;!!??].*$", "", candidate).strip(" ,,。.!!??::;;") + normalized = normalize_project_title_candidate(candidate) + if normalized.endswith(("方向", "规划", "创新")): + normalized = normalize_project_title_candidate(f"{normalized}研究") + return normalized + + title = _normalize(question) + if title: + return title + + cleaned_answer = re.sub(r"\s+", " ", str(answer or "")).strip() + for pattern in ( + r"((?:十五五|医药|医疗|器械|公共卫生|老龄化)[^。;;!!??]{0,16}(?:方向|规划|创新|体系))", + r"((?:[A-Za-z0-9\u4e00-\u9fff]{2,18})(?:方向|规划|创新|中台|系统|平台|治理|流程|方案|体系))", + ): + match = re.search(pattern, cleaned_answer) + if not match: + continue + title = _normalize(match.group(1)) + if title: + return title + return "" + + async def _load_recent_chat_turns( + self, + *, + source_chat_id: str, + source_sender_open_id: Optional[str], + sender: str, + limit: int = 3, + ) -> list[dict[str, str]]: + if self.db is None or not source_chat_id: + return [] + conversation_key = self._build_rag_conversation_key( + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + sender=sender, + ) + external_session_id = AdaptiveRAGService._external_session_id_from_key(conversation_key) + if external_session_id is None: + return [] + rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) + messages = await rag_service.context_cache.get_session_messages( + tenant_id=self.tenant_id, + session_id=int(external_session_id), + ) + if not messages: + return [] + turns = rag_service.context_cache.messages_to_turns(messages) + return [row for row in turns if isinstance(row, dict)][-limit:] + + async def _load_recent_chat_reply_fallback_text(self, *, source_chat_id: str, limit: int = 5) -> str: + if self.db is None or not source_chat_id: + return "" + rows = ( + await self.db.execute( + select(FeishuReplyOutbox) + .where( + and_( + FeishuReplyOutbox.chat_id == (source_chat_id or "")[:128], + FeishuReplyOutbox.status == "sent", + ) + ) + .order_by(FeishuReplyOutbox.id.desc()) + .limit(limit) + ) + ).scalars().all() + for row in rows: + text = self._strip_feishu_mention_prefix(str(row.message_text or "")) + if not text: + continue + if self._looks_like_unusable_project_context(text): + continue + return text[:4000] + return "" + + async def _maybe_prefill_project_fields_from_embedded_content( + self, + *, + message: str, + fields: dict[str, Any], + ) -> dict[str, Any]: + if fields.get("ready"): + return fields + + embedded_context = self._extract_embedded_project_context(message) + if not embedded_context or self._looks_like_unusable_project_context(embedded_context): + return fields + + current_description = str(fields.get("description") or "").strip() + if ( + self._looks_like_placeholder_project_text(current_description) + or self._looks_like_project_request_artifact(current_description) + ): + current_description = "" + current_scope = str(fields.get("project_scope") or "").strip() + if ( + self._looks_like_placeholder_project_text(current_scope) + or self._looks_like_project_request_artifact(current_scope) + ): + current_scope = "" + + title = str(fields.get("title") or "").strip() or self._guess_project_title_from_text(embedded_context) + description = current_description or embedded_context[:4000] + project_scope = current_scope or self._extract_project_scope(embedded_context, description=description) + if title and description and project_scope: + return { + "title": title, + "description": description, + "project_scope": project_scope, + "missing_fields": [], + "ready": True, + } + + project_info = await self._extract_project_prefill( + f"当前消息里的项目材料:{embedded_context}\n\n用户动作:{message}\n\n请仅基于当前消息里的材料补全项目名称、项目描述、项目范围。" + ) + suggested_name = normalize_project_title_candidate( + str(project_info.get("suggested_name") or "").strip() + ) + if self._looks_like_placeholder_project_text(suggested_name): + suggested_name = "" + title = title or suggested_name + description = description or str(project_info.get("suggested_description") or "").strip()[:4000] + if self._looks_like_unusable_project_context(description): + description = "" + project_scope = project_scope or self._extract_project_scope(embedded_context, description=description) + + missing_fields: list[str] = [] + if not title: + missing_fields.append("title") + if not description: + missing_fields.append("description") + if not project_scope: + missing_fields.append("project_scope") + return { + "title": title, + "description": description, + "project_scope": project_scope, + "missing_fields": missing_fields, + "ready": not missing_fields, + } + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_context_prefill_b.py b/backend/app/services/feishu_signal_pipeline/mixin_context_prefill_b.py new file mode 100644 index 00000000..f1acd9cd --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_context_prefill_b.py @@ -0,0 +1,233 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * + + +class FeishuPipelineContextPrefillBMixin: + async def _maybe_prefill_project_fields_from_context( + self, + *, + message: str, + source_chat_id: str, + source_sender_open_id: Optional[str], + sender: str, + fields: dict[str, Any], + ) -> dict[str, Any]: + if fields.get("ready"): + return fields + if not source_chat_id or not self._looks_like_contextual_project_request(message): + return fields + + turns = await self._load_recent_chat_turns( + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + sender=sender, + limit=3, + ) + context_blocks: list[str] = [] + question_hint = "" + answer_hint = "" + if turns: + anchor = turns[-1] + recent_question = str(anchor.get("question") or "").strip() + recent_answer = str(anchor.get("answer") or "").strip() + if recent_question: + context_blocks.append(f"最近用户问题:{recent_question}") + question_hint = recent_question + if recent_answer and not self._looks_like_unusable_project_context(recent_answer): + context_blocks.append(f"最近系统回答:{recent_answer}") + answer_hint = recent_answer + + if not context_blocks: + reply_fallback = await self._load_recent_chat_reply_fallback_text(source_chat_id=source_chat_id) + if reply_fallback: + context_blocks.append(f"最近系统回答:{reply_fallback}") + answer_hint = reply_fallback + + if not context_blocks: + return fields + + base_description = str(fields.get("description") or "").strip() + if ( + self._looks_like_placeholder_project_text(base_description) + or self._looks_like_project_request_artifact(base_description) + ): + base_description = "" + if not answer_hint and not base_description: + return fields + + heuristic_title = self._derive_project_title_from_context(question=question_hint, answer=answer_hint) + heuristic_description = base_description or answer_hint[:4000] + heuristic_scope = str(fields.get("project_scope") or "").strip() + if ( + self._looks_like_placeholder_project_text(heuristic_scope) + or self._looks_like_project_request_artifact(heuristic_scope) + ): + heuristic_scope = "" + if not heuristic_scope and heuristic_description: + heuristic_scope = self._extract_project_scope( + f"{question_hint}\n\n{answer_hint}".strip(), + description=heuristic_description, + ) + if heuristic_title and heuristic_description and heuristic_scope: + return { + "title": str(fields.get("title") or "").strip() or heuristic_title, + "description": heuristic_description, + "project_scope": heuristic_scope, + "missing_fields": [], + "ready": True, + } + + prefill_source = ( + f"当前建项目请求:{message}\n\n" + + "\n\n".join(context_blocks) + + "\n\n请基于最近上下文补全项目名称、项目描述、项目范围。" + ) + project_info = await self._extract_project_prefill(prefill_source) + current_description = str(fields.get("description") or "").strip() + if ( + self._looks_like_placeholder_project_text(current_description) + or self._looks_like_project_request_artifact(current_description) + ): + current_description = "" + current_scope = str(fields.get("project_scope") or "").strip() + if ( + self._looks_like_placeholder_project_text(current_scope) + or self._looks_like_project_request_artifact(current_scope) + ): + current_scope = "" + title = ( + str(fields.get("title") or "").strip() + or heuristic_title + or normalize_project_title_candidate(str(project_info.get("suggested_name") or "")) + ) + description = current_description or str(project_info.get("suggested_description") or "").strip() + if not description and answer_hint: + description = answer_hint[:4000] + if self._looks_like_unusable_project_context(description): + description = "" + project_scope = current_scope + if not project_scope: + scope_source = f"{description}\n\n{answer_hint}".strip() + project_scope = self._extract_project_scope(scope_source, description=description) + + missing_fields: list[str] = [] + if not title: + missing_fields.append("title") + if not description: + missing_fields.append("description") + if not project_scope: + missing_fields.append("project_scope") + return { + "title": title, + "description": description, + "project_scope": project_scope, + "missing_fields": missing_fields, + "ready": not missing_fields, + } + + def _resolve_project_candidate_fields( + self, + *, + item: FeishuItem, + suggestion: Optional[ProjectSuggestion] = None, + ) -> dict[str, Any]: + payload = item.result_payload if isinstance(item.result_payload, dict) else {} + operation_payload = payload.get("operation_payload") if isinstance(payload.get("operation_payload"), dict) else {} + title = str( + (suggestion.suggested_name if suggestion is not None else None) + or operation_payload.get("title") + or "" + ).strip() + description = str( + (suggestion.suggested_description if suggestion is not None else None) + or operation_payload.get("description") + or "" + ).strip() + project_scope = str(payload.get("project_scope") or "").strip() + if not title or not description: + extracted = self._extract_project_intent_fields(item.raw_message or "") + if not title: + title = str(extracted.get("title") or "").strip() + if not description: + description = str(extracted.get("description") or "").strip() + if not project_scope: + project_scope = str(extracted.get("project_scope") or "").strip() + if not project_scope: + project_scope = self._extract_project_scope(item.raw_message or "", description=description) + + missing_fields: list[str] = [] + if not title: + missing_fields.append("title") + if not description: + missing_fields.append("description") + if not project_scope: + missing_fields.append("project_scope") + return { + "title": title, + "description": description, + "project_scope": project_scope, + "missing_fields": missing_fields, + "ready": not missing_fields, + } + + @staticmethod + def _build_project_clarification_message(missing_fields: list[str]) -> str: + labels = { + "title": "项目名称", + "description": "项目描述", + "project_scope": "项目范围", + } + ordered = [labels[field] for field in missing_fields if field in labels] + suffix = "、".join(ordered) if ordered else "项目名称、项目描述、项目范围" + return f"未执行:当前建项目请求还缺少 {suffix}。请直接补充这三项后我再创建。" + + @classmethod + def _build_project_preview_title( + cls, + *, + title: str, + description: str, + project_scope: str, + ) -> str: + normalized_title = normalize_project_title_candidate(title) + if normalized_title and not cls._looks_like_placeholder_project_text(normalized_title): + return normalized_title + + for candidate in (project_scope, description): + cleaned = str(candidate or "").strip() + if not cleaned or cls._looks_like_placeholder_project_text(cleaned): + continue + normalized = normalize_project_title_candidate(cleaned[:48]) + if normalized: + return normalized + return "项目创建请求" + + @classmethod + def _build_project_preview_summary( + cls, + *, + description: str, + project_scope: str, + blocking_reason: Optional[str], + fallback_text: str, + ) -> str: + normalized_description = str(description or "").strip() + if cls._looks_like_placeholder_project_text(normalized_description): + normalized_description = "" + normalized_scope = str(project_scope or "").strip() + if cls._looks_like_placeholder_project_text(normalized_scope): + normalized_scope = "" + + fragments: list[str] = [] + if normalized_description: + fragments.append(_compact_text(normalized_description, max_len=140)) + if normalized_scope and normalized_scope not in normalized_description: + fragments.append(f"范围:{_compact_text(normalized_scope, max_len=90)}") + if fragments: + return ";".join(fragments[:2]) + if str(blocking_reason or "").strip(): + return str(blocking_reason).strip() + return _compact_text(fallback_text, max_len=140) + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_core_base.py b/backend/app/services/feishu_signal_pipeline/mixin_core_base.py new file mode 100644 index 00000000..58c977aa --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_core_base.py @@ -0,0 +1,172 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * + + +class FeishuPipelineCoreBaseMixin: + def __init__(self, db: AsyncSession, *, tenant_id: int): + self.db = db + self.tenant_id = int(tenant_id) + self.llm = LLMService() + + @staticmethod + def _normalize_route_status(value: Any) -> str: + text = str(value or "").strip().lower() + if text in {ROUTE_STATUS_CLASSIFIED, ROUTE_STATUS_QUEUED, ROUTE_STATUS_AUTO_RUNNING, ROUTE_STATUS_AUTO_DONE, ROUTE_STATUS_FAILED, ROUTE_STATUS_DISMISSED, ROUTE_STATUS_UNDONE}: + return text + if text == "pending": + return ROUTE_STATUS_QUEUED + if text in {"auto_processed", "confirmed"}: + return ROUTE_STATUS_AUTO_DONE + if text == "dismissed": + return ROUTE_STATUS_DISMISSED + return text or ROUTE_STATUS_FAILED + + @classmethod + def _is_active_route_status(cls, value: Any) -> bool: + return cls._normalize_route_status(value) in { + ROUTE_STATUS_CLASSIFIED, + ROUTE_STATUS_QUEUED, + ROUTE_STATUS_AUTO_RUNNING, + } + + @staticmethod + def _is_healthcheck_message(message: str) -> bool: + return bool(HEALTHCHECK_RE.search(str(message or "").strip())) + + @staticmethod + def _is_short_confirmation_message(message: str) -> bool: + return bool(SHORT_CONFIRM_RE.search(str(message or "").strip())) + + @staticmethod + def _is_correction_message(message: str) -> bool: + return bool(CORRECTION_CUE_RE.search(str(message or "").strip())) + + @staticmethod + def _looks_like_continue_followup(message: str) -> bool: + text = str(message or "").strip() + if not text: + return False + if FOLLOWUP_CREATE_CONTINUE_RE.search(text): + return bool(FOLLOWUP_REFERENCE_RE.search(text)) + return bool(FOLLOWUP_CONTINUE_RE.search(text)) + + @staticmethod + def _looks_like_task_create_request(message: str) -> bool: + return bool(TASK_CREATE_RE.search(str(message or "").strip())) + + @staticmethod + def _looks_like_project_create_request(message: str) -> bool: + text = str(message or "").strip() + if not text: + return False + if PROJECT_CREATE_GATE_RE.search(text): + return True + return bool(LEGACY_COMMAND_PROJECT_CREATE_RE.search(text)) + + @staticmethod + def _looks_like_external_connect_request(message: str) -> bool: + text = str(message or "").strip() + if not text: + return False + return bool(CREATE_ACTION_RE.search(text) and EXTERNAL_INTEGRATION_RE.search(text)) + + @staticmethod + def _can_execute_high_value_action(user: Optional[User]) -> bool: + if user is None: + return False + return str(getattr(user, "role", "") or "").upper() in {"BOSS", "ADMIN", "PM"} + + @staticmethod + def _stable_positive_int(*parts: Any) -> int: + raw = "::".join(str(part or "").strip() for part in parts if str(part or "").strip()) + digest = hashlib.sha1(raw.encode("utf-8")).hexdigest() + return int(digest[:12], 16) + + @staticmethod + def _normalize_key_fragment(value: str, *, max_len: int = 80) -> str: + normalized = re.sub(r"[^0-9a-z\u4e00-\u9fff]+", "", str(value or "").strip().lower()) + return normalized[:max_len] + + @classmethod + def _build_intent_fragment(cls, message: str) -> str: + text = str(message or "").strip() + if not text: + return "generic" + if EXTERNAL_INTEGRATION_RE.search(text): + return "external_integration" + if CROSS_SYSTEM_RE.search(text): + return "cross_system" + if PROJECT_COMPLEXITY_RE.search(text): + return "project_setup" + if SIMPLE_SIGNAL_REQUEST_RE.search(text): + return "feature_change" + fragment = cls._normalize_key_fragment(text, max_len=64) + return fragment or "generic" + + def _build_queue_dedupe_key( + self, + *, + level: str, + intent: Optional[str], + message: str, + source_chat_id: str, + matched_project_id: Optional[int], + source_object: Optional[str] = None, + ) -> str: + project_key = str(int(matched_project_id)) if matched_project_id is not None else "global" + source_key = self._normalize_key_fragment(source_chat_id or "direct", max_len=48) or "direct" + intent_key = self._normalize_key_fragment(str(intent or "").strip().lower(), max_len=48) or self._build_intent_fragment(message) + object_key = self._normalize_key_fragment(source_object or message, max_len=64) or "generic" + return f"{level}:{project_key}:{source_key}:{intent_key}:{object_key}"[:255] + + async def _find_active_dedupe_item(self, dedupe_key: Optional[str]) -> Optional[FeishuItem]: + normalized_key = (dedupe_key or "").strip() + if not normalized_key: + return None + return ( + await self.db.execute( + select(FeishuItem) + .where( + and_( + FeishuItem.tenant_id == self.tenant_id, + FeishuItem.dedupe_key == normalized_key, + FeishuItem.route_status.in_(tuple(ACTIVE_ITEM_ROUTE_STATUSES)), + ) + ) + .order_by(FeishuItem.updated_at.desc(), FeishuItem.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + + async def _build_existing_item_result(self, item: FeishuItem) -> dict[str, Any]: + level = str(item.level or "").lower() + result: dict[str, Any] = { + "item_id": int(item.id), + "level": level, + "project_id": int(item.matched_project_id) if item.matched_project_id is not None else None, + "signal_id": None, + "route_status": self._normalize_route_status(item.route_status), + "clarification_message": item.blocking_reason or None, + } + if level == "project": + suggestion = await self._get_project_suggestion(item.id) + if suggestion and suggestion.created_project_id: + result["project_id"] = int(suggestion.created_project_id) + return result + + if level == "command": + payload = item.result_payload if isinstance(item.result_payload, dict) else {} + project_id = _safe_int(payload.get("project_id") or item.matched_project_id) + if project_id is not None: + result["project_id"] = int(project_id) + return result + + signal = await self._get_signal_by_item(item.id) + if signal: + result["signal_id"] = int(signal.id) + if signal.matched_project_id is not None: + result["project_id"] = int(signal.matched_project_id) + return result + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_extraction.py b/backend/app/services/feishu_signal_pipeline/mixin_extraction.py new file mode 100644 index 00000000..32e88e2a --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_extraction.py @@ -0,0 +1,265 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * + + +class FeishuPipelineExtractionMixin: + async def _extract_project_prefill(self, message: str) -> dict[str, Any]: + prompt = PROMPT_FEISHU_PROJECT_PREFILL.format(message=message[:1500]) + try: + raw = await self.llm.generate( + prompt, + db=self.db, + tenant_id=self.tenant_id, + function_name="feishu_project_prefill", + ) + parsed = _safe_json_object(raw) + if not parsed: + raise ValueError("project_prefill_empty_json") + parsed["reasoning"] = _normalize_reasoning(parsed.get("reasoning")) + return _validate_schema(_ProjectPrefillSchema, parsed, context="project_prefill") + except Exception as exc: + logger.warning(f"feishu_project_prefill_fallback: error={exc}") + fallback = self._heuristic_project_prefill_fallback(message) + return _validate_schema(_ProjectPrefillSchema, fallback, context="project_prefill_fallback") + + async def _extract_signal(self, message: str, *, projects: list[dict[str, Any]]) -> dict[str, Any]: + if self._is_simple_signal_source_request(message): + fallback = self._heuristic_signal_fallback(message=message, projects=projects) + return _validate_schema(_SignalExtractionSchema, fallback, context="signal_extraction_fastpath") + + prompt = PROMPT_FEISHU_SIGNAL_EXTRACTION.format( + message=message[:1500], + projects_json=json.dumps(projects, ensure_ascii=False), + ) + try: + raw = await self.llm.generate( + prompt, + db=self.db, + tenant_id=self.tenant_id, + function_name="feishu_signal_extraction", + ) + parsed = _safe_json_object(raw) + if not parsed: + raise ValueError("signal_extraction_empty_json") + parsed["reasoning"] = _normalize_reasoning(parsed.get("reasoning")) + return _validate_schema(_SignalExtractionSchema, parsed, context="signal_extraction") + except Exception as exc: + logger.warning(f"feishu_signal_extraction_fallback: error={exc}") + fallback = self._heuristic_signal_fallback(message=message, projects=projects) + return _validate_schema(_SignalExtractionSchema, fallback, context="signal_extraction_fallback") + + async def _classify_level(self, *, message: str, projects: list[dict[str, Any]]) -> dict[str, Any]: + if self._is_simple_signal_source_request(message): + fallback = self._heuristic_level_fallback(message) + return _validate_schema(_LevelClassificationSchema, fallback, context="level_classification_fastpath") + + prompt = PROMPT_FEISHU_LEVEL_CLASSIFICATION.format( + message=message[:1500], + projects_json=json.dumps(projects, ensure_ascii=False), + ) + try: + raw = await self.llm.generate( + prompt, + db=self.db, + tenant_id=self.tenant_id, + function_name="feishu_level_classification", + ) + parsed = _safe_json_object(raw) + if not parsed: + raise ValueError("level_classification_empty_json") + + # 兼容不同模型输出别名,避免只返回总分/中文 level 时被误降级到 signal。 + bi_raw = _first_non_empty(parsed, ("build_intent_score", "buildIntentScore", "intent_score", "build_intent")) + sc_raw = _first_non_empty(parsed, ("scope_score", "scopeScore", "scope")) + tl_raw = _first_non_empty(parsed, ("timeline_score", "timelineScore", "timeline")) + ow_raw = _first_non_empty(parsed, ("owner_score", "ownerScore", "owner")) + provided_project_score_raw = _first_non_empty(parsed, ("project_score", "projectScore", "score")) + + bi = _safe_float(bi_raw, 0.0) + sc = _safe_float(sc_raw, 0.0) + tl = _safe_float(tl_raw, 0.0) + ow = _safe_float(ow_raw, 0.0) + weighted = round((bi * 0.4) + (sc * 0.25) + (tl * 0.2) + (ow * 0.15), 4) + + component_count = sum(value is not None for value in (bi_raw, sc_raw, tl_raw, ow_raw)) + has_project_score = provided_project_score_raw is not None + provided_project_score = _safe_float(provided_project_score_raw, 0.0) + + final_project_score = weighted + if component_count < 4 and has_project_score: + final_project_score = provided_project_score + elif component_count > 0 and has_project_score: + # 当模型给出了总分与维度分,优先采用更保守的较高值,降低误判为 signal 的风险。 + final_project_score = max(weighted, provided_project_score) + final_project_score = max(0.0, min(1.0, final_project_score)) + + default_level = "project" if final_project_score >= 0.6 else "signal" + normalized_split_items: list[dict[str, str]] = [] + raw_split_items = parsed.get("split_items") + if isinstance(raw_split_items, list): + for item in raw_split_items[:2]: + if not isinstance(item, dict): + continue + item_level = _normalize_level_value(item.get("level"), default=default_level) + item_content = str(item.get("content") or "").strip() + if not item_content: + continue + normalized_split_items.append({"level": item_level, "content": item_content}) + + level_reasoning = str(parsed.get("level_reasoning") or "").strip() or "模型返回缺少理由,已按结构化兜底。" + payload: dict[str, Any] = { + "build_intent_score": max(0.0, min(1.0, bi)), + "scope_score": max(0.0, min(1.0, sc)), + "timeline_score": max(0.0, min(1.0, tl)), + "owner_score": max(0.0, min(1.0, ow)), + "project_score": final_project_score, + "level": _normalize_level_value(parsed.get("level"), default=default_level), + "level_confidence": max( + 0.0, + min(1.0, _safe_float(parsed.get("level_confidence"), max(final_project_score, 1 - final_project_score))), + ), + "level_reasoning": level_reasoning[:600], + "should_split": _safe_bool(parsed.get("should_split"), False), + "split_items": normalized_split_items, + } + if self._is_simple_signal_source_request(message): + payload["project_score"] = min(_safe_float(payload.get("project_score"), final_project_score), 0.35) + payload["level"] = "signal" + payload["should_split"] = False + payload["split_items"] = [] + payload["level_reasoning"] = ( + f"{payload['level_reasoning']};命中简单需求模式,按信号处理避免误升级为项目。" + )[:600] + return _validate_schema(_LevelClassificationSchema, payload, context="level_classification") + except Exception as exc: + logger.warning(f"feishu_level_classification_fallback: error={exc}") + fallback = self._heuristic_level_fallback(message) + return _validate_schema(_LevelClassificationSchema, fallback, context="level_classification_fallback") + + def _heuristic_level_fallback(self, message: str) -> dict[str, Any]: + text = message or "" + build_intent_score = 1.0 if PROJECT_INTENT_RE.search(text) else 0.2 + scope_score = 0.75 if ("系统" in text or "平台" in text or "功能" in text) else 0.35 + timeline_score = 0.8 if TIMELINE_RE.search(text) else 0.1 + owner_score = 0.7 if OWNER_RE.search(text) else 0.15 + project_score = round( + (build_intent_score * 0.4) + + (scope_score * 0.25) + + (timeline_score * 0.2) + + (owner_score * 0.15), + 4, + ) + has_project_intent = bool(PROJECT_INTENT_RE.search(text)) + has_signal_fact = bool(SIGNAL_FACT_RE.search(text)) + should_split = bool(has_project_intent and has_signal_fact) + simple_signal_request = self._is_simple_signal_source_request(text) + if simple_signal_request: + project_score = min(project_score, 0.35) + should_split = False + level = "signal" + level_reasoning = "命中简单需求规则,按信号处理,避免误升级项目。" + else: + level = "project" if project_score >= 0.6 else "signal" + level_reasoning = "基于关键词与上下文规则的兜底判定。" + return { + "build_intent_score": build_intent_score, + "scope_score": scope_score, + "timeline_score": timeline_score, + "owner_score": owner_score, + "project_score": project_score, + "level": level, + "level_confidence": max(project_score, 1 - project_score), + "level_reasoning": level_reasoning, + "should_split": should_split, + "split_items": ( + [ + {"level": "project", "content": text}, + {"level": "signal", "content": text}, + ] + if should_split + else [] + ), + } + + @staticmethod + def _guess_matched_project_id_from_text(message: str, projects: list[dict[str, Any]]) -> Optional[int]: + normalized_message = str(message or "").strip().lower() + if not normalized_message: + return None + for row in projects: + if not isinstance(row, dict): + continue + pid_raw = row.get("id") + title = str(row.get("name") or row.get("title") or "").strip() + if not title or len(title) < 2: + continue + try: + pid = int(pid_raw) if pid_raw is not None else None + except Exception: + pid = None + if pid is None: + continue + if title.lower() in normalized_message: + return pid + return None + + def _heuristic_signal_fallback(self, *, message: str, projects: list[dict[str, Any]]) -> dict[str, Any]: + text = str(message or "").strip() + normalized = text.lower() + cleaned = re.sub(r"\s+", " ", text).strip() + summary = cleaned[:280] if cleaned else "飞书消息(空内容)" + + signal_type = "新需求" + priority = "P2" + recommended_action = "enter_only" + if any(k in normalized for k in ("故障", "告警", "宕机", "报错", "bug", "风险")): + signal_type = "项目风险" + priority = "P1" + recommended_action = "trigger_alert" + elif any(k in normalized for k in ("反馈", "吐槽", "体验", "投诉")): + signal_type = "用户反馈" + priority = "P2" + elif any(k in normalized for k in ("竞品", "对标", "发布", "上线")): + signal_type = "竞品动态" + priority = "P2" + + title_seed = cleaned or "飞书需求信号" + title = title_seed[:48] + if not title: + title = "飞书需求信号" + confidence = 0.78 if signal_type in {"项目风险", "用户反馈"} else 0.72 + + return { + "title": title, + "type": signal_type, + "priority": priority, + "matched_project_id": self._guess_matched_project_id_from_text(text, projects), + "summary": summary, + "reasoning": ["LLM 返回异常,已按关键词规则生成兜底信号结构。"], + "confidence": confidence, + "recommended_action": recommended_action, + } + + @staticmethod + def _heuristic_project_prefill_fallback(message: str) -> dict[str, Any]: + text = str(message or "").strip() + cleaned = re.sub(r"\s+", " ", text).strip() + suggested_name = cleaned[:30] if cleaned else "飞书新需求项目" + if len(suggested_name) < 4: + suggested_name = "飞书新需求项目" + suggested_description = ( + f"由飞书需求自动生成的项目草案:{cleaned[:200]}" + if cleaned + else "由飞书需求自动生成的项目草案,待补充目标、范围与里程碑。" + ) + return { + "suggested_name": suggested_name, + "suggested_description": suggested_description, + "suggested_owner": "待指定", + "suggested_timeline": "待评估", + "confidence": 0.62, + "reasoning": ["LLM 返回异常,已按规则生成项目预填草案。"], + } + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_followup_a.py b/backend/app/services/feishu_signal_pipeline/mixin_followup_a.py new file mode 100644 index 00000000..3271201d --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_followup_a.py @@ -0,0 +1,256 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * + + +class FeishuPipelineFollowupAMixin: + async def _maybe_handle_queue_followup( + self, + *, + message: str, + sender: str, + source_chat_id: str, + feishu_message_id: Optional[str], + process_started_at: float, + ) -> Optional[dict[str, Any]]: + if not source_chat_id: + return None + wants_continue = self._looks_like_continue_followup(message) + wants_status = bool(FOLLOWUP_STATUS_RE.search(message)) + if not wants_continue and not wants_status: + return None + + recent_items = await self._load_recent_chat_items(source_chat_id=source_chat_id) + if not recent_items: + reply_text = "当前群里还没有可追踪的飞书处理项。" + decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED + reason = "群上下文中不存在可追踪的最近处理项。" + classification = "question" + status = "answered" + mutations: list[dict[str, Any]] = [] + results: list[dict[str, Any]] = [] + elif wants_continue: + pending_items = [item for item in recent_items if self._normalize_route_status(item.route_status) == ROUTE_STATUS_QUEUED] + if len(pending_items) != 1: + reply_text = self._build_followup_pending_clarification(pending_items or recent_items[:3]) + decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED + reason = "群上下文中存在多个待处理项,无法自动确定继续处理目标。" + classification = "command" + status = "captured" + mutations = [] + results = [] + else: + operator_user = await self._resolve_command_operator_user(sender=sender) + if operator_user is None: + reply_text = "未执行:当前租户下没有可用操作者,无法继续处理该项。" + decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED + reason = "继续处理被阻断:当前租户下没有可用操作者。" + classification = "command" + status = "captured" + mutations = [] + results = [] + else: + target_item = pending_items[0] + if (target_item.level or "").lower() == "project": + suggestion = await self._get_project_suggestion(int(target_item.id)) + if suggestion is None: + reply_text = "未执行:最近待处理项目候选缺少建议记录,无法继续处理。" + decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED + reason = "继续处理被阻断:最近待处理项目候选缺少建议记录。" + classification = "command" + status = "captured" + mutations = [] + results = [] + else: + candidate_fields = self._resolve_project_candidate_fields(item=target_item, suggestion=suggestion) + if not candidate_fields["ready"]: + reply_text = ( + str(target_item.blocking_reason or "").strip() + or self._build_project_clarification_message(candidate_fields["missing_fields"]) + ) + decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED + reason = "继续处理被阻断:最近待处理建项目项仍缺少必填字段。" + classification = "command" + status = "captured" + mutations = [] + results = [] + else: + confirm_result = await self.confirm_item(item_id=int(target_item.id), user_id=int(operator_user.id)) + reply_text = self._build_followup_confirmation_reply(target_item=target_item, confirm_result=confirm_result) + decision = ROUTING_QUEUE_FOLLOWUP_CONFIRM + reason = "命中继续处理指令,已按最近唯一待处理项继续执行。" + classification = "command" + status = "captured" + mutations = self._summarize_db_mutations( + { + "results": [ + { + "item_id": int(target_item.id), + "level": target_item.level, + "route_status": ROUTE_STATUS_AUTO_DONE, + "project_id": confirm_result.get("project_id"), + "signal_id": confirm_result.get("signal_id"), + } + ] + } + ) + results = [ + { + "item_id": int(target_item.id), + "level": target_item.level, + "route_status": ROUTE_STATUS_AUTO_DONE, + "project_id": _safe_int(confirm_result.get("project_id")), + "signal_id": _safe_int(confirm_result.get("signal_id")), + } + ] + else: + signal = await self._get_signal_by_item(int(target_item.id)) + if (target_item.level or "").lower() == "signal" and not (signal and (signal.matched_project_id or target_item.matched_project_id)): + reply_text = "未执行:最近待处理信号仍缺少明确项目归属,请先补充项目名或项目 ID。" + decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED + reason = "继续处理被阻断:最近待处理信号缺少项目归属。" + classification = "command" + status = "captured" + mutations = [] + results = [] + else: + confirm_result = await self.confirm_item(item_id=int(target_item.id), user_id=int(operator_user.id)) + reply_text = self._build_followup_confirmation_reply(target_item=target_item, confirm_result=confirm_result) + decision = ROUTING_QUEUE_FOLLOWUP_CONFIRM + reason = "命中继续处理指令,已按最近唯一待处理项继续执行。" + classification = "command" + status = "captured" + mutations = self._summarize_db_mutations( + { + "results": [ + { + "item_id": int(target_item.id), + "level": target_item.level, + "route_status": ROUTE_STATUS_AUTO_DONE, + "project_id": confirm_result.get("project_id"), + "signal_id": confirm_result.get("signal_id"), + } + ] + } + ) + results = [ + { + "item_id": int(target_item.id), + "level": target_item.level, + "route_status": ROUTE_STATUS_AUTO_DONE, + "project_id": _safe_int(confirm_result.get("project_id")), + "signal_id": _safe_int(confirm_result.get("signal_id")), + } + ] + else: + target_item = self._select_followup_status_target(message=message, recent_items=recent_items) + reply_text = await self._build_followup_status_reply(target_item) + decision = ROUTING_QUEUE_FOLLOWUP_QUERY + reason = "命中进度/状态追问,已直接基于真实 DB 返回最近处理项状态。" + classification = "question" + status = "answered" + mutations = [] + results = [] + + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=None, + raw_message=message, + ai_raw_output=json.dumps( + { + "followup_reply": reply_text, + "recent_item_ids": [int(item.id) for item in recent_items[:3]], + "wants_continue": wants_continue, + "wants_status": wants_status, + }, + ensure_ascii=False, + )[:4000], + project_score=None, + signal_confidence=None, + level="followup", + routing_decision=decision, + routing_reason=reason, + reasoning_bullets=[ + "follow-up 查询/指令命中最近飞书上下文。", + "回复内容只基于真实 DB 状态,不使用自由生成。", + f"recent_items={len(recent_items)}", + ], + processing_status="success", + error_message=None, + duration_ms=int((time.perf_counter() - process_started_at) * 1000), + ) + + latest = recent_items[0] if recent_items else None + linked_project_ids: list[int] = [] + if results and results[0].get("project_id") is not None: + linked_project_ids.append(int(results[0]["project_id"])) + if latest and latest.matched_project_id is not None: + linked_project_ids.append(int(latest.matched_project_id)) + linked_project_ids = sorted(set(linked_project_ids)) + return { + "status": status, + "classification": classification, + "project_scope": classification, + "linked_project_ids": linked_project_ids, + "signal_id": str(results[0].get("signal_id")) if results else None, + "created_project_id": str(results[0].get("project_id")) if results else None, + "auto_tasks": [], + "ai_trace_id": f"feishu-followup-{(feishu_message_id or 'noid')[:32]}", + "results": results, + "direct_reply_required": True, + "direct_reply_text": reply_text, + "db_mutations": mutations, + } + + async def _load_recent_chat_items(self, *, source_chat_id: str, limit: int = 5) -> list[FeishuItem]: + rows = ( + await self.db.execute( + select(FeishuItem) + .where( + and_( + FeishuItem.tenant_id == self.tenant_id, + FeishuItem.source_chat_id == (source_chat_id or "")[:128], + ) + ) + .order_by(FeishuItem.id.desc()) + .limit(limit) + ) + ).scalars().all() + return list(rows) + + @staticmethod + def _extract_item_operation_code(item: FeishuItem) -> str: + payload = item.result_payload if isinstance(item.result_payload, dict) else {} + return str(payload.get("operation_code") or "").strip().lower() + + @staticmethod + def _build_followup_pending_clarification(items: list[FeishuItem]) -> str: + if not items: + return "当前群没有待处理项可继续。" + fragments = [] + for item in items[:3]: + level = str(item.level or "").lower() + label = "项目候选" if level == "project" else ("命令" if level == "command" else "信号") + fragments.append(f"{label}#{int(item.id)}") + return f"当前群最近有多个待处理项:{'、'.join(fragments)}。请先明确要继续哪一条。" + + def _build_followup_confirmation_reply(self, *, target_item: FeishuItem, confirm_result: dict[str, Any]) -> str: + level = str(target_item.level or "").lower() + if level == "project": + project_id = confirm_result.get("project_id") + return f"已继续处理:项目候选#{int(target_item.id)} 已确认并创建项目#{project_id}。" + if level == "command": + project_id = confirm_result.get("project_id") + task_id = confirm_result.get("task_id") + if task_id and project_id: + return f"已继续处理:任务#{task_id} 已在项目#{project_id} 中创建。" + if project_id: + return f"已继续处理:命令队列项#{int(target_item.id)} 已落到项目#{project_id}。" + return f"已继续处理:命令队列项#{int(target_item.id)} 已确认。" + signal_id = confirm_result.get("signal_id") + project_id = confirm_result.get("project_id") + if signal_id and project_id: + return f"已继续处理:信号#{signal_id} 已确认并关联项目#{project_id}。" + return f"已继续处理:队列项#{int(target_item.id)} 已确认。" + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_followup_b.py b/backend/app/services/feishu_signal_pipeline/mixin_followup_b.py new file mode 100644 index 00000000..3b7ae1f2 --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_followup_b.py @@ -0,0 +1,299 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * + + +class FeishuPipelineFollowupBMixin: + @classmethod + def _select_followup_status_target(cls, *, message: str, recent_items: list[FeishuItem]) -> FeishuItem: + if not recent_items: + raise ValueError("recent_items_empty") + normalized = re.sub(r"\s+", "", str(message or "")).lower() + wants_project_create = any(token in normalized for token in ("创建", "建好", "建成", "项目")) + wants_source_status = any(token in normalized for token in ("信号源", "web搜索", "webhook", "source")) + + if wants_source_status: + for item in recent_items: + operation_code = cls._extract_item_operation_code(item) + if operation_code == "source.connect" or "信号源" in str(item.raw_message or ""): + return item + + if wants_project_create: + for item in recent_items: + operation_code = cls._extract_item_operation_code(item) + level = str(item.level or "").strip().lower() + card_type = str(item.card_type or "").strip().lower() + if ( + level == "project" + or operation_code == "project.create" + or card_type in {"project_create", "new_project", "new_project_candidate"} + ): + return item + + return recent_items[0] + + async def _build_followup_status_reply(self, item: FeishuItem) -> str: + route_status = self._normalize_route_status(item.route_status) + level = str(item.level or "").strip().lower() + if level == "project": + suggestion = await self._get_project_suggestion(int(item.id)) + project_id = suggestion.created_project_id if suggestion and suggestion.created_project_id else item.matched_project_id + if route_status == ROUTE_STATUS_AUTO_DONE and project_id: + return f"最近一条项目候选已确认,项目#{int(project_id)} 已落库。" + if route_status == ROUTE_STATUS_UNDONE: + return "最近一条项目候选已撤销,系统没有保留该次自动结果。" + return "最近一条项目候选仍在待确认,系统还没有落正式项目。" + + if level == "command": + payload = item.result_payload if isinstance(item.result_payload, dict) else {} + operation_code = str(payload.get("operation_code") or "").strip() + project_id = _safe_int(payload.get("project_id") or item.matched_project_id) + summary = str(payload.get("result_summary") or "").strip() + if route_status == ROUTE_STATUS_AUTO_DONE: + return summary or ( + f"最近一条命令已执行完成,归属项目#{int(project_id)}。" + if project_id is not None + else "最近一条命令已执行完成。" + ) + if route_status == ROUTE_STATUS_UNDONE: + return "最近一条命令结果已撤销。" + if route_status == ROUTE_STATUS_QUEUED: + return ( + f"最近一条命令 {operation_code or '待处理动作'} 已入队待确认。" + if operation_code + else "最近一条命令已入队待确认。" + ) + return "最近一条命令仍在处理中。" + + signal = await self._get_signal_by_item(int(item.id)) + signal_id = int(signal.id) if signal is not None else None + project_id = int(signal.matched_project_id or item.matched_project_id) if (signal and (signal.matched_project_id or item.matched_project_id)) else None + if route_status == ROUTE_STATUS_AUTO_DONE and signal_id and project_id: + return f"最近一条信号#{signal_id} 已确认并关联项目#{project_id}。" + if route_status == ROUTE_STATUS_QUEUED and signal_id and project_id: + return f"最近一条信号#{signal_id} 已入队待确认,候选项目#{project_id}。" + if route_status == ROUTE_STATUS_QUEUED and signal_id: + return f"最近一条信号#{signal_id} 已入队待确认,但项目归属仍不明确。" + if route_status == ROUTE_STATUS_UNDONE: + return "最近一条信号结果已撤销。" + return "最近一条飞书处理项仍在处理中,尚未得到可确认的 DB 状态。" + + async def _resolve_command_operator_user(self, *, sender: str) -> Optional[User]: + sender_id = await self._resolve_user_id_by_name(sender) + if sender_id is not None: + sender_user = await self._load_active_user_by_id(user_id=int(sender_id)) + if sender_user is not None: + return sender_user + + manager = ( + await self.db.execute( + select(User).where( + and_( + User.tenant_id == self.tenant_id, + User.is_active.is_(True), + User.status == "active", + User.role.in_(["BOSS", "ADMIN", "PM"]), + ) + ).order_by(User.id.asc()).limit(1) + ) + ).scalar_one_or_none() + if manager is not None: + return manager + return await self._load_active_user_by_id(user_id=await self._fallback_user_id()) + + async def _load_active_user_by_id(self, user_id: Optional[int]) -> Optional[User]: + if user_id is None: + return None + return ( + await self.db.execute( + select(User).where( + and_( + User.tenant_id == self.tenant_id, + User.id == int(user_id), + User.is_active.is_(True), + User.status == "active", + ) + ).limit(1) + ) + ).scalar_one_or_none() + + async def _record_command_context_item( + self, + *, + message: str, + sender: str, + source_chat_id: str, + source_sender_open_id: Optional[str], + feishu_message_id: Optional[str], + captured_at: Optional[datetime], + matched_project_id: Optional[int], + route_status: str, + command_result: dict[str, Any], + ) -> Optional[int]: + if matched_project_id is None and not source_chat_id: + return None + occurred_at = captured_at or _utc_now() + if occurred_at.tzinfo is not None: + occurred_at = occurred_at.astimezone(CN_TZ).replace(tzinfo=None) + item = FeishuItem( + tenant_id=self.tenant_id, + feishu_message_id=feishu_message_id, + project_score=1.0 if matched_project_id is not None else None, + signal_confidence=0.0 if matched_project_id is not None else None, + raw_message=message[:4000], + split_from_item_id=None, + level="command", + level_confidence=1.0, + level_reasoning="legacy_command_first_execution", + card_type="command", + route_status=self._normalize_route_status(route_status or ROUTE_STATUS_AUTO_DONE)[:30], + matched_project_id=int(matched_project_id) if matched_project_id is not None else None, + source_chat_id=(source_chat_id or "")[:128] or None, + source_sender=(sender or "")[:120] or None, + source_sender_open_id=(source_sender_open_id or "")[:128] or None, + source_occurred_at=occurred_at, + ai_output=json.dumps({"command_result": command_result}, ensure_ascii=False)[:8000], + message_class="auto_done", + safe_to_auto_execute=True, + auto_execute_state="done", + result_payload={ + "intent": "command", + "executor": "legacy_command_planner", + "operation_code": None, + "result_summary": self._build_operation_commit_reply(command_result), + "command_result": command_result, + "project_id": int(matched_project_id) if matched_project_id is not None else None, + "target_url": f"/projects/{int(matched_project_id)}/board" if matched_project_id is not None else None, + }, + queue_visible=False, + processed_at=_utc_now().replace(tzinfo=None), + ) + self.db.add(item) + await self.db.flush() + return int(item.id) + + @staticmethod + def _resolve_command_route_status(command_result: dict[str, Any]) -> str: + status = str( + command_result.get("command_status") + or command_result.get("run_status") + or command_result.get("execution_status") + or "" + ).strip().lower() + if status in {"completed", "partial", "planned"}: + return ROUTE_STATUS_AUTO_DONE + return ROUTE_STATUS_FAILED + + @staticmethod + def _collect_command_project_ids(command_result: dict[str, Any]) -> list[int]: + project_ids: set[int] = set() + project_info = command_result.get("project") if isinstance(command_result, dict) else None + if isinstance(project_info, dict): + project_id = project_info.get("id") + try: + if project_id is not None and int(project_id) > 0: + project_ids.add(int(project_id)) + except (TypeError, ValueError): + pass + execution = command_result.get("execution") if isinstance(command_result, dict) else None + results = execution.get("results") if isinstance(execution, dict) else None + if isinstance(results, list): + for row in results: + if not isinstance(row, dict): + continue + if str(row.get("entity_type") or "").strip() != "project": + continue + payload = row.get("result") if isinstance(row.get("result"), dict) else {} + candidate = payload.get("project_id") or row.get("entity_id") + try: + if candidate is not None and int(candidate) > 0: + project_ids.add(int(candidate)) + except (TypeError, ValueError): + continue + return sorted(project_ids) + + @staticmethod + def _extract_created_project_id(command_result: dict[str, Any]) -> Optional[int]: + execution = command_result.get("execution") if isinstance(command_result, dict) else None + results = execution.get("results") if isinstance(execution, dict) else None + if not isinstance(results, list): + return None + for row in results: + if not isinstance(row, dict): + continue + if str(row.get("operation_code") or "").strip() != "project.create": + continue + payload = row.get("result") if isinstance(row.get("result"), dict) else {} + candidate = payload.get("project_id") or row.get("entity_id") + try: + if candidate is not None and int(candidate) > 0: + return int(candidate) + except (TypeError, ValueError): + continue + return None + + @classmethod + def _build_operation_commit_reply(cls, command_result: dict[str, Any]) -> str: + execution = command_result.get("execution") if isinstance(command_result, dict) else {} + results = execution.get("results") if isinstance(execution, dict) else [] + if not isinstance(results, list) or not results: + return "未执行:未识别到可安全落库的业务操作。" + + fragments = [cls._format_operation_result_fragment(row) for row in results[:3] if isinstance(row, dict)] + fragments = [item for item in fragments if item] + applied_count = int((execution or {}).get("applied_count") or 0) + skipped_count = int((execution or {}).get("skipped_count") or 0) + failed_count = int((execution or {}).get("failed_count") or 0) + + if applied_count <= 0 and skipped_count <= 0 and failed_count > 0: + first_failed = next((row for row in results if isinstance(row, dict) and str(row.get("status") or "") == "failed"), {}) + message = str(first_failed.get("message") or "执行失败").strip() + return f"未执行:{message}" + if not fragments: + return "已接收指令,但未生成可展示的执行回执。" + + prefix = "未重复执行:" if applied_count <= 0 and skipped_count > 0 else "已执行完成:" + reply = f"{prefix}{';'.join(fragments[:2])}" + if failed_count > 0 and applied_count > 0: + reply = f"{reply};另有 {failed_count} 项失败" + return reply + "。" + + @staticmethod + def _format_operation_result_fragment(row: dict[str, Any]) -> str: + operation_code = str(row.get("operation_code") or "").strip() + status = str(row.get("status") or "").strip().lower() + message = str(row.get("message") or "").strip() + payload = row.get("result") if isinstance(row.get("result"), dict) else {} + if status == "failed": + return "" + + if operation_code.startswith("project."): + project_id = payload.get("project_id") or row.get("entity_id") + title = str(payload.get("title") or payload.get("project_title") or "").strip() + status_text = str(payload.get("status") or "").strip() + label = f"项目#{project_id}" + if title: + label = f"{label}「{title}」" + if operation_code == "project.create" and status == "applied": + return f"{label}已创建,状态 {status_text or 'active'}" + if operation_code == "project.create" and status == "skipped": + return f"{label}已存在,状态 {status_text or 'active'}" + if status_text: + return f"{label}状态已更新为 {status_text}" + return f"{label}{message or '已处理'}" + + if operation_code == "task.create": + return f"任务#{row.get('entity_id') or ''}已创建".strip() + if operation_code == "task.assign": + return f"任务#{row.get('entity_id') or ''}已分配".strip() + if operation_code == "task.prioritize": + return f"任务#{row.get('entity_id') or ''}优先级已更新".strip() + if operation_code == "task.status_transition": + return f"任务#{row.get('entity_id') or ''}状态已更新".strip() + if operation_code == "personnel.workload_update": + return f"成员#{row.get('entity_id') or ''}工时已更新".strip() + if operation_code == "notification.create": + return f"通知#{row.get('entity_id') or ''}已创建".strip() + return message or operation_code + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_gate_dispatch.py b/backend/app/services/feishu_signal_pipeline/mixin_gate_dispatch.py new file mode 100644 index 00000000..8bd01f23 --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_gate_dispatch.py @@ -0,0 +1,270 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * + + +class FeishuPipelineGateDispatchMixin: + async def _handle_external_connect_gate( + self, + *, + message: str, + sender: str, + source_chat_id: str, + source_sender_open_id: Optional[str], + feishu_message_id: Optional[str], + captured_at: Optional[datetime], + intent_gate: dict[str, Any], + process_started_at: float, + ) -> dict[str, Any]: + occurred_at = captured_at or _utc_now() + if occurred_at.tzinfo is None: + occurred_at = occurred_at.replace(tzinfo=CN_TZ) + fields = intent_gate.get("fields") if isinstance(intent_gate.get("fields"), dict) else {} + blocking_reason = str(intent_gate.get("clarification_template") or "").strip() or "外部系统接入默认进入待决策队列。" + queued = await self._queue_command_intent_item( + message=message, + sender=sender, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + feishu_message_id=feishu_message_id, + occurred_at=occurred_at, + intent_gate=intent_gate, + operation_code="source.connect", + operation_payload={"project_id": _safe_int(fields.get("project_id"))}, + blocking_reason=blocking_reason, + decision_reason="external_connect_requires_manual_decision", + matched_project_id=_safe_int(fields.get("project_id")), + ) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=_safe_int(queued.get("item_id")), + raw_message=message, + ai_raw_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False), + project_score=1.0 if fields.get("project_id") is not None else None, + signal_confidence=0.0 if fields.get("project_id") is not None else None, + level="command", + routing_decision=ROUTING_INTENT_GATE_EXTERNAL_CONNECT, + routing_reason="外部系统接入被收口到待决策队列,不允许自由执行。", + reasoning_bullets=[ + "intent gate 命中 external_connect。", + "当前仅允许受控工具链执行。", + "系统已转待决策,不做任意 HTTP/shell。", + ], + processing_status="success", + error_message=None, + duration_ms=int((time.perf_counter() - process_started_at) * 1000), + normalized_text=message, + route=ROUTING_INTENT_GATE_EXTERNAL_CONNECT, + intent=INTENT_EXTERNAL_CONNECT, + executor="router", + operation_code="source.connect", + reply_source="deterministic_reply", + ) + return { + "status": "captured", + "classification": "command", + "project_scope": "command", + "linked_project_ids": [int(fields["project_id"])] if fields.get("project_id") is not None else [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": f"feishu-external-{queued.get('item_id')}", + "results": [queued], + "intent": INTENT_EXTERNAL_CONNECT, + "clarification_message": queued.get("clarification_message"), + "direct_reply_required": True, + "direct_reply_text": str(queued.get("clarification_message") or blocking_reason), + } + + async def _maybe_handle_intent_gate_capture( + self, + *, + intent_gate: dict[str, Any], + message: str, + sender: str, + source_chat_id: str, + source_sender_open_id: Optional[str], + feishu_message_id: Optional[str], + captured_at: Optional[datetime], + inferred_project_id: Optional[int], + projects: list[dict[str, Any]], + process_started_at: float, + normalized_chat_type: str, + ) -> Optional[dict[str, Any]]: + gate_intent = str(intent_gate.get("intent") or INTENT_SIGNAL_INTAKE) + if gate_intent == INTENT_SMALLTALK: + duration_ms = int((time.perf_counter() - process_started_at) * 1000) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=None, + raw_message=message, + ai_raw_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False), + project_score=None, + signal_confidence=None, + level="smalltalk", + routing_decision=ROUTING_NOISE_FILTERED, + routing_reason="intent gate 识别为 smalltalk/短确认,不进入 Inbox。", + reasoning_bullets=[ + "消息不包含可执行主数据动作。", + "命中 short confirmation / smalltalk。", + "系统直接忽略,不写通知。", + ], + processing_status="success", + error_message=None, + duration_ms=duration_ms, + ) + await self.db.commit() + return self._decorate_capture_result( + { + "status": "ignored", + "reason": ROUTING_NOISE_FILTERED, + "classification": "smalltalk", + "project_scope": "smalltalk", + "linked_project_ids": [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": f"feishu-smalltalk-{(feishu_message_id or 'noid')[:32]}", + "results": [], + }, + chat_type=normalized_chat_type, + normalized_text=message, + route=f"{normalized_chat_type}.intent_gate.smalltalk", + db_mutations=[], + ) + + if gate_intent == INTENT_HEALTHCHECK: + healthcheck_capture = await self._build_healthcheck_capture( + message=message, + feishu_message_id=feishu_message_id, + process_started_at=process_started_at, + ) + await self.db.commit() + return self._decorate_capture_result( + healthcheck_capture, + chat_type=normalized_chat_type, + normalized_text=message, + route=f"{normalized_chat_type}.intent_gate.healthcheck", + db_mutations=[], + ) + + if gate_intent == INTENT_QUERY: + rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) + conversation_key = self._build_rag_conversation_key( + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + sender=sender, + ) + rag_result = await rag_service.answer_question( + question=message, + asked_by=None, + asked_via="feishu", + project_id=inferred_project_id, + source_chat_id=source_chat_id, + conversation_key=conversation_key, + ) + qa_capture = await self._build_direct_qa_capture( + message=message, + rag_result=rag_result, + inferred_project_id=inferred_project_id, + feishu_message_id=feishu_message_id, + process_started_at=process_started_at, + route_reason="intent gate 判定为 query,直接走 RAG 直答通道,不进入决策队列。", + intent_label=INTENT_QUERY, + ) + await self.db.commit() + return self._decorate_capture_result( + qa_capture, + chat_type=normalized_chat_type, + normalized_text=message, + route=f"{normalized_chat_type}.intent_gate.query", + db_mutations=[], + ) + + if gate_intent == INTENT_CREATE_PROJECT or ( + gate_intent == INTENT_CLARIFICATION_REQUIRED and str(intent_gate.get("canonical_action") or "") == "project.create" + ): + project_capture = await self._handle_create_project_gate( + message=message, + sender=sender, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + feishu_message_id=feishu_message_id, + captured_at=captured_at, + intent_gate=intent_gate, + process_started_at=process_started_at, + ) + await self.db.commit() + return self._decorate_capture_result( + project_capture, + chat_type=normalized_chat_type, + normalized_text=message, + route=f"{normalized_chat_type}.intent_gate.project_create", + db_mutations=self._summarize_db_mutations(project_capture), + ) + + if gate_intent == INTENT_CREATE_TASK or ( + gate_intent == INTENT_CLARIFICATION_REQUIRED and str(intent_gate.get("canonical_action") or "") == "task.create" + ): + task_capture = await self._handle_create_task_gate( + message=message, + sender=sender, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + feishu_message_id=feishu_message_id, + captured_at=captured_at, + intent_gate=intent_gate, + process_started_at=process_started_at, + ) + await self.db.commit() + return self._decorate_capture_result( + task_capture, + chat_type=normalized_chat_type, + normalized_text=message, + route=f"{normalized_chat_type}.intent_gate.task_create", + db_mutations=self._summarize_db_mutations(task_capture), + ) + + if gate_intent == INTENT_EXTERNAL_CONNECT: + external_capture = await self._handle_external_connect_gate( + message=message, + sender=sender, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + feishu_message_id=feishu_message_id, + captured_at=captured_at, + intent_gate=intent_gate, + process_started_at=process_started_at, + ) + await self.db.commit() + return self._decorate_capture_result( + external_capture, + chat_type=normalized_chat_type, + normalized_text=message, + route=f"{normalized_chat_type}.intent_gate.external_connect", + db_mutations=self._summarize_db_mutations(external_capture), + ) + + command_capture = await self._maybe_block_legacy_command_request( + message=message, + sender=sender, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + feishu_message_id=feishu_message_id, + captured_at=captured_at, + inferred_project_id=inferred_project_id, + projects=projects, + process_started_at=process_started_at, + ) + if command_capture is not None: + await self.db.commit() + return self._decorate_capture_result( + command_capture, + chat_type=normalized_chat_type, + normalized_text=message, + route=f"{normalized_chat_type}.command", + db_mutations=self._summarize_db_mutations(command_capture), + ) + return None + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_gate_project.py b/backend/app/services/feishu_signal_pipeline/mixin_gate_project.py new file mode 100644 index 00000000..7433a131 --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_gate_project.py @@ -0,0 +1,208 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * + + +class FeishuPipelineGateProjectMixin: + async def _handle_create_project_gate( + self, + *, + message: str, + sender: str, + source_chat_id: str, + source_sender_open_id: Optional[str], + feishu_message_id: Optional[str], + captured_at: Optional[datetime], + intent_gate: dict[str, Any], + process_started_at: float, + ) -> dict[str, Any]: + fields = intent_gate.get("fields") if isinstance(intent_gate.get("fields"), dict) else {} + clarification_message = str(intent_gate.get("clarification_template") or "").strip() or self._build_project_clarification_message( + list(fields.get("missing_fields") or []), + ) + occurred_at = captured_at or _utc_now() + if occurred_at.tzinfo is None: + occurred_at = occurred_at.replace(tzinfo=CN_TZ) + + operator_user = await self._resolve_command_operator_user(sender=sender) + if not fields.get("ready"): + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=None, + raw_message=message, + ai_raw_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False), + project_score=1.0, + signal_confidence=0.0, + level="project", + routing_decision=ROUTING_INTENT_GATE_CLARIFICATION, + routing_reason="建项目缺少必填字段,已转待澄清,禁止猜测执行。", + reasoning_bullets=[ + "intent gate 命中 create_project。", + "当前缺少 project.create 必填字段。", + "仅保留结构化待澄清项,不触发执行器。", + ], + processing_status="success", + error_message=None, + duration_ms=int((time.perf_counter() - process_started_at) * 1000), + normalized_text=message, + route=ROUTING_INTENT_GATE_CLARIFICATION, + intent=INTENT_CLARIFICATION_REQUIRED, + executor="explicit_service", + operation_code="project.create", + reply_source="deterministic_reply", + ) + return { + "status": "captured", + "classification": "project", + "project_scope": "project", + "linked_project_ids": [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": f"feishu-project-clarify-{(feishu_message_id or 'noid')[:32]}", + "results": [], + "intent": INTENT_CLARIFICATION_REQUIRED, + "clarification_message": clarification_message, + "direct_reply_required": True, + "direct_reply_text": clarification_message, + } + + if not self._can_execute_high_value_action(operator_user): + queued = await self._queue_project_intent_item( + message=message, + sender=sender, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + feishu_message_id=feishu_message_id, + occurred_at=occurred_at, + intent_gate=intent_gate, + blocking_reason=clarification_message if not fields.get("ready") else "建项目属于高风险动作,当前需要你确认后再执行。", + decision_reason="high_value_action_guard", + ) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=_safe_int(queued.get("item_id")), + raw_message=message, + ai_raw_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False), + project_score=1.0, + signal_confidence=0.0, + level="project", + routing_decision=ROUTING_INTENT_GATE_CLARIFICATION, + routing_reason="建项目命中高风险保护,已转待决策/待澄清。", + reasoning_bullets=[ + "intent gate 命中 create_project。", + "当前未满足自动执行条件。", + "仅保留结构化待决策项,不做猜测执行。", + ], + processing_status="success", + error_message=None, + duration_ms=int((time.perf_counter() - process_started_at) * 1000), + normalized_text=message, + route=ROUTING_INTENT_GATE_CLARIFICATION, + intent=INTENT_CLARIFICATION_REQUIRED if not fields.get("ready") else INTENT_CREATE_PROJECT, + executor="explicit_service", + operation_code="project.create", + reply_source="deterministic_reply", + ) + return { + "status": "captured", + "classification": "project", + "project_scope": "project", + "linked_project_ids": [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": f"feishu-project-pending-{queued.get('item_id')}", + "results": [queued], + "intent": INTENT_CLARIFICATION_REQUIRED if not fields.get("ready") else INTENT_CREATE_PROJECT, + "clarification_message": queued.get("clarification_message"), + "direct_reply_required": True, + "direct_reply_text": str(queued.get("clarification_message") or "已转待决策。"), + } + + payload_description = str(fields.get("description") or "").strip() + project_scope = str(fields.get("project_scope") or "").strip() + if project_scope and project_scope not in payload_description: + payload_description = f"{payload_description}\n\n项目范围:{project_scope}".strip() + payload = { + "title": str(fields.get("title") or "").strip(), + "description": payload_description, + } + trace_id = f"intent-project-{self._stable_positive_int(message, sender, feishu_message_id)}" + execution = await self._execute_controlled_operation( + operator_user=operator_user, + operation_code="project.create", + payload=payload, + trace_id=trace_id, + ) + first = execution["results"][0] if execution.get("results") else {} + status = str(first.get("status") or "").strip().lower() + if status not in {"applied", "skipped"}: + raise ValueError(str(first.get("message") or "project.create failed")) + + project_result = first.get("result") if isinstance(first.get("result"), dict) else {} + project_id = _safe_int(project_result.get("project_id") or first.get("entity_id")) + item_id = await self._record_committed_operation_item( + message=message, + sender=sender, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + feishu_message_id=feishu_message_id, + captured_at=captured_at, + intent=INTENT_CREATE_PROJECT, + operation_code="project.create", + execution_result=first, + matched_project_id=project_id, + ) + reply_text = self._build_operation_commit_reply({"execution": {"results": [first], "applied_count": 1 if status == "applied" else 0, "skipped_count": 1 if status == "skipped" else 0, "failed_count": 0}}) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=item_id, + raw_message=message, + ai_raw_output=json.dumps({"intent_gate": intent_gate, "execution": execution}, ensure_ascii=False)[:120000], + project_score=1.0, + signal_confidence=0.0, + level="project", + routing_decision=ROUTING_OPERATION_COMMITTED, + routing_reason="建项目已通过 deterministic operation executor 提交,并以真实 DB 结果回执。", + reasoning_bullets=[ + "intent gate 命中 create_project。", + "执行器仅走 project.create。", + "回复绑定真实 commit 结果。", + ], + processing_status="success", + error_message=None, + duration_ms=int((time.perf_counter() - process_started_at) * 1000), + normalized_text=message, + route=ROUTING_OPERATION_COMMITTED, + intent=INTENT_CREATE_PROJECT, + executor="explicit_service", + operation_code="project.create", + operation_result=first if isinstance(first, dict) else None, + reply_source="db_commit", + ) + return { + "status": "captured", + "classification": "project", + "project_scope": "project", + "linked_project_ids": [int(project_id)] if project_id is not None else [], + "signal_id": None, + "created_project_id": int(project_id) if project_id is not None else None, + "auto_tasks": [], + "ai_trace_id": trace_id, + "results": [ + { + "item_id": item_id, + "level": "command", + "project_id": int(project_id) if project_id is not None else None, + "signal_id": None, + "route_status": ROUTE_STATUS_AUTO_DONE, + } + ], + "intent": INTENT_CREATE_PROJECT, + "direct_reply_required": True, + "direct_reply_text": reply_text, + "command_result": {"execution": execution}, + } + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_gate_task.py b/backend/app/services/feishu_signal_pipeline/mixin_gate_task.py new file mode 100644 index 00000000..0ced7038 --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_gate_task.py @@ -0,0 +1,206 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * + + +class FeishuPipelineGateTaskMixin: + async def _handle_create_task_gate( + self, + *, + message: str, + sender: str, + source_chat_id: str, + source_sender_open_id: Optional[str], + feishu_message_id: Optional[str], + captured_at: Optional[datetime], + intent_gate: dict[str, Any], + process_started_at: float, + ) -> dict[str, Any]: + fields = intent_gate.get("fields") if isinstance(intent_gate.get("fields"), dict) else {} + operation_payload = { + "project_id": _safe_int(fields.get("project_id")), + "title": str(fields.get("title") or "").strip(), + "description": str(fields.get("description") or "").strip() or None, + } + clarification_message = str(intent_gate.get("clarification_template") or "").strip() or self._build_task_clarification_message( + list(fields.get("missing_fields") or []), + ) + occurred_at = captured_at or _utc_now() + if occurred_at.tzinfo is None: + occurred_at = occurred_at.replace(tzinfo=CN_TZ) + + operator_user = await self._resolve_command_operator_user(sender=sender) + if not fields.get("ready"): + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=None, + raw_message=message, + ai_raw_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False), + project_score=1.0 if fields.get("project_id") is not None else None, + signal_confidence=0.0 if fields.get("project_id") is not None else None, + level="command", + routing_decision=ROUTING_INTENT_GATE_CLARIFICATION, + routing_reason="建任务缺少必填字段,已转待澄清,禁止猜测执行。", + reasoning_bullets=[ + "intent gate 命中 create_task。", + "当前缺少 task.create 必填字段。", + "仅保留结构化待澄清项,不触发执行器。", + ], + processing_status="success", + error_message=None, + duration_ms=int((time.perf_counter() - process_started_at) * 1000), + normalized_text=message, + route=ROUTING_INTENT_GATE_CLARIFICATION, + intent=INTENT_CLARIFICATION_REQUIRED, + executor="explicit_service", + operation_code="task.create", + reply_source="deterministic_reply", + ) + return { + "status": "captured", + "classification": "command", + "project_scope": "command", + "linked_project_ids": [int(fields["project_id"])] if fields.get("project_id") is not None else [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": f"feishu-task-clarify-{(feishu_message_id or 'noid')[:32]}", + "results": [], + "intent": INTENT_CLARIFICATION_REQUIRED, + "clarification_message": clarification_message, + "direct_reply_required": True, + "direct_reply_text": clarification_message, + } + + if not self._can_execute_high_value_action(operator_user): + queued = await self._queue_command_intent_item( + message=message, + sender=sender, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + feishu_message_id=feishu_message_id, + occurred_at=occurred_at, + intent_gate=intent_gate, + operation_code="task.create", + operation_payload={k: v for k, v in operation_payload.items() if v is not None}, + blocking_reason=clarification_message if not fields.get("ready") else "建任务属于高风险动作,当前需要你确认后再执行。", + decision_reason="high_value_action_guard", + matched_project_id=_safe_int(fields.get("project_id")), + ) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=_safe_int(queued.get("item_id")), + raw_message=message, + ai_raw_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False), + project_score=1.0 if fields.get("project_id") is not None else None, + signal_confidence=0.0 if fields.get("project_id") is not None else None, + level="command", + routing_decision=ROUTING_INTENT_GATE_CLARIFICATION, + routing_reason="建任务命中高风险保护,已转待决策/待澄清。", + reasoning_bullets=[ + "intent gate 命中 create_task。", + "当前未满足自动执行条件。", + "仅保留结构化待决策项,不做猜测执行。", + ], + processing_status="success", + error_message=None, + duration_ms=int((time.perf_counter() - process_started_at) * 1000), + normalized_text=message, + route=ROUTING_INTENT_GATE_CLARIFICATION, + intent=INTENT_CLARIFICATION_REQUIRED if not fields.get("ready") else INTENT_CREATE_TASK, + executor="explicit_service", + operation_code="task.create", + reply_source="deterministic_reply", + ) + return { + "status": "captured", + "classification": "command", + "project_scope": "command", + "linked_project_ids": [int(fields["project_id"])] if fields.get("project_id") is not None else [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": f"feishu-task-pending-{queued.get('item_id')}", + "results": [queued], + "intent": INTENT_CLARIFICATION_REQUIRED if not fields.get("ready") else INTENT_CREATE_TASK, + "clarification_message": queued.get("clarification_message"), + "direct_reply_required": True, + "direct_reply_text": str(queued.get("clarification_message") or "已转待决策。"), + } + + trace_id = f"intent-task-{self._stable_positive_int(message, sender, feishu_message_id)}" + execution = await self._execute_controlled_operation( + operator_user=operator_user, + operation_code="task.create", + payload={k: v for k, v in operation_payload.items() if v is not None}, + trace_id=trace_id, + ) + first = execution["results"][0] if execution.get("results") else {} + status = str(first.get("status") or "").strip().lower() + if status not in {"applied", "skipped"}: + raise ValueError(str(first.get("message") or "task.create failed")) + project_id = _safe_int(fields.get("project_id")) + item_id = await self._record_committed_operation_item( + message=message, + sender=sender, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + feishu_message_id=feishu_message_id, + captured_at=captured_at, + intent=INTENT_CREATE_TASK, + operation_code="task.create", + execution_result=first, + matched_project_id=project_id, + ) + reply_text = self._build_operation_commit_reply({"execution": {"results": [first], "applied_count": 1 if status == "applied" else 0, "skipped_count": 1 if status == "skipped" else 0, "failed_count": 0}}) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=item_id, + raw_message=message, + ai_raw_output=json.dumps({"intent_gate": intent_gate, "execution": execution}, ensure_ascii=False)[:120000], + project_score=1.0 if project_id is not None else None, + signal_confidence=0.0 if project_id is not None else None, + level="command", + routing_decision=ROUTING_OPERATION_COMMITTED, + routing_reason="建任务已通过 deterministic operation executor 提交,并以真实 DB 结果回执。", + reasoning_bullets=[ + "intent gate 命中 create_task。", + "执行器仅走 task.create。", + "回复绑定真实 commit 结果。", + ], + processing_status="success", + error_message=None, + duration_ms=int((time.perf_counter() - process_started_at) * 1000), + normalized_text=message, + route=ROUTING_OPERATION_COMMITTED, + intent=INTENT_CREATE_TASK, + executor="explicit_service", + operation_code="task.create", + operation_result=first if isinstance(first, dict) else None, + reply_source="db_commit", + ) + return { + "status": "captured", + "classification": "command", + "project_scope": "command", + "linked_project_ids": [int(project_id)] if project_id is not None else [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": trace_id, + "results": [ + { + "item_id": item_id, + "level": "command", + "project_id": int(project_id) if project_id is not None else None, + "signal_id": None, + "route_status": ROUTE_STATUS_AUTO_DONE, + } + ], + "intent": INTENT_CREATE_TASK, + "direct_reply_required": True, + "direct_reply_text": reply_text, + "command_result": {"execution": execution}, + } + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_intent_gate_core.py b/backend/app/services/feishu_signal_pipeline/mixin_intent_gate_core.py new file mode 100644 index 00000000..c4afed3e --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_intent_gate_core.py @@ -0,0 +1,283 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * + + +class FeishuPipelineIntentGateCoreMixin: + async def _resolve_intent_gate( + self, + *, + message: str, + inferred_project_id: Optional[int], + source_chat_id: str = "", + source_sender_open_id: Optional[str] = None, + sender: str = "", + ) -> dict[str, Any]: + text = str(message or "").strip() + if not text: + return { + "intent": INTENT_SMALLTALK, + "canonical_action": None, + "clarification_template": "", + "risk_level": "low", + "fields": {}, + "correction_match": None, + } + + explicit_project_request = self._looks_like_project_create_request(text) + explicit_task_request = self._looks_like_task_create_request(text) + explicit_external_request = self._looks_like_external_connect_request(text) + correction_match: Optional[dict[str, Any]] = None + if self.db is not None and len(text) >= 4: + try: + recall_rows = await AdaptiveRAGService(self.db, tenant_id=self.tenant_id).retrieve_intent_correction_samples( + utterance=text, + top_k=1, + project_id=inferred_project_id, + ) + if recall_rows: + correction_match = recall_rows[0] + except Exception as exc: + logger.warning(f"intent_correction_recall_failed: tenant_id={self.tenant_id}, error={exc}") + correction_decision = kernel_resolve_correction(text) + if correction_decision.action != "none" and not ( + explicit_project_request or explicit_task_request or explicit_external_request + ): + decision_trace = build_decision_trace( + intent=INTENT_CLARIFICATION_REQUIRED, + confidence=correction_decision.confidence, + route_tier="rule", + route_function="_resolve_intent_gate", + gate_action="pending_manual", + gate_code="correction_review", + correction_action=correction_decision.action, + correction_keywords=correction_decision.matched_keywords, + complexity_reasons=[], + ) + return { + "intent": INTENT_CLARIFICATION_REQUIRED, + "canonical_action": "clarify", + "clarification_template": "检测到修正/回滚语义,已进入待确认队列。请明确目标对象后继续执行。", + "risk_level": "high", + "fields": {}, + "correction_match": correction_match, + "decision_trace": decision_trace, + } + + if self._is_healthcheck_message(text): + return { + "intent": INTENT_HEALTHCHECK, + "canonical_action": None, + "clarification_template": "", + "risk_level": "low", + "fields": {}, + "correction_match": correction_match, + } + if self._is_short_confirmation_message(text): + return { + "intent": INTENT_SMALLTALK, + "canonical_action": None, + "clarification_template": "", + "risk_level": "low", + "fields": {}, + "correction_match": correction_match, + } + + if ( + correction_match + and float(correction_match.get("score") or 0.0) >= 0.84 + and not (explicit_project_request or explicit_task_request or explicit_external_request) + ): + gate = { + "intent": str(correction_match.get("correct_intent") or INTENT_SIGNAL_INTAKE).strip().lower(), + "canonical_action": str(correction_match.get("canonical_action") or "").strip() or None, + "clarification_template": str(correction_match.get("clarification_template") or "").strip(), + "risk_level": str(correction_match.get("risk_level") or "medium").strip().lower() or "medium", + "fields": {}, + "correction_match": correction_match, + } + if gate["intent"] not in { + INTENT_QUERY, + INTENT_SMALLTALK, + INTENT_HEALTHCHECK, + INTENT_CREATE_PROJECT, + INTENT_CREATE_TASK, + INTENT_SIGNAL_INTAKE, + INTENT_EXTERNAL_CONNECT, + INTENT_CLARIFICATION_REQUIRED, + }: + gate["intent"] = INTENT_SIGNAL_INTAKE + return gate + + if explicit_project_request: + fields = self._extract_project_intent_fields(text) + fields = await self._maybe_prefill_project_fields_from_embedded_content( + message=text, + fields=fields, + ) + fields = await self._maybe_prefill_project_fields_from_context( + message=text, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + sender=sender, + fields=fields, + ) + if fields["ready"]: + return { + "intent": INTENT_CREATE_PROJECT, + "canonical_action": "project.create", + "clarification_template": "", + "risk_level": "high", + "fields": fields, + "correction_match": correction_match, + } + return { + "intent": INTENT_CLARIFICATION_REQUIRED, + "canonical_action": "project.create", + "clarification_template": self._build_project_clarification_message(fields["missing_fields"]), + "risk_level": "high", + "fields": fields, + "correction_match": correction_match, + } + + if explicit_task_request: + fields = self._extract_task_intent_fields(text=text, inferred_project_id=inferred_project_id) + if fields["ready"]: + return { + "intent": INTENT_CREATE_TASK, + "canonical_action": "task.create", + "clarification_template": "", + "risk_level": "high", + "fields": fields, + "correction_match": correction_match, + } + return { + "intent": INTENT_CLARIFICATION_REQUIRED, + "canonical_action": "task.create", + "clarification_template": self._build_task_clarification_message(fields["missing_fields"]), + "risk_level": "high", + "fields": fields, + "correction_match": correction_match, + } + + if explicit_external_request: + return { + "intent": INTENT_EXTERNAL_CONNECT, + "canonical_action": "source.connect", + "clarification_template": "外部系统接入默认进入待决策队列。请补充接入目标、授权边界、回流项目和幂等要求。", + "risk_level": "high", + "fields": {"project_id": inferred_project_id}, + "correction_match": correction_match, + } + + if self._is_query_like_question(text): + return { + "intent": INTENT_QUERY, + "canonical_action": None, + "clarification_template": "", + "risk_level": "low", + "fields": {}, + "correction_match": correction_match, + } + + if self._is_obvious_question(text): + return { + "intent": INTENT_QUERY, + "canonical_action": None, + "clarification_template": "", + "risk_level": "low", + "fields": {}, + "correction_match": correction_match, + } + + return { + "intent": INTENT_SIGNAL_INTAKE, + "canonical_action": "signal.enter", + "clarification_template": "", + "risk_level": "medium", + "fields": {"project_id": inferred_project_id}, + "correction_match": correction_match, + } + + @staticmethod + def _extract_project_scope(text: str, *, description: str) -> str: + cleaned = re.sub(r"\s+", " ", str(text or "")).strip() + for pattern in ( + r"(?:范围|scope|面向|目标|用于|负责|服务于)[::\s]*(.+)", + r"(?:描述|背景|场景|做什么)[::\s]*(.+)", + ): + match = re.search(pattern, cleaned, re.IGNORECASE) + if not match: + continue + candidate = re.sub(r"[。;;!!??].*$", "", match.group(1)).strip(" ,,::") + if candidate: + return candidate[:400] + if len(description) >= 12: + return description[:400] + return "" + + def _extract_project_intent_fields(self, message: str) -> dict[str, Any]: + title = extract_project_create_title(message) + if not title: + cleaned = re.sub(r"\s+", " ", str(message or "")).strip() + for pattern in ( + r"(?:名称|名字|项目名)(?:叫|是|为)?[::\s]*(.+)", + r"项目(?:叫|名为|名称叫|名称是|名称为)[::\s]*(.+)", + ): + match = re.search(pattern, cleaned, re.IGNORECASE) + if not match: + continue + candidate = re.sub(r"[,。,.;;!??].*$", "", match.group(1)).strip(" ,,::\"'“”") + if candidate: + title = candidate[:24] + break + description = extract_project_create_description( + instruction=message, + title=title, + ) + project_scope = self._extract_project_scope(message, description=description) + missing_fields: list[str] = [] + if not title: + missing_fields.append("title") + if not description: + missing_fields.append("description") + if not project_scope: + missing_fields.append("project_scope") + return { + "title": title, + "description": description, + "project_scope": project_scope, + "missing_fields": missing_fields, + "ready": not missing_fields, + } + + @staticmethod + def _looks_like_contextual_project_request(message: str) -> bool: + normalized = re.sub(r"\s+", "", str(message or "")).lower() + if not normalized: + return False + return any( + token in normalized + for token in ( + "根据这个", + "根据上面", + "基于这个", + "基于上面", + "按这个", + "按上面", + "汇总以上", + "汇总上面", + "以上内容", + "上面内容", + "这个汇总", + "这个总结", + "刚才内容", + ) + ) + + @staticmethod + def _strip_feishu_mention_prefix(text: str) -> str: + cleaned = re.sub(r"^]*>[^<]*\s*", "", str(text or ""), flags=re.IGNORECASE).strip() + return re.sub(r"\s+", " ", cleaned).strip() + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_intent_support.py b/backend/app/services/feishu_signal_pipeline/mixin_intent_support.py new file mode 100644 index 00000000..c0c95e0b --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_intent_support.py @@ -0,0 +1,102 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * + + +class FeishuPipelineIntentSupportMixin: + @staticmethod + def _derive_wrong_interpretation_from_recent_item(item: Optional[FeishuItem]) -> str: + if item is None: + return "unknown" + level = str(item.level or "").strip().lower() + if level == "project": + return "project_candidate" + if level == "signal": + return "signal_intake" + if level == "command": + return str(item.card_type or "command") + return level or "unknown" + + async def _maybe_write_intent_correction_sample( + self, + *, + message: str, + source_chat_id: str, + intent_gate: dict[str, Any], + ) -> None: + if self.db is None or not self._is_correction_message(message): + return + recent_items = await self._load_recent_chat_items(source_chat_id=source_chat_id, limit=3) if source_chat_id else [] + wrong_interpretation = self._derive_wrong_interpretation_from_recent_item(recent_items[0] if recent_items else None) + fields = intent_gate.get("fields") if isinstance(intent_gate.get("fields"), dict) else {} + clarification_template = str(intent_gate.get("clarification_template") or "").strip() + if not clarification_template: + if str(intent_gate.get("intent") or "") == INTENT_CREATE_PROJECT: + clarification_template = self._build_project_clarification_message( + list(fields.get("missing_fields") or []), + ) + elif str(intent_gate.get("intent") or "") == INTENT_CREATE_TASK: + clarification_template = self._build_task_clarification_message( + list(fields.get("missing_fields") or []), + ) + try: + await AdaptiveRAGService(self.db, tenant_id=self.tenant_id).upsert_intent_correction_sample( + user_utterance=message, + wrong_interpretation=wrong_interpretation, + correct_intent=str(intent_gate.get("intent") or INTENT_CLARIFICATION_REQUIRED), + canonical_action=str(intent_gate.get("canonical_action") or "").strip() or "clarify", + clarification_template=clarification_template, + risk_level=str(intent_gate.get("risk_level") or "medium"), + project_id=_safe_int(fields.get("project_id")), + ) + except Exception as exc: + logger.warning(f"intent_correction_write_failed: tenant_id={self.tenant_id}, error={exc}") + + async def _build_healthcheck_capture( + self, + *, + message: str, + feishu_message_id: Optional[str], + process_started_at: float, + ) -> dict[str, Any]: + duration_ms = int((time.perf_counter() - process_started_at) * 1000) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=None, + raw_message=message, + ai_raw_output=json.dumps({"intent": INTENT_HEALTHCHECK}, ensure_ascii=False), + project_score=None, + signal_confidence=None, + level="healthcheck", + routing_decision=ROUTING_INTENT_GATE_HEALTHCHECK, + routing_reason="命中 healthcheck,直接返回执行链可用状态,不进入 Inbox。", + reasoning_bullets=[ + "intent gate 已识别为 healthcheck。", + "不涉及写库动作。", + "回执固定由后端生成。", + ], + processing_status="success", + error_message=None, + duration_ms=duration_ms, + normalized_text=message, + route=ROUTING_INTENT_GATE_HEALTHCHECK, + intent=INTENT_HEALTHCHECK, + executor="router", + reply_source="deterministic_reply", + ) + return { + "status": "answered", + "classification": "healthcheck", + "project_scope": "healthcheck", + "linked_project_ids": [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": f"feishu-healthcheck-{(feishu_message_id or 'noid')[:32]}", + "results": [], + "intent": INTENT_HEALTHCHECK, + "direct_reply_required": True, + "direct_reply_text": "healthcheck=ok;执行链在线;仅真实 commit 后才会回“已执行”。", + } + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_item_finalize_routing.py b/backend/app/services/feishu_signal_pipeline/mixin_item_finalize_routing.py new file mode 100644 index 00000000..5d2fee55 --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_item_finalize_routing.py @@ -0,0 +1,225 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * + + +class FeishuPipelineItemFinalizeRoutingMixin: + async def _finalize_signal_item( + self, + *, + item: FeishuItem, + content: str, + sender: str, + source_chat_id: str, + source_sender_open_id: Optional[str], + feishu_message_id: Optional[str], + level_result: dict[str, Any], + signal_info: dict[str, Any], + matched_project_id: Optional[int], + confidence: float, + project_score: float, + cold_start_enabled: bool, + signal_auto_enter_threshold: float, + chat_context_resolution: dict[str, Any], + inferred_project_id: Optional[int], + simple_signal_request: bool, + clarification_message: Optional[str], + item_started_at: float, + ) -> dict[str, Any]: + signal = Signal( + tenant_id=self.tenant_id, + source="Feishu", + content=f"[{sender or '未知用户'}]: {content[:800]}", + impact_score=max(0.1, min(1.0, confidence)), + priority=self._to_signal_priority(str(signal_info.get("priority") or "P2")), + processed=False, + strategic_analysis=json.dumps( + { + "source_context": { + "group_id": source_chat_id, + "sender": sender, + "sender_open_id": source_sender_open_id, + }, + "ai_analysis": { + "classification": "signal", + "reason": signal_info.get("summary"), + }, + }, + ensure_ascii=False, + ), + tasks_generated="[]", + feishu_item_id=int(item.id), + matched_project_id=matched_project_id, + title=(signal_info.get("title") or content[:40] or "未命名信号")[:200], + signal_type=self._normalize_signal_type(signal_info.get("type")), + summary=(signal_info.get("summary") or "")[:3000] or None, + reasoning=json.dumps(_normalize_reasoning(signal_info.get("reasoning")), ensure_ascii=False), + confidence=confidence, + recommended_action=self._normalize_recommended_action(signal_info.get("recommended_action")), + status="待处理", + ) + self.db.add(signal) + await self.db.flush() + + project_id = await self._route_signal_item( + item=item, + signal=signal, + matched_project_id=matched_project_id, + confidence=confidence, + cold_start_enabled=cold_start_enabled, + signal_auto_enter_threshold=signal_auto_enter_threshold, + ) + routing_decision, routing_reason, reasoning_bullets = self._build_signal_routing_log( + confidence=confidence, + cold_start_enabled=cold_start_enabled, + matched_project_id=matched_project_id, + signal_auto_enter_threshold=signal_auto_enter_threshold, + ) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=int(item.id), + raw_message=content, + ai_raw_output=json.dumps( + { + "level_classification": level_result, + "signal_extraction": signal_info, + "simple_signal_request": simple_signal_request, + "chat_context_inferred_project_id": inferred_project_id, + "chat_context_resolution": chat_context_resolution, + "clarification_message": clarification_message, + }, + ensure_ascii=False, + ), + project_score=project_score, + signal_confidence=confidence, + level="signal", + routing_decision=routing_decision, + routing_reason=routing_reason, + reasoning_bullets=reasoning_bullets, + processing_status="success", + error_message=None, + duration_ms=int((time.perf_counter() - item_started_at) * 1000), + ) + return { + "item_id": int(item.id), + "level": "signal", + "project_id": project_id, + "signal_id": int(signal.id), + "route_status": item.route_status, + "clarification_message": clarification_message, + } + + async def _route_project_item( + self, + *, + item: FeishuItem, + suggestion: ProjectSuggestion, + sender: str, + project_score: float, + cold_start_enabled: bool, + project_auto_create_threshold: float, + ) -> Optional[int]: + _ = project_auto_create_threshold + high_score = project_score >= max(project_auto_create_threshold, 0.7) + item.card_type = "new_project" if high_score else "new_project_candidate" + item.route_status = ROUTE_STATUS_QUEUED + item.queue_visible = True + item.message_class = "action_required" + item.safe_to_auto_execute = False + item.auto_execute_state = "blocked" + item.blocking_reason = ( + "当前处于冷启动保护期,建项目默认需要你确认后再执行。" + if cold_start_enabled + else "建项目属于高风险动作,默认需要你授权后再执行。" + ) + item.decision_reason = ( + "intent gate/自由分类均不再直接 auto create,避免误把自然语言写成项目主数据。" + ) + item.next_step_text = "确认后,系统会走受控 project.create,并绑定真实项目 ID。" + return None + + async def _route_signal_item( + self, + *, + item: FeishuItem, + signal: Signal, + matched_project_id: Optional[int], + confidence: float, + cold_start_enabled: bool, + signal_auto_enter_threshold: float, + ) -> Optional[int]: + item.matched_project_id = int(matched_project_id) if matched_project_id else None + gate = kernel_gate_execution( + confidence=float(confidence or 0.0), + risk_level=("medium" if matched_project_id else "high"), + correction_action="none", + ) + decision_trace = build_decision_trace( + intent=INTENT_SIGNAL_INTAKE, + confidence=float(confidence or 0.0), + route_tier="rule", + route_function="_route_signal_item", + gate_action=gate.action, + gate_code=gate.gate_code, + correction_action="none", + correction_keywords=[], + complexity_reasons=[], + ) + if cold_start_enabled or gate.action != "auto_execute" or confidence < signal_auto_enter_threshold or not matched_project_id: + item.card_type = "signal" + item.route_status = ROUTE_STATUS_QUEUED + item.queue_visible = True + item.message_class = "action_required" + item.safe_to_auto_execute = False + item.auto_execute_state = "blocked" + if not matched_project_id: + item.blocking_reason = "当前缺少明确项目归属,继续自动执行会产生错误副作用。" + item.next_step_text = "补齐项目归属后,AI 将自动把信号入库并继续后续链路。" + item.decision_reason = "上下文尚不完整,系统暂停在需你决策队列。" + elif cold_start_enabled: + item.blocking_reason = "当前处于冷启动保护期,信号入库暂不自动执行。" + item.next_step_text = "确认后,AI 将继续完成信号入库并推进后续动作。" + item.decision_reason = "保守自动化已拦截自动入库,避免早期误关联。" + elif gate.action == "reject": + item.blocking_reason = gate.reason + item.next_step_text = "请补充更明确的执行对象后重试,或人工确认继续处理。" + item.decision_reason = "共享决策内核判定置信度不足,自动执行被拒绝。" + else: + item.blocking_reason = gate.reason or "当前置信度不足以自动入库,需要你拍板后再继续。" + item.next_step_text = "确认后,AI 将把信号入库并进入后续生成流程。" + item.decision_reason = "信号虽然可执行,但归属判断仍存在不确定性。" + item.result_payload = { + "result_summary": "信号已进入待确认队列。", + "result_count": 0, + "impact_objects": [signal.title or f"信号#{signal.id}"], + "decision_trace": decision_trace, + } + return matched_project_id + + await self._ensure_signal_link(signal_id=int(signal.id), project_id=int(matched_project_id)) + item.card_type = "signal_auto" + item.route_status = ROUTE_STATUS_AUTO_DONE + item.queue_visible = False + item.processed_at = _utc_now().replace(tzinfo=None) + item.message_class = "auto_done" + item.safe_to_auto_execute = True + item.auto_execute_state = "done" + item.blocking_reason = None + item.decision_reason = "低风险且上下文完整,AI 已自动完成信号入库。" + item.next_step_text = "下一步可基于该信号继续生成 charter 或分发任务。" + item.undoable_until = _build_undo_deadline(item.processed_at) + item.result_payload = { + "result_summary": f"已自动入库信号 {signal.title or '未命名信号'}", + "result_count": 1, + "impact_objects": [signal.title or f"信号#{signal.id}", f"项目#{int(matched_project_id)}"], + "target_url": f"/projects/{int(matched_project_id)}/board", + "project_id": int(matched_project_id), + "decision_trace": decision_trace, + } + await self._notify_signal_auto_entered( + project_id=int(matched_project_id), + signal=signal, + ) + return int(matched_project_id) + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_item_processing.py b/backend/app/services/feishu_signal_pipeline/mixin_item_processing.py new file mode 100644 index 00000000..e3594194 --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_item_processing.py @@ -0,0 +1,294 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * + + +class FeishuPipelineItemProcessingMixin: + async def _process_one_item( + self, + *, + content: str, + level: str, + sender: str, + source_chat_id: str, + source_sender_open_id: Optional[str], + feishu_message_id: Optional[str], + occurred_at: datetime, + level_result: dict[str, Any], + level_confidence: float, + level_reasoning: str, + project_score: float, + cold_start_enabled: bool, + signal_auto_enter_threshold: float, + project_auto_create_threshold: float, + project_uncertain_threshold: float, + chat_context_auto_link_enabled: bool, + projects: list[dict[str, Any]], + split_from_item_id: Optional[int], + chat_type: str, + chat_context_resolution: dict[str, Any], + ) -> dict[str, Any]: + item_started_at = time.perf_counter() + level_val = "project" if level == "project" else "signal" + matched_project_hint = _safe_int(chat_context_resolution.get("project_id")) + dedupe_key = self._build_queue_dedupe_key( + level=level_val, + intent=INTENT_SIGNAL_INTAKE if level_val == "signal" else INTENT_CREATE_PROJECT, + message=content, + source_chat_id=source_chat_id, + matched_project_id=matched_project_hint, + source_object=content, + ) + existing_item = await self._find_active_dedupe_item(dedupe_key) + if existing_item is not None: + existing_item.occurrence_count = max(1, int(existing_item.occurrence_count or 1)) + 1 + existing_item.raw_message = content[:4000] + existing_item.source_sender = (sender or "")[:120] or None + existing_item.source_sender_open_id = (source_sender_open_id or "")[:128] or None + existing_item.source_chat_id = (source_chat_id or "")[:128] or None + existing_item.source_occurred_at = occurred_at.replace(tzinfo=None) + existing_item.level_reasoning = level_reasoning[:1000] + existing_item.ai_output = json.dumps( + { + "chat_type": chat_type, + "normalized_text": content, + "level_result": level_result, + }, + ensure_ascii=False, + )[:8000] + await self.db.flush() + return await self._build_existing_item_result(existing_item) + + item = FeishuItem( + tenant_id=self.tenant_id, + feishu_message_id=feishu_message_id, + project_score=project_score, + signal_confidence=max(0.0, 1 - project_score), + raw_message=content[:4000], + split_from_item_id=split_from_item_id, + level=level_val, + level_confidence=level_confidence, + level_reasoning=level_reasoning[:1000], + card_type="signal" if level_val == "signal" else "new_project", + route_status=ROUTE_STATUS_CLASSIFIED, + source_chat_id=(source_chat_id or "")[:128] or None, + source_sender=(sender or "")[:120] or None, + source_sender_open_id=(source_sender_open_id or "")[:128] or None, + source_occurred_at=occurred_at.replace(tzinfo=None), + ai_output=json.dumps( + { + "chat_type": chat_type, + "normalized_text": content, + "level_result": level_result, + }, + ensure_ascii=False, + )[:8000], + decision_reason=level_reasoning[:1000] or None, + safe_to_auto_execute=False, + auto_execute_state="blocked", + dedupe_key=dedupe_key, + message_class="action_required", + occurrence_count=1, + next_step_text="AI 正在校验上下文,确认后会继续推进后续动作。", + queue_visible=True, + ) + self.db.add(item) + await self.db.flush() + + if level_val == "project": + project_info, suggestion = await self._create_project_suggestion( + item_id=int(item.id), + content=content, + sender=sender, + fallback_confidence=level_confidence, + ) + return await self._finalize_project_item( + item=item, + content=content, + feishu_message_id=feishu_message_id, + level_result=level_result, + project_info=project_info, + project_score=project_score, + signal_confidence=max(0.0, 1 - project_score), + suggestion=suggestion, + sender=sender, + cold_start_enabled=cold_start_enabled, + project_auto_create_threshold=project_auto_create_threshold, + project_uncertain_threshold=project_uncertain_threshold, + item_started_at=item_started_at, + ) + + simple_signal_request = self._is_simple_signal_source_request(content) + signal_info = await self._extract_signal(content, projects=projects) + matched_project_id = signal_info.get("matched_project_id") + if matched_project_id is not None: + try: + matched_project_id = int(matched_project_id) + except (TypeError, ValueError): + matched_project_id = None + + confidence = _safe_float(signal_info.get("confidence"), max(0.55, 1 - project_score)) + if simple_signal_request: + confidence = max(confidence, min(0.95, signal_auto_enter_threshold + 0.05)) + inferred_project_id: Optional[int] = None + clarification_message: Optional[str] = None + if (chat_context_auto_link_enabled or simple_signal_request) and matched_project_id is None and source_chat_id: + inferred_project_id = _safe_int(chat_context_resolution.get("project_id")) + if inferred_project_id is not None: + matched_project_id = int(inferred_project_id) + elif bool(chat_context_resolution.get("ambiguous")): + candidate_ids = [int(x) for x in (chat_context_resolution.get("candidate_ids") or [])[:3] if _safe_int(x)] + if candidate_ids: + clarification_message = ( + f"当前群最近同时涉及项目#{'、#'.join([str(pid) for pid in candidate_ids])}," + "请明确这条消息归属哪个项目。" + ) + + if matched_project_id is None and self._should_promote_signal_to_project_candidate( + content=content, + signal_info=signal_info, + confidence=confidence, + signal_auto_enter_threshold=signal_auto_enter_threshold, + simple_signal_request=simple_signal_request, + ): + promoted_project_score = max(float(project_score or 0.0), float(project_uncertain_threshold)) + item.level = "project" + item.project_score = promoted_project_score + item.signal_confidence = max(0.0, 1 - promoted_project_score) + item.card_type = "new_project_candidate" + item.matched_project_id = None + + project_info, suggestion = await self._create_project_suggestion( + item_id=int(item.id), + content=content, + sender=sender, + fallback_confidence=confidence, + ) + return await self._finalize_project_item( + item=item, + content=content, + feishu_message_id=feishu_message_id, + level_result=level_result, + project_info=project_info, + project_score=promoted_project_score, + signal_confidence=confidence, + suggestion=suggestion, + sender=sender, + cold_start_enabled=cold_start_enabled, + project_auto_create_threshold=project_auto_create_threshold, + project_uncertain_threshold=project_uncertain_threshold, + item_started_at=item_started_at, + extra_ai_fields={ + "signal_extraction": signal_info, + "promoted_to_project_candidate": True, + "simple_signal_request": simple_signal_request, + }, + ) + + return await self._finalize_signal_item( + item=item, + content=content, + sender=sender, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + feishu_message_id=feishu_message_id, + level_result=level_result, + signal_info=signal_info, + matched_project_id=matched_project_id, + confidence=confidence, + project_score=project_score, + cold_start_enabled=cold_start_enabled, + signal_auto_enter_threshold=signal_auto_enter_threshold, + chat_context_resolution=chat_context_resolution, + inferred_project_id=inferred_project_id, + simple_signal_request=simple_signal_request, + clarification_message=clarification_message, + item_started_at=item_started_at, + ) + + async def _create_project_suggestion( + self, + *, + item_id: int, + content: str, + sender: str, + fallback_confidence: float, + ) -> tuple[dict[str, Any], ProjectSuggestion]: + project_info = await self._extract_project_prefill(content) + suggestion = ProjectSuggestion( + tenant_id=self.tenant_id, + feishu_item_id=int(item_id), + suggested_name=(project_info.get("suggested_name") or content[:20] or "新项目建议")[:255], + suggested_description=(project_info.get("suggested_description") or content[:400])[:4000], + suggested_owner=(project_info.get("suggested_owner") or sender or "待分配")[:120], + suggested_timeline=(project_info.get("suggested_timeline") or "待确认")[:120], + confidence=_safe_float(project_info.get("confidence"), fallback_confidence), + reasoning=json.dumps(_normalize_reasoning(project_info.get("reasoning")), ensure_ascii=False), + status="pending", + ) + self.db.add(suggestion) + await self.db.flush() + return project_info, suggestion + + async def _finalize_project_item( + self, + *, + item: FeishuItem, + content: str, + feishu_message_id: Optional[str], + level_result: dict[str, Any], + project_info: dict[str, Any], + project_score: float, + signal_confidence: float, + suggestion: ProjectSuggestion, + sender: str, + cold_start_enabled: bool, + project_auto_create_threshold: float, + project_uncertain_threshold: float, + item_started_at: float, + extra_ai_fields: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + project_id = await self._route_project_item( + item=item, + suggestion=suggestion, + sender=sender, + project_score=project_score, + cold_start_enabled=cold_start_enabled, + project_auto_create_threshold=project_auto_create_threshold, + ) + routing_decision, routing_reason, reasoning_bullets = self._build_project_routing_log( + project_score=project_score, + cold_start_enabled=cold_start_enabled, + project_auto_create_threshold=project_auto_create_threshold, + project_uncertain_threshold=project_uncertain_threshold, + ) + ai_raw_payload: dict[str, Any] = { + "level_classification": level_result, + "project_prefill": project_info, + } + if isinstance(extra_ai_fields, dict): + ai_raw_payload.update(extra_ai_fields) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=int(item.id), + raw_message=content, + ai_raw_output=json.dumps(ai_raw_payload, ensure_ascii=False), + project_score=project_score, + signal_confidence=signal_confidence, + level="project", + routing_decision=routing_decision, + routing_reason=routing_reason, + reasoning_bullets=reasoning_bullets, + processing_status="success", + error_message=None, + duration_ms=int((time.perf_counter() - item_started_at) * 1000), + ) + return { + "item_id": int(item.id), + "level": "project", + "project_id": project_id, + "signal_id": None, + "route_status": item.route_status, + } + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_lookup_and_block.py b/backend/app/services/feishu_signal_pipeline/mixin_lookup_and_block.py new file mode 100644 index 00000000..6c3a06e4 --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_lookup_and_block.py @@ -0,0 +1,238 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * + + +class FeishuPipelineLookupAndBlockMixin: + @staticmethod + def _looks_like_project_create_command(message: str) -> bool: + return bool(LEGACY_COMMAND_PROJECT_CREATE_RE.search(str(message or ""))) + + @classmethod + def _looks_like_project_lookup_question(cls, message: str) -> bool: + text = str(message or "").strip() + if not text: + return False + if cls._is_legacy_business_command(text): + return False + normalized = re.sub(r"\s+", "", text.lower()) + if PROJECT_LIST_QUERY_RE.search(text): + return True + if PROJECT_ID_QUERY_RE.search(text): + return True + if "项目" not in normalized: + return False + return any(token in normalized for token in ("有吗", "有没有", "有无", "是否有", "是否存在", "存在吗", "是什么", "哪些")) + + @classmethod + def _is_legacy_business_command(cls, message: str) -> bool: + text = str(message or "").strip() + if not text: + return False + if cls._is_query_like_question(message): + return False + if cls._looks_like_project_create_command(text): + return True + return bool(LEGACY_COMMAND_VERB_RE.search(text) and LEGACY_COMMAND_OBJECT_RE.search(text)) + + async def _maybe_block_legacy_command_request( + self, + *, + message: str, + sender: str, + source_chat_id: str, + source_sender_open_id: Optional[str], + feishu_message_id: Optional[str], + captured_at: Optional[datetime], + inferred_project_id: Optional[int], + projects: list[dict[str, Any]], + process_started_at: float, + ) -> Optional[dict[str, Any]]: + if not self._is_legacy_business_command(message): + return None + + _ = sender, source_chat_id, source_sender_open_id, captured_at, inferred_project_id, projects + return await self._build_legacy_command_blocked_capture( + message=message, + feishu_message_id=feishu_message_id, + duration_ms=int((time.perf_counter() - process_started_at) * 1000), + reason="旧 command-first 规划链路已下线,请改用 GLM 分析后直接调用显式接口。", + ) + + async def _maybe_answer_project_lookup_question( + self, + *, + message: str, + feishu_message_id: Optional[str], + process_started_at: float, + projects: list[dict[str, Any]], + ) -> Optional[dict[str, Any]]: + if not self._looks_like_project_lookup_question(message): + return None + + linked_project_ids: list[int] = [] + reply_text = "" + id_match = PROJECT_ID_QUERY_RE.search(message) + if id_match: + target_id = int(id_match.group(1)) + matched = next((item for item in projects if _safe_int(item.get("id")) == target_id), None) + if matched is None: + reply_text = f"项目#{target_id} 不存在,或当前租户下不可见。" + else: + linked_project_ids = [target_id] + reply_text = self._format_project_lookup_hit_rows([matched], prefix="查到了") + elif PROJECT_LIST_QUERY_RE.search(message): + if not projects: + reply_text = "当前没有可见项目。" + else: + reply_text = self._format_project_lookup_hit_rows(projects[:8], prefix="当前项目") + linked_project_ids = [int(item["id"]) for item in projects[:8] if _safe_int(item.get("id"))] + else: + keyword = self._extract_project_lookup_keyword(message) + matched_rows = self._match_projects_by_keyword(projects=projects, keyword=keyword) + if matched_rows: + linked_project_ids = [int(item["id"]) for item in matched_rows if _safe_int(item.get("id"))] + reply_text = self._format_project_lookup_hit_rows(matched_rows[:5], prefix="查到了") + elif keyword: + reply_text = f"没有查到包含“{keyword}”的项目。" + else: + reply_text = "这条问题缺少可检索的项目关键词。" + + duration_ms = int((time.perf_counter() - process_started_at) * 1000) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=None, + raw_message=message, + ai_raw_output=json.dumps({"deterministic_lookup": True, "linked_project_ids": linked_project_ids}, ensure_ascii=False), + project_score=None, + signal_confidence=None, + level="question", + routing_decision=ROUTING_DIRECT_QA, + routing_reason="命中项目查询问句,已直接基于真实 DB 返回结果,跳过 LLM 分流。", + reasoning_bullets=[ + "命中 deterministic project lookup 规则。", + "直接查询 projects 表,不走 signal/project 分流。", + "回复内容只基于真实 DB 结果。", + ], + processing_status="success", + error_message=None, + duration_ms=duration_ms, + ) + return { + "status": "answered", + "classification": "question", + "project_scope": "question", + "linked_project_ids": linked_project_ids, + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": f"feishu-project-lookup-{(feishu_message_id or 'noid')[:32]}", + "results": [], + "intent": "question", + "direct_reply_required": True, + "direct_reply_text": reply_text, + } + + async def _build_legacy_command_blocked_capture( + self, + *, + message: str, + feishu_message_id: Optional[str], + duration_ms: int, + reason: str, + ) -> dict[str, Any]: + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=None, + raw_message=message, + ai_raw_output=None, + project_score=None, + signal_confidence=None, + level="command", + routing_decision=ROUTING_LEGACY_COMMAND_BLOCKED, + routing_reason=reason, + reasoning_bullets=[ + "命中 command-first 路由,但当前缺少安全执行所需的上下文。", + "系统未执行任何写库动作。", + "回执只基于确定性拦截结果,不使用 LLM 概括。", + ], + processing_status="success", + error_message=None, + duration_ms=duration_ms, + ) + return { + "status": "captured", + "classification": "command", + "project_scope": "command", + "linked_project_ids": [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": None, + "results": [], + "intent": "command", + "direct_reply_required": True, + "direct_reply_text": f"未执行:{reason}", + "command_result": None, + } + + @staticmethod + def _extract_project_lookup_keyword(message: str) -> str: + text = str(message or "").strip() + if not text: + return "" + cleaned = re.sub( + r"(请问|麻烦问下|问下|现在|当前|目前|系统里|系统中|项目里|项目中|有没有|有无|有吗|是否有|是否存在|存在吗|这个|那个|一个|个|项目|列表|清单|有|吗|呢|呀|啊|么)", + " ", + text, + flags=re.IGNORECASE, + ) + cleaned = re.sub(r"[,。,.;;::!??()()\\[\\]【】]+", " ", cleaned) + cleaned = re.sub(r"\s+", " ", cleaned).strip() + return cleaned[:40] + + @staticmethod + def _match_projects_by_keyword(*, projects: list[dict[str, Any]], keyword: str) -> list[dict[str, Any]]: + normalized_keyword = re.sub(r"\s+", "", str(keyword or "")).lower() + if len(normalized_keyword) < 2: + return [] + matched: list[dict[str, Any]] = [] + for item in projects: + title = re.sub(r"\s+", "", str(item.get("title") or item.get("name") or "")).lower() + if normalized_keyword and normalized_keyword in title: + matched.append(item) + return matched + + @staticmethod + def _format_project_lookup_hit_rows(rows: list[dict[str, Any]], *, prefix: str) -> str: + if not rows: + return "当前没有可见项目。" + fragments: list[str] = [] + for item in rows[:8]: + project_id = item.get("id") + title = str(item.get("title") or item.get("name") or "").strip() or f"项目#{project_id}" + status = str(item.get("status") or "").strip() or "unknown" + fragments.append(f"#{project_id} {title}({status})") + return f"{prefix}:{'、'.join(fragments)}。" + + @classmethod + def _should_hide_pending_item_from_inbox(cls, item: FeishuItem) -> bool: + if str(item.decision_reason or "").strip() == "missing_required_fields": + return True + raw_message = str(item.raw_message or "").strip() + if not raw_message: + return False + normalized = re.sub(r"\s+", "", raw_message).lower() + if cls._is_water_talk_message(raw_message): + return True + if cls._is_healthcheck_message(raw_message): + return True + if cls._is_short_confirmation_message(raw_message): + return True + if cls._looks_like_project_lookup_question(raw_message): + return True + if "长连接" in normalized and any(token in normalized for token in ("已通", "都已通", "正常", "在线", "已恢复")): + return True + return False + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_message_utils_a.py b/backend/app/services/feishu_signal_pipeline/mixin_message_utils_a.py new file mode 100644 index 00000000..d70ed30e --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_message_utils_a.py @@ -0,0 +1,140 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * + + +class FeishuPipelineMessageUtilsAMixin: + @staticmethod + def _is_water_talk_message(message: str) -> bool: + content = str(message or "").strip().lower() + if not content: + return True + normalized = re.sub(r"\s+", "", content) + question_hints = ( + "?", + "?", + "吗", + "怎么", + "如何", + "为什么", + "谁", + "多少", + "是否", + "有没有", + "几号", + "几点", + "周几", + ) + water_keywords = ( + "你是谁", + "你是誰", + "什么身份", + "身份确认", + "今天什么日期", + "今天几号", + "当前日期", + "日期疑问", + "询问日期", + "现在几点", + "几点了", + "今天周几", + "今天星期几", + "说话", + "在吗", + "在不在", + ) + requirement_hints = ( + "需求", + "优化", + "调整", + "修复", + "bug", + "故障", + "报错", + "项目", + "页面", + "接口", + "功能", + "排版", + "样式", + "任务", + "立项", + "上线", + "配置", + ) + if any(k in normalized for k in water_keywords) and not any(h in normalized for h in requirement_hints): + return True + if any(k in normalized for k in question_hints): + return False + return False + + @staticmethod + def _build_rag_conversation_key( + *, + source_chat_id: Optional[str], + source_sender_open_id: Optional[str], + sender: Optional[str], + ) -> str: + chat = str(source_chat_id or "").strip() + if not chat: + return "" + actor = str(source_sender_open_id or "").strip() + if not actor: + actor = str(sender or "").strip().lower() + actor = re.sub(r"\s+", "", actor) + actor = actor[:96] if actor else "unknown" + return f"feishu:{chat}:{actor}" + + @staticmethod + def _is_requirement_like_question(message: str) -> bool: + text = str(message or "").strip() + if not text: + return False + normalized = re.sub(r"\s+", "", text.lower()) + has_question = ( + "?" in normalized + or "?" in normalized + or any( + cue in normalized + for cue in ("吗", "是否", "是不是", "要不要", "能不能", "可不可以", "需不需要", "有没有必要") + ) + ) + if not has_question: + return False + if INDEPENDENT_REQUIREMENT_RE.search(normalized): + return True + if SIMPLE_SIGNAL_REQUEST_RE.search(normalized): + return True + has_verb = bool(QUESTION_REQUIREMENT_VERB_RE.search(normalized)) + has_object = bool(QUESTION_REQUIREMENT_OBJECT_RE.search(normalized)) + return has_verb and has_object + + @staticmethod + def _extract_requirement_statement_from_question(message: str) -> str: + text = str(message or "").strip() + if not text: + return "" + candidate = re.sub(r"^(请问|问下|想问下|我想问下|麻烦问下|请教下)\s*", "", text, flags=re.IGNORECASE).strip() + candidate = re.sub(r"\s*[??!!。\.]+\s*$", "", candidate).strip() + candidate = re.sub(r"(吗|呢|呀|吧)\s*$", "", candidate).strip() + replacements = ( + (r"能不能", "需要"), + (r"可不可以", "需要"), + (r"要不要", "需要"), + (r"需不需要", "需要"), + (r"有没有必要", "需要"), + (r"是不是", ""), + (r"是否", ""), + ) + for pattern, repl in replacements: + candidate = re.sub(pattern, repl, candidate, flags=re.IGNORECASE) + candidate = re.sub(r"\s+", " ", candidate).strip(" ,,::") + return candidate or text + + def _convert_requirement_question_to_signal_text(self, *, message: str, fallback_content: str) -> str: + converted = self._extract_requirement_statement_from_question(message) + if converted: + return converted + return self._normalize_incoming_text(fallback_content) + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_message_utils_b.py b/backend/app/services/feishu_signal_pipeline/mixin_message_utils_b.py new file mode 100644 index 00000000..e6b2fb2c --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_message_utils_b.py @@ -0,0 +1,264 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * + + +class FeishuPipelineMessageUtilsBMixin: + async def _has_processed_message(self, feishu_message_id: str) -> bool: + key = (feishu_message_id or "").strip() + if not key: + return False + existed_in_items = ( + await self.db.execute( + select(FeishuItem.id).where( + and_( + FeishuItem.tenant_id == self.tenant_id, + FeishuItem.feishu_message_id == key, + ) + ).limit(1) + ) + ).scalar_one_or_none() + if existed_in_items is not None: + return True + existed = ( + await self.db.execute( + select(AIProcessingLog.id).where( + and_( + AIProcessingLog.tenant_id == self.tenant_id, + AIProcessingLog.feishu_message_id == key, + or_( + AIProcessingLog.level.is_(None), + AIProcessingLog.level != "ingress", + ), + or_( + AIProcessingLog.routing_decision.is_(None), + AIProcessingLog.routing_decision != "event_received", + ), + ) + ).limit(1) + ) + ).scalar_one_or_none() + return existed is not None + + async def _acquire_message_lock(self, feishu_message_id: str) -> None: + key = (feishu_message_id or "").strip() + if not key: + return + try: + bind = self.db.get_bind() + dialect_name = str(getattr(getattr(bind, "dialect", None), "name", "") or "").lower() + except Exception: + dialect_name = "" + if dialect_name not in {"postgresql", "postgres"}: + return + try: + await self.db.execute( + text("SELECT pg_advisory_xact_lock(hashtext(:key))"), + {"key": key}, + ) + except Exception as exc: + logger.warning(f"feishu_pipeline_message_lock_failed: key={key[:32]}, error={exc}") + + async def _write_processing_log( + self, + *, + feishu_message_id: Optional[str], + feishu_item_id: Optional[int], + raw_message: Optional[str], + ai_raw_output: Optional[str], + project_score: Optional[float], + signal_confidence: Optional[float], + level: Optional[str], + routing_decision: str, + routing_reason: Optional[str], + reasoning_bullets: Optional[list[str]], + processing_status: str, + error_message: Optional[str], + duration_ms: Optional[int], + normalized_text: Optional[str] = None, + route: Optional[str] = None, + intent: Optional[str] = None, + executor: Optional[str] = None, + operation_code: Optional[str] = None, + operation_result: Optional[dict[str, Any]] = None, + reply_source: Optional[str] = None, + ) -> None: + normalized_route = (route or routing_decision or "")[:80] or None + normalized_intent = (intent or level or "")[:40] or None + normalized_executor = (executor or ("explicit_service" if operation_code else "router"))[:40] or None + normalized_reply_source = ( + reply_source + or ("db_commit" if operation_code else "deterministic_reply") + ) + self.db.add( + AIProcessingLog( + tenant_id=self.tenant_id, + feishu_message_id=(feishu_message_id or "")[:128] or None, + feishu_item_id=int(feishu_item_id) if feishu_item_id is not None else None, + raw_message=(raw_message or "")[:8000] or None, + ai_raw_output=(ai_raw_output or "")[:120000] or None, + project_score=project_score, + signal_confidence=signal_confidence, + level=(level or "")[:20] or None, + normalized_text=(normalized_text or raw_message or "")[:8000] or None, + route=normalized_route, + intent=normalized_intent, + executor=normalized_executor, + operation_code=(operation_code or "")[:80] or None, + operation_result=operation_result if isinstance(operation_result, dict) else None, + reply_source=(normalized_reply_source or "")[:40] or None, + routing_decision=(routing_decision or ROUTING_FAILED)[:64], + routing_reason=(routing_reason or "")[:1000] or None, + reasoning_bullets=[str(x).strip()[:120] for x in (reasoning_bullets or [])[:3]], + processing_status=(processing_status or "success")[:20], + error_message=(error_message or "")[:4000] or None, + duration_ms=max(0, int(duration_ms)) if duration_ms is not None else None, + ) + ) + + def _build_project_routing_log( + self, + *, + project_score: float, + cold_start_enabled: bool, + project_auto_create_threshold: float, + project_uncertain_threshold: float, + ) -> tuple[str, str, list[str]]: + _ = project_auto_create_threshold + if cold_start_enabled: + return ( + ROUTING_PENDING_COLD_START, + "当前处于冷启动期,项目候选会统一进入待处理队列。", + [ + "冷启动期开启,自动创建策略暂缓。", + f"project_score={project_score:.2f},仅用于排序参考。", + "等待人工确认后再执行创建动作。", + ], + ) + if project_score >= project_uncertain_threshold: + return ( + ROUTING_PENDING_UNCERTAIN_PROJECT, + "项目分数较高,但建项已收口到待决策链,不再自动创建。", + [ + f"project_score={project_score:.2f},高于 {project_uncertain_threshold:.2f}。", + "即使意图强,也必须等待受控 project.create。", + "已进入待处理队列等待确认。", + ], + ) + return ( + ROUTING_PENDING_LOW_CONFIDENCE, + "项目分数偏低,暂不自动创建,进入待处理队列。", + [ + f"project_score={project_score:.2f},低于 {project_uncertain_threshold:.2f}。", + "消息更接近执行信号而非完整立项需求。", + "需要人工补充信息后再决策。", + ], + ) + + def _build_signal_routing_log( + self, + *, + confidence: float, + cold_start_enabled: bool, + matched_project_id: Optional[int], + signal_auto_enter_threshold: float, + ) -> tuple[str, str, list[str]]: + if cold_start_enabled: + return ( + ROUTING_PENDING_COLD_START, + "当前处于冷启动期,信号统一进入待处理队列。", + [ + "冷启动期开启,自动入库策略暂缓。", + f"signal_confidence={confidence:.2f},仅用于排序参考。", + "等待人工审核后再入库。", + ], + ) + if confidence < signal_auto_enter_threshold: + return ( + ROUTING_PENDING_LOW_CONFIDENCE, + "信号置信度低于自动入库阈值,已进入待处理队列。", + [ + f"signal_confidence={confidence:.2f},低于 {signal_auto_enter_threshold:.2f} 阈值。", + "当前信息不足以支持自动决策。", + "需要人工确认后再执行后续动作。", + ], + ) + if not matched_project_id: + return ( + ROUTING_PENDING_UNCERTAIN_PROJECT, + "置信度达标但项目归属不确定,转人工确认。", + [ + f"signal_confidence={confidence:.2f},达到自动判定线。", + "未识别到明确项目归属。", + "已进入待处理队列等待项目确认。", + ], + ) + return ( + ROUTING_AUTO_ENTER_SIGNAL, + "信号置信度达标且项目归属明确,系统已自动入库。", + [ + f"signal_confidence={confidence:.2f},高于 {signal_auto_enter_threshold:.2f} 阈值。", + f"已匹配项目 #{int(matched_project_id)}。", + "已自动建立信号关联并发送通知。", + ], + ) + + @staticmethod + def _normalize_recommended_action(value: Any) -> str: + action = str(value or "enter_only").strip().lower() + if action not in {"enter_only", "create_charter", "trigger_alert"}: + return "enter_only" + return action + + @staticmethod + def _normalize_signal_type(value: Any) -> str: + signal_type = str(value or "新需求").strip() + if signal_type in SIGNAL_TYPE_OPTIONS: + return signal_type + return "新需求" + + @staticmethod + def _to_signal_priority(value: str) -> str: + key = str(value or "").strip().upper() + if key in {"P0", "HIGH"}: + return "high" + if key in {"P1", "MEDIUM"}: + return "medium" + if key in {"P2", "LOW"}: + return "low" + if key == "P3": + return "low" + return "medium" + + def _should_promote_signal_to_project_candidate( + self, + *, + content: str, + signal_info: dict[str, Any], + confidence: float, + signal_auto_enter_threshold: float, + simple_signal_request: bool, + ) -> bool: + _ = (content, signal_info, confidence, signal_auto_enter_threshold, simple_signal_request) + # 项目创建只能通过 intent gate:create_project + 受控 operation executor 落库; + # 不再把 signal 自由升级为 project candidate。 + return False + + @staticmethod + def _is_simple_signal_source_request(message: str) -> bool: + text = re.sub(r"\s+", "", str(message or "")) + if len(text) < 6: + return False + if PROJECT_COMPLEXITY_RE.search(text): + return False + if SIMPLE_SIGNAL_REQUEST_RE.search(text): + return True + action_tokens = ("新增", "增加", "实现", "支持", "修复", "优化", "接入", "改造", "调整") + object_tokens = ("功能", "能力", "页面", "接口", "流程", "按钮", "提醒", "识别", "字段", "规则", "逻辑") + return ( + len(text) <= 180 + and any(token in text for token in action_tokens) + and any(token in text for token in object_tokens) + ) + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_persistence_lookup.py b/backend/app/services/feishu_signal_pipeline/mixin_persistence_lookup.py new file mode 100644 index 00000000..c4fe4a09 --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_persistence_lookup.py @@ -0,0 +1,154 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * + + +class FeishuPipelinePersistenceLookupMixin: + async def _resolve_user_id_by_name(self, name: Optional[str]) -> Optional[int]: + key = (name or "").strip() + if not key: + return None + user = ( + await self.db.execute( + select(User).where( + and_( + User.tenant_id == self.tenant_id, + User.is_active.is_(True), + func.lower(User.name) == key.lower(), + ) + ) + ) + ).scalar_one_or_none() + if user: + return int(user.id) + fuzzy = ( + await self.db.execute( + select(User).where( + and_( + User.tenant_id == self.tenant_id, + User.is_active.is_(True), + User.name.ilike(f"%{key}%"), + ) + ).limit(1) + ) + ).scalar_one_or_none() + return int(fuzzy.id) if fuzzy else None + + async def _fallback_user_id(self) -> Optional[int]: + user = ( + await self.db.execute( + select(User.id).where( + and_( + User.tenant_id == self.tenant_id, + User.is_active.is_(True), + ) + ).limit(1) + ) + ).scalar_one_or_none() + return int(user) if user else None + + async def _resolve_chat_context_project(self, *, source_chat_id: str) -> dict[str, Any]: + chat_id = (source_chat_id or "").strip() + if not chat_id: + return {"project_id": None, "ambiguous": False, "candidate_ids": [], "top_ratio": 0.0} + + recent_rows = ( + await self.db.execute( + select(FeishuItem.matched_project_id) + .where( + and_( + FeishuItem.tenant_id == self.tenant_id, + FeishuItem.source_chat_id == chat_id, + FeishuItem.matched_project_id.is_not(None), + FeishuItem.route_status.in_((ROUTE_STATUS_AUTO_DONE, "auto_processed", "confirmed")), + ) + ) + .order_by(FeishuItem.id.desc()) + .limit(20) + ) + ).all() + project_ids = [int(row[0]) for row in recent_rows if row and row[0] is not None] + if not project_ids: + return {"project_id": None, "ambiguous": False, "candidate_ids": [], "top_ratio": 0.0} + + unique_ids = set(project_ids) + candidate_ids = sorted(unique_ids, key=lambda pid: (-project_ids.count(pid), pid)) + if len(unique_ids) == 1: + return {"project_id": int(project_ids[0]), "ambiguous": False, "candidate_ids": candidate_ids, "top_ratio": 1.0} + + top_pid = max(unique_ids, key=lambda pid: project_ids.count(pid)) + top_count = project_ids.count(top_pid) + ratio = top_count / len(project_ids) + if top_count >= 2 and ratio >= 0.6: + return {"project_id": int(top_pid), "ambiguous": False, "candidate_ids": candidate_ids, "top_ratio": ratio} + return {"project_id": None, "ambiguous": True, "candidate_ids": candidate_ids, "top_ratio": ratio} + + async def _infer_project_id_from_chat_context(self, *, source_chat_id: str) -> Optional[int]: + resolution = await self._resolve_chat_context_project(source_chat_id=source_chat_id) + return _safe_int(resolution.get("project_id")) + + async def _get_item(self, item_id: int) -> Optional[FeishuItem]: + return ( + await self.db.execute( + select(FeishuItem).where( + and_( + FeishuItem.tenant_id == self.tenant_id, + FeishuItem.id == int(item_id), + ) + ) + ) + ).scalar_one_or_none() + + async def _get_project_suggestion(self, item_id: int) -> Optional[ProjectSuggestion]: + return ( + await self.db.execute( + select(ProjectSuggestion).where( + and_( + ProjectSuggestion.tenant_id == self.tenant_id, + ProjectSuggestion.feishu_item_id == int(item_id), + ) + ) + ) + ).scalar_one_or_none() + + async def _get_signal_by_item(self, item_id: int) -> Optional[Signal]: + return ( + await self.db.execute( + select(Signal).where( + and_( + Signal.tenant_id == self.tenant_id, + Signal.feishu_item_id == int(item_id), + ) + ) + ) + ).scalar_one_or_none() + + def _dedupe_queue_items(self, rows: list[FeishuItem]) -> list[FeishuItem]: + deduped: list[FeishuItem] = [] + seen_signal_keys: set[str] = set() + aggregated: dict[str, FeishuItem] = {} + for row in rows: + explicit_key = str(row.dedupe_key or "").strip() + if explicit_key: + existing = aggregated.get(explicit_key) + if existing is None: + aggregated[explicit_key] = row + deduped.append(row) + else: + existing.occurrence_count = max(1, int(existing.occurrence_count or 1)) + max( + 1, int(row.occurrence_count or 1) + ) + continue + level = str(row.level or "").lower() + if level != "signal": + deduped.append(row) + continue + message_id = str(row.feishu_message_id or "").strip() + key = message_id or f"{str(row.raw_message or '')[:120]}::{str(row.source_sender or '')}" + if key in seen_signal_keys: + continue + seen_signal_keys.add(key) + deduped.append(row) + return deduped + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_process_message_core.py b/backend/app/services/feishu_signal_pipeline/mixin_process_message_core.py new file mode 100644 index 00000000..40fa9279 --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_process_message_core.py @@ -0,0 +1,207 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * + + +class FeishuPipelineProcessMessageCoreMixin: + async def process_message( + self, + *, + message_content: str, + sender: str, + source_chat_id: str, + source_sender_open_id: Optional[str] = None, + feishu_message_id: Optional[str] = None, + captured_at: Optional[datetime] = None, + chat_type: Optional[str] = None, + ) -> dict[str, Any]: + process_started_at = time.perf_counter() + normalized_chat_type = self._normalize_chat_type(chat_type=chat_type, source_chat_id=source_chat_id) + message = self._normalize_incoming_text(message_content) + if not message: + return self._decorate_capture_result( + {"status": "ignored", "reason": "empty_message", "results": []}, + chat_type=normalized_chat_type, + normalized_text=message, + route=f"{normalized_chat_type}.empty_message", + db_mutations=[], + ) + + if self._is_water_talk_message(message): + duration_ms = int((time.perf_counter() - process_started_at) * 1000) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=None, + raw_message=message, + ai_raw_output=None, + project_score=None, + signal_confidence=None, + level=None, + routing_decision=ROUTING_NOISE_FILTERED, + routing_reason="识别为闲聊/问答水话,已过滤且不进入待处理队列。", + reasoning_bullets=[ + "消息缺少可执行交付信息。", + "不满足需求信号的最小结构(目标/对象/动作)。", + "已按噪音消息忽略,不触发入库与排队。", + ], + processing_status="success", + error_message=None, + duration_ms=duration_ms, + ) + await self.db.commit() + return self._decorate_capture_result({ + "status": "ignored", + "reason": ROUTING_NOISE_FILTERED, + "classification": "non_requirement", + "project_scope": "non_requirement", + "linked_project_ids": [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": f"feishu-noise-{(feishu_message_id or 'noid')[:32]}", + "results": [], + }, chat_type=normalized_chat_type, normalized_text=message, route=f"{normalized_chat_type}.noise_filtered", db_mutations=[]) + + if feishu_message_id: + await self._acquire_message_lock(feishu_message_id) + + if feishu_message_id and await self._has_processed_message(feishu_message_id): + duration_ms = int((time.perf_counter() - process_started_at) * 1000) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=None, + raw_message=message, + ai_raw_output=None, + project_score=None, + signal_confidence=None, + level=None, + routing_decision=ROUTING_DUPLICATE_FILTERED, + routing_reason="检测到重复飞书消息 ID,已过滤且不再重复执行 AI 处理。", + reasoning_bullets=[ + "消息唯一标识已存在于处理日志中。", + "本次请求被标记为重复消息。", + "系统已跳过后续分类和入库动作。", + ], + processing_status="success", + error_message=None, + duration_ms=duration_ms, + ) + await self.db.commit() + return self._decorate_capture_result({ + "status": "ignored", + "reason": ROUTING_DUPLICATE_FILTERED, + "classification": "duplicate", + "project_scope": "duplicate", + "linked_project_ids": [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": f"feishu-duplicate-{feishu_message_id}", + "results": [], + }, chat_type=normalized_chat_type, normalized_text=message, route=f"{normalized_chat_type}.duplicate_filtered", db_mutations=[]) + + projects = await self._load_active_projects() + chat_context_resolution = { + "project_id": None, + "ambiguous": False, + "candidate_ids": [], + "top_ratio": 0.0, + } + if source_chat_id: + try: + chat_context_resolution = await self._resolve_chat_context_project(source_chat_id=source_chat_id) + except Exception: + chat_context_resolution = { + "project_id": None, + "ambiguous": False, + "candidate_ids": [], + "top_ratio": 0.0, + } + inferred_project_id = _safe_int(chat_context_resolution.get("project_id")) + + followup_capture = await self._maybe_handle_queue_followup( + message=message, + sender=sender, + source_chat_id=source_chat_id, + feishu_message_id=feishu_message_id, + process_started_at=process_started_at, + ) + if followup_capture is not None: + await self.db.commit() + return self._decorate_capture_result( + followup_capture, + chat_type=normalized_chat_type, + normalized_text=message, + route=f"{normalized_chat_type}.followup", + db_mutations=self._summarize_db_mutations(followup_capture), + ) + + lookup_capture = await self._maybe_answer_project_lookup_question( + message=message, + feishu_message_id=feishu_message_id, + process_started_at=process_started_at, + projects=projects, + ) + if lookup_capture is not None: + await self.db.commit() + return self._decorate_capture_result( + lookup_capture, + chat_type=normalized_chat_type, + normalized_text=message, + route=f"{normalized_chat_type}.project_lookup", + db_mutations=[], + ) + + intent_gate = await self._resolve_intent_gate( + message=message, + inferred_project_id=inferred_project_id, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + sender=sender, + ) + logger.info( + "intent_resolved", + tenant_id=self.tenant_id, + feishu_message_id=feishu_message_id, + intent=str(intent_gate.get("intent") or ""), + canonical_action=str(intent_gate.get("canonical_action") or ""), + normalized_text=message[:400], + ) + await self._maybe_write_intent_correction_sample( + message=message, + source_chat_id=source_chat_id, + intent_gate=intent_gate, + ) + + gate_capture = await self._maybe_handle_intent_gate_capture( + intent_gate=intent_gate, + message=message, + sender=sender, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + feishu_message_id=feishu_message_id, + captured_at=captured_at, + inferred_project_id=inferred_project_id, + projects=projects, + process_started_at=process_started_at, + normalized_chat_type=normalized_chat_type, + ) + if gate_capture is not None: + return gate_capture + + return await self._process_message_default_capture( + message=message, + message_content=message_content, + sender=sender, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + feishu_message_id=feishu_message_id, + captured_at=captured_at, + inferred_project_id=inferred_project_id, + projects=projects, + process_started_at=process_started_at, + normalized_chat_type=normalized_chat_type, + chat_context_resolution=chat_context_resolution, + ) + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_process_message_default.py b/backend/app/services/feishu_signal_pipeline/mixin_process_message_default.py new file mode 100644 index 00000000..15ae83c4 --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_process_message_default.py @@ -0,0 +1,256 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * + + +class FeishuPipelineProcessMessageDefaultMixin: + async def _process_message_default_capture( + self, + *, + message: str, + message_content: str, + sender: str, + source_chat_id: str, + source_sender_open_id: Optional[str], + feishu_message_id: Optional[str], + captured_at: Optional[datetime], + inferred_project_id: Optional[int], + projects: list[dict[str, Any]], + process_started_at: float, + normalized_chat_type: str, + chat_context_resolution: dict[str, Any], + ) -> dict[str, Any]: + rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) + conversation_key = self._build_rag_conversation_key( + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + sender=sender, + ) + + sidecar_rag_result: Optional[dict[str, Any]] = None + intent_for_response = "statement" + force_signal_fastpath = False + rag_question_part = message + + if self._is_obvious_question(message): + rag_result = await rag_service.answer_question( + question=rag_question_part, + asked_by=None, + asked_via="feishu", + project_id=inferred_project_id, + source_chat_id=source_chat_id, + conversation_key=conversation_key, + ) + if self._is_requirement_like_question(message): + sidecar_rag_result = rag_result + intent_for_response = "question_requirement" + message = self._convert_requirement_question_to_signal_text( + message=message, + fallback_content=message_content, + ) + force_signal_fastpath = True + else: + return await self._finalize_direct_qa_capture_result( + message=message, + rag_result=rag_result, + inferred_project_id=inferred_project_id, + feishu_message_id=feishu_message_id, + process_started_at=process_started_at, + route_reason="明显问句直接走 RAG 直答通道,不进入决策队列。", + intent_label="question_obvious", + normalized_chat_type=normalized_chat_type, + ) + else: + intent_result = await rag_service.classify_intent(message) + intent = str(intent_result.get("intent") or "statement").lower() + intent_for_response = intent + rag_question_part = str(intent_result.get("question_part") or "").strip() or message + rag_statement_part = str(intent_result.get("statement_part") or "").strip() + + if intent == "question": + rag_result = await rag_service.answer_question( + question=rag_question_part, + asked_by=None, + asked_via="feishu", + project_id=inferred_project_id, + source_chat_id=source_chat_id, + conversation_key=conversation_key, + ) + if self._is_requirement_like_question(message): + sidecar_rag_result = rag_result + intent_for_response = "question_requirement" + message = self._convert_requirement_question_to_signal_text( + message=message, + fallback_content=message_content, + ) + force_signal_fastpath = True + else: + return await self._finalize_direct_qa_capture_result( + message=message, + rag_result=rag_result, + inferred_project_id=inferred_project_id, + feishu_message_id=feishu_message_id, + process_started_at=process_started_at, + route_reason="识别为问答消息,已走 RAG 直答通道,不进入决策队列。", + intent_label=intent, + normalized_chat_type=normalized_chat_type, + ) + + if intent == "mixed": + mixed_rag_result = await rag_service.answer_question( + question=rag_question_part, + asked_by=None, + asked_via="feishu", + project_id=inferred_project_id, + source_chat_id=source_chat_id, + conversation_key=conversation_key, + ) + if rag_statement_part: + message = rag_statement_part + sidecar_rag_result = mixed_rag_result + + try: + if force_signal_fastpath: + level_result = self._heuristic_level_fallback(message) + else: + level_result = await self._classify_level(message=message, projects=projects) + project_score = _safe_float(level_result.get("project_score"), 0.0) + level = _normalize_level_value( + level_result.get("level"), + default="project" if project_score >= 0.6 else "signal", + ) + level_confidence = _safe_float(level_result.get("level_confidence"), project_score) + level_reasoning = str(level_result.get("level_reasoning") or "").strip() + + split_items = [] + seen_split_items: set[tuple[str, str]] = set() + raw_split_items = level_result.get("split_items") + should_split = bool(level_result.get("should_split")) + if should_split and isinstance(raw_split_items, list): + for item in raw_split_items[:2]: + if not isinstance(item, dict): + continue + item_level = _normalize_level_value(item.get("level"), default=level) + item_content = str(item.get("content") or "").strip() + if item_level in {"project", "signal"} and item_content: + dedupe_key = (item_level, item_content) + if dedupe_key in seen_split_items: + continue + seen_split_items.add(dedupe_key) + split_items.append({"level": item_level, "content": item_content}) + if not split_items: + split_items = [{"level": level if level in {"project", "signal"} else "signal", "content": message}] + + routing_policy = await self._load_routing_policy() + occurred_at = captured_at or _utc_now() + if occurred_at.tzinfo is None: + occurred_at = occurred_at.replace(tzinfo=CN_TZ) + + results: list[dict[str, Any]] = [] + parent_item_id: Optional[int] = None + for idx, split in enumerate(split_items): + item_result = await self._process_one_item( + content=split["content"], + level=split["level"], + sender=sender, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + feishu_message_id=feishu_message_id, + occurred_at=occurred_at, + level_result=level_result, + level_confidence=level_confidence, + level_reasoning=level_reasoning, + project_score=project_score, + cold_start_enabled=bool(routing_policy["cold_start_enabled"]), + signal_auto_enter_threshold=float(routing_policy["signal_auto_enter_threshold"]), + project_auto_create_threshold=float(routing_policy["project_auto_create_threshold"]), + project_uncertain_threshold=float(routing_policy["project_uncertain_threshold"]), + chat_context_auto_link_enabled=bool(routing_policy["chat_context_auto_link_enabled"]), + projects=projects, + split_from_item_id=parent_item_id if idx > 0 else None, + chat_type=normalized_chat_type, + chat_context_resolution=chat_context_resolution, + ) + if idx == 0 and item_result.get("item_id"): + parent_item_id = int(item_result["item_id"]) + results.append(item_result) + + await self.db.commit() + + signal_ids = [int(x["signal_id"]) for x in results if x.get("signal_id")] + linked_projects = sorted( + { + int(x["project_id"]) + for x in results + if x.get("project_id") is not None + } + ) + classification = "split" if len(results) > 1 else (results[0].get("level") or "signal") + clarification_message = next( + ( + str(row.get("clarification_message") or "").strip() + for row in results + if isinstance(row, dict) and str(row.get("clarification_message") or "").strip() + ), + "", + ) + direct_reply_text: Optional[str] = None + if sidecar_rag_result: + sidecar_answer = str(sidecar_rag_result.get("answer") or "").strip() + if sidecar_answer: + direct_reply_text = ( + self._build_rag_reply_text( + answer=sidecar_answer, + sources=sidecar_rag_result.get("sources") or [], + ) + + "\n\n我已回答你的问题,并把需求部分记录为信号进入处理队列。" + ) + return self._decorate_capture_result( + { + "status": "captured", + "classification": classification, + "project_scope": classification, + "linked_project_ids": linked_projects, + "signal_id": str(signal_ids[0]) if signal_ids else None, + "created_project_id": linked_projects[0] if linked_projects else None, + "auto_tasks": [], + "ai_trace_id": f"feishu-item-{results[0].get('item_id')}" if results else None, + "results": results, + "intent": intent_for_response, + "rag_result": sidecar_rag_result, + "direct_reply_required": bool(direct_reply_text), + "direct_reply_text": direct_reply_text, + "clarification_message": clarification_message or None, + }, + chat_type=normalized_chat_type, + normalized_text=message, + route=f"{normalized_chat_type}.{classification}", + db_mutations=self._summarize_db_mutations( + { + "results": results, + "signal_id": str(signal_ids[0]) if signal_ids else None, + "created_project_id": linked_projects[0] if linked_projects else None, + } + ), + ) + except IntegrityError as exc: + duplicate_capture = await self._handle_default_capture_integrity_error( + exc=exc, + message=message, + feishu_message_id=feishu_message_id, + process_started_at=process_started_at, + normalized_chat_type=normalized_chat_type, + ) + if duplicate_capture is not None: + return duplicate_capture + raise + except Exception as exc: + await self._handle_default_capture_unexpected_error( + exc=exc, + message=message, + feishu_message_id=feishu_message_id, + process_started_at=process_started_at, + ) + raise + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_process_message_helpers_a.py b/backend/app/services/feishu_signal_pipeline/mixin_process_message_helpers_a.py new file mode 100644 index 00000000..94c25775 --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_process_message_helpers_a.py @@ -0,0 +1,204 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * + + +class FeishuPipelineProcessMessageHelpersAMixin: + async def _handle_default_capture_integrity_error( + self, + *, + exc: IntegrityError, + message: str, + feishu_message_id: Optional[str], + process_started_at: float, + normalized_chat_type: str, + ) -> Optional[dict[str, Any]]: + await self.db.rollback() + duration_ms = int((time.perf_counter() - process_started_at) * 1000) + if feishu_message_id and self._is_duplicate_integrity_error(exc): + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=None, + raw_message=message, + ai_raw_output=None, + project_score=None, + signal_confidence=None, + level=None, + routing_decision=ROUTING_DUPLICATE_FILTERED, + routing_reason="唯一约束命中,判定为重复飞书消息,已跳过重复处理。", + reasoning_bullets=[ + "数据库唯一约束阻止了重复入库。", + "当前请求被收敛到 duplicate 模板回执。", + "后续不再触发 AI 分类与入库动作。", + ], + processing_status="success", + error_message=None, + duration_ms=duration_ms, + ) + await self.db.commit() + return self._decorate_capture_result( + { + "status": "ignored", + "reason": ROUTING_DUPLICATE_FILTERED, + "classification": "duplicate", + "project_scope": "duplicate", + "linked_project_ids": [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": f"feishu-duplicate-{feishu_message_id}", + "results": [], + }, + chat_type=normalized_chat_type, + normalized_text=message, + route=f"{normalized_chat_type}.duplicate_integrity", + db_mutations=[], + ) + + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=None, + raw_message=message, + ai_raw_output=None, + project_score=None, + signal_confidence=None, + level=None, + routing_decision=ROUTING_FAILED, + routing_reason="数据库完整性校验失败,已停止当前消息处理。", + reasoning_bullets=[ + f"异常类型:{exc.__class__.__name__}", + "可能原因:唯一约束/外键约束冲突。", + "请结合 SQL 日志排查数据一致性问题。", + ], + processing_status="failed", + error_message=str(exc)[:2000] or "integrity_error", + duration_ms=duration_ms, + ) + await self.db.commit() + return None + + async def _handle_default_capture_unexpected_error( + self, + *, + exc: Exception, + message: str, + feishu_message_id: Optional[str], + process_started_at: float, + ) -> None: + await self.db.rollback() + duration_ms = int((time.perf_counter() - process_started_at) * 1000) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=None, + raw_message=message, + ai_raw_output=None, + project_score=None, + signal_confidence=None, + level=None, + routing_decision=ROUTING_FAILED, + routing_reason="AI 处理链路执行失败,已停止当前消息处理并写入错误日志。", + reasoning_bullets=[ + f"异常类型:{exc.__class__.__name__}", + f"异常信息:{str(exc)[:120] or '未知错误'}", + "请在日志详情中查看完整错误并重试。", + ], + processing_status="failed", + error_message=str(exc)[:2000] or "unknown_error", + duration_ms=duration_ms, + ) + await self.db.commit() + + @staticmethod + def _is_duplicate_integrity_error(exc: IntegrityError) -> bool: + message = str(exc or "").lower() + if "feishu_items" not in message: + return False + duplicate_hints = ( + "uq_feishu_items_tenant_message_root", + "uq_feishu_items_tenant_message_split", + "unique constraint", + "duplicate key value", + "is not unique", + ) + return any(hint in message for hint in duplicate_hints) + + @staticmethod + def _build_rag_reply_text(*, answer: str, sources: list[dict[str, Any]]) -> str: + clean_answer = str(answer or "").strip() or "我未找到足够证据,建议补充更多上下文。" + labels: list[str] = [] + deep_link = "" + for source in sources[:4]: + if not isinstance(source, dict): + continue + label = str(source.get("label") or source.get("source") or "").strip() + if label and label not in labels: + labels.append(label) + if not deep_link: + meta = source.get("metadata") if isinstance(source.get("metadata"), dict) else {} + target_url = str(meta.get("target_url") or "").strip() + if not target_url: + project_id = meta.get("project_id") + if project_id is not None: + try: + target_url = f"/projects/{int(project_id)}/board" + except Exception: + target_url = "" + if target_url.startswith("/"): + deep_link = target_url + source_text = " / ".join(labels) if labels else "未命中可引用来源" + if deep_link: + return f"{clean_answer}\n\n来源: {source_text}\nView details → {deep_link}" + return f"{clean_answer}\n\n来源: {source_text}" + + @staticmethod + def _normalize_chat_type(*, chat_type: Optional[str], source_chat_id: str) -> str: + text = str(chat_type or "").strip().lower() + if text in {"p2p", "single", "single_chat", "dm", "direct"}: + return "p2p" + if text in {"group", "chat", "group_chat"}: + return "group" + return "group" if source_chat_id else "p2p" + + @staticmethod + def _normalize_incoming_text(message_content: Optional[str]) -> str: + text = str(message_content or "").replace("\r\n", "\n").replace("\r", "\n") + if not text: + return "" + text = FEISHU_BOT_MENTION_RE.sub(" ", text) + text = FEISHU_QUOTE_LINE_RE.sub(" ", text) + text = FEISHU_QUOTE_PREFIX_RE.sub("", text) + text = FEISHU_NOISE_TOKEN_RE.sub(" ", text) + text = re.sub(r"@所有人|@everyone", " ", text, flags=re.IGNORECASE) + text = re.sub(r"[ \t]+", " ", text) + text = re.sub(r"\n{2,}", "\n", text) + text = re.sub(r"\s+", " ", text).strip() + return text[:2000] + + @classmethod + def _is_obvious_question(cls, message: str) -> bool: + text = str(message or "").strip() + if not text: + return False + if cls._is_query_like_question(message): + return True + if cls._is_legacy_business_command(text): + return False + if cls._looks_like_project_lookup_question(text): + return True + return bool(OBVIOUS_QUESTION_RE.search(text)) + + @classmethod + def _is_query_like_question(cls, message: str) -> bool: + text = str(message or "").strip() + if not text: + return False + normalized = re.sub(r"\s+", "", text.lower()) + has_question_structure = bool(OBVIOUS_QUESTION_RE.search(text) or QUERY_INTENT_RE.search(text)) + if not has_question_structure: + return False + has_operation_verb = bool(LEGACY_COMMAND_VERB_RE.search(text)) + imperative_tokens = ("帮我", "请", "麻烦", "直接", "立刻", "马上", "执行", "处理", "安排") + has_imperative = any(token in normalized for token in imperative_tokens) + return not has_operation_verb and not has_imperative + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_process_message_helpers_b.py b/backend/app/services/feishu_signal_pipeline/mixin_process_message_helpers_b.py new file mode 100644 index 00000000..c1498566 --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_process_message_helpers_b.py @@ -0,0 +1,189 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * + + +class FeishuPipelineProcessMessageHelpersBMixin: + @staticmethod + def _decorate_capture_result( + result: dict[str, Any], + *, + chat_type: str, + normalized_text: str, + route: str, + db_mutations: list[dict[str, Any]], + ) -> dict[str, Any]: + payload = dict(result or {}) + direct_reply_text = str(payload.get("direct_reply_text") or "").strip() + if direct_reply_text: + payload["direct_reply_text"] = FeishuSignalPipelineService._normalize_direct_reply_text(direct_reply_text) + clarification_message = str(payload.get("clarification_message") or "").strip() + if clarification_message: + payload["clarification_message"] = FeishuSignalPipelineService._normalize_direct_reply_text(clarification_message) + followup_reply = str(payload.get("followup_reply") or "").strip() + if followup_reply: + payload["followup_reply"] = FeishuSignalPipelineService._normalize_direct_reply_text(followup_reply) + payload["chat_type"] = chat_type + payload["normalized_text"] = normalized_text + payload["route"] = route + payload["db_mutations"] = db_mutations + payload.setdefault("results", []) + return payload + + @staticmethod + def _normalize_direct_reply_text(text: str) -> str: + cleaned = str(text or "").strip() + if cleaned.startswith("```"): + cleaned = cleaned.replace("```", "").strip() + if cleaned.lower().startswith("json"): + cleaned = cleaned[4:].strip() + if not cleaned: + return "系统处理中出现异常,本次未执行。请稍后重试,或补充更明确的目标、对象和动作。" + lowered = cleaned.lower() + provider_error_signals = ( + "request was aborted", + "request aborted", + "connection aborted", + "operation aborted", + "context canceled", + "context cancelled", + "cancelled", + "canceled", + "rate limit", + "too many requests", + "quota", + "insufficient_quota", + "insufficient quota", + "authentication failed", + "service unavailable", + "upstream error", + "bad gateway", + "gateway timeout", + "internal server error", + "connection reset", + "econnreset", + "socket hang up", + ) + if any(flag in lowered for flag in provider_error_signals): + return "系统处理中出现异常,本次未执行。请稍后重试,或补充更明确的目标、对象和动作。" + return cleaned[:500] + + @staticmethod + def _summarize_db_mutations(result: dict[str, Any]) -> list[dict[str, Any]]: + if not isinstance(result, dict): + return [] + mutations: list[dict[str, Any]] = [] + command_result = result.get("command_result") if isinstance(result.get("command_result"), dict) else None + execution = command_result.get("execution") if isinstance(command_result, dict) else None + execution_results = execution.get("results") if isinstance(execution, dict) else None + if isinstance(execution_results, list): + for row in execution_results[:6]: + if not isinstance(row, dict): + continue + mutations.append( + { + "operation_code": str(row.get("operation_code") or ""), + "status": str(row.get("status") or ""), + "entity_type": str(row.get("entity_type") or ""), + "entity_id": str(row.get("entity_id") or ""), + } + ) + if mutations: + return mutations + + rows = result.get("results") + if isinstance(rows, list): + for row in rows[:6]: + if not isinstance(row, dict): + continue + mutations.append( + { + "item_id": row.get("item_id"), + "level": row.get("level"), + "route_status": row.get("route_status"), + "project_id": row.get("project_id"), + "signal_id": row.get("signal_id"), + } + ) + return mutations + + async def _finalize_direct_qa_capture_result( + self, + *, + message: str, + rag_result: dict[str, Any], + inferred_project_id: Optional[int], + feishu_message_id: Optional[str], + process_started_at: float, + route_reason: str, + intent_label: str, + normalized_chat_type: str, + ) -> dict[str, Any]: + qa_capture = await self._build_direct_qa_capture( + message=message, + rag_result=rag_result, + inferred_project_id=inferred_project_id, + feishu_message_id=feishu_message_id, + process_started_at=process_started_at, + route_reason=route_reason, + intent_label=intent_label, + ) + await self.db.commit() + return self._decorate_capture_result( + qa_capture, + chat_type=normalized_chat_type, + normalized_text=message, + route=f"{normalized_chat_type}.qa_direct", + db_mutations=[], + ) + + async def _build_direct_qa_capture( + self, + *, + message: str, + rag_result: dict[str, Any], + inferred_project_id: Optional[int], + feishu_message_id: Optional[str], + process_started_at: float, + route_reason: str, + intent_label: str, + ) -> dict[str, Any]: + duration_ms = int((time.perf_counter() - process_started_at) * 1000) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=None, + raw_message=message, + ai_raw_output=json.dumps({"intent": intent_label, "rag": rag_result}, ensure_ascii=False)[:4000], + project_score=None, + signal_confidence=None, + level="question", + routing_decision=ROUTING_DIRECT_QA, + routing_reason=route_reason, + reasoning_bullets=[ + f"intent={intent_label}", + "命中 question 路由,跳过 signal/project 处理。", + "回答已记录 QA 训练数据。", + ], + processing_status="success", + error_message=None, + duration_ms=duration_ms, + ) + return { + "status": "answered", + "classification": "question", + "project_scope": "question", + "linked_project_ids": [int(inferred_project_id)] if inferred_project_id is not None else [], + "signal_id": None, + "created_project_id": int(inferred_project_id) if inferred_project_id is not None else None, + "auto_tasks": [], + "ai_trace_id": f"feishu-qa-{(feishu_message_id or 'noid')[:32]}", + "results": [], + "rag_result": rag_result, + "direct_reply_required": True, + "direct_reply_text": self._build_rag_reply_text( + answer=str(rag_result.get("answer") or ""), + sources=rag_result.get("sources") or [], + ), + } + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_queue_confirm_undo.py b/backend/app/services/feishu_signal_pipeline/mixin_queue_confirm_undo.py new file mode 100644 index 00000000..fc6f8512 --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_queue_confirm_undo.py @@ -0,0 +1,194 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * +from . import shared as _pipeline + + +class FeishuPipelineQueueConfirmUndoMixin: + async def confirm_item(self, *, item_id: int, user_id: int, action: Optional[str] = None) -> dict[str, Any]: + item = await self._get_item(item_id) + if not item: + raise ValueError("item_not_found") + + level = (item.level or "").lower() + result: dict[str, Any] = {"id": str(item.id), "level": level} + if level == "project": + suggestion = await self._get_project_suggestion(item.id) + if not suggestion: + raise ValueError("project_suggestion_not_found") + candidate_fields = self._resolve_project_candidate_fields(item=item, suggestion=suggestion) + if not candidate_fields["ready"]: + raise ValueError( + str(item.blocking_reason or "").strip() + or self._build_project_clarification_message(candidate_fields["missing_fields"]) + ) + operator_user = await self._load_active_user_by_id(user_id=int(user_id)) + if operator_user is None: + raise ValueError("operator_not_found") + execution = await self._execute_controlled_operation( + operator_user=operator_user, + operation_code="project.create", + payload={ + "title": candidate_fields["title"], + "description": candidate_fields["description"], + }, + trace_id=f"confirm-project-{int(item.id)}-{int(user_id)}", + ) + first = execution["results"][0] if execution.get("results") else {} + status = str(first.get("status") or "").strip().lower() + if status not in {"applied", "skipped"}: + raise ValueError(str(first.get("message") or "project_create_failed")) + project_result = first.get("result") if isinstance(first.get("result"), dict) else {} + project_id = _pipeline._safe_int(project_result.get("project_id") or first.get("entity_id")) + suggestion.status = "confirmed" + suggestion.created_project_id = int(project_id) if project_id is not None else None + item.matched_project_id = int(project_id) if project_id is not None else item.matched_project_id + item.result_payload = { + "intent": _pipeline.INTENT_CREATE_PROJECT, + "executor": "explicit_service", + "operation_code": "project.create", + "result_summary": f"已创建项目 #{project_id}" if project_id else "已创建项目", + "operation_result": project_result, + "target_url": f"/projects/{project_id}/board" if project_id else None, + "project_id": project_id, + } + result["project_id"] = str(project_id) if project_id is not None else None + elif level == "command": + operator_user = await self._load_active_user_by_id(user_id=int(user_id)) + if operator_user is None: + raise ValueError("operator_not_found") + payload = item.result_payload if isinstance(item.result_payload, dict) else {} + operation_code = str(payload.get("operation_code") or "").strip() + operation_payload = payload.get("operation_payload") if isinstance(payload.get("operation_payload"), dict) else {} + if operation_code not in {"task.create"}: + raise ValueError("command_operation_not_supported") + execution = await self._execute_controlled_operation( + operator_user=operator_user, + operation_code=operation_code, + payload=operation_payload, + trace_id=f"confirm-command-{int(item.id)}-{int(user_id)}", + ) + first = execution["results"][0] if execution.get("results") else {} + status = str(first.get("status") or "").strip().lower() + if status not in {"applied", "skipped"}: + raise ValueError(str(first.get("message") or "command_execution_failed")) + project_id = _pipeline._safe_int(operation_payload.get("project_id") or payload.get("project_id") or item.matched_project_id) + task_id = _pipeline._safe_int(first.get("entity_id")) + item.matched_project_id = int(project_id) if project_id is not None else item.matched_project_id + item.result_payload = { + **payload, + "executor": "explicit_service", + "result_summary": f"已创建任务 #{task_id}" if task_id is not None else "已创建任务", + "operation_result": first.get("result") if isinstance(first.get("result"), dict) else {}, + "target_url": f"/projects/{project_id}/board" if project_id else None, + "project_id": project_id, + } + result["project_id"] = str(project_id) if project_id is not None else None + result["task_id"] = str(task_id) if task_id is not None else None + else: + signal = await self._get_signal_by_item(item.id) + if not signal: + raise ValueError("signal_not_found") + matched_project_id = signal.matched_project_id or item.matched_project_id + if not matched_project_id: + raise ValueError("signal_missing_project") + await self._ensure_signal_link(signal_id=int(signal.id), project_id=int(matched_project_id)) + signal.processed = False + signal.status = "待处理" + if action in {"create_charter", "trigger_alert"}: + signal.recommended_action = action + result["signal_id"] = str(signal.id) + result["project_id"] = str(matched_project_id) + + item.route_status = _pipeline.ROUTE_STATUS_AUTO_DONE + item.queue_visible = False + item.processed_at = _pipeline._utc_now().replace(tzinfo=None) + item.message_class = "auto_done" + item.safe_to_auto_execute = False + item.auto_execute_state = "done" + item.blocking_reason = None + item.next_step_text = "你已授权继续执行,结果已落地,可进入结果页查看。" + item.undoable_until = _pipeline._build_undo_deadline(item.processed_at) if level in {"project", "signal"} else None + if level == "project": + created_project_id = _pipeline._safe_int(result.get("project_id")) + item.result_payload = { + **(item.result_payload if isinstance(item.result_payload, dict) else {}), + "result_count": 1, + "impact_objects": [f"项目#{created_project_id}"] if created_project_id else [], + } + elif level == "signal": + project_id = _pipeline._safe_int(result.get("project_id")) + signal_id = _pipeline._safe_int(result.get("signal_id")) + item.result_payload = { + "result_summary": ( + f"已确认信号 #{signal_id} 并关联项目 #{project_id}" if project_id and signal_id else "已确认信号并完成入库" + ), + "result_count": 1, + "impact_objects": [f"信号#{signal_id}"] if signal_id else [], + "target_url": f"/projects/{project_id}/board" if project_id else None, + "project_id": project_id, + } + else: + payload = item.result_payload if isinstance(item.result_payload, dict) else {} + item.result_payload = { + **payload, + "result_count": 1, + } + await self.db.commit() + return result + + async def undo_auto_processed(self, *, item_id: int) -> dict[str, Any]: + item = await self._get_item(item_id) + if not item: + raise ValueError("item_not_found") + if self._normalize_route_status(item.route_status) != _pipeline.ROUTE_STATUS_AUTO_DONE: + raise ValueError("item_not_auto_processed") + + level = (item.level or "").lower() + if level == "project": + suggestion = await self._get_project_suggestion(item.id) + if suggestion and suggestion.created_project_id: + project = ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == int(suggestion.created_project_id), + ) + ) + ) + ).scalar_one_or_none() + if project: + project.status = "inactive" + if suggestion: + suggestion.status = "pending" + elif level == "signal": + signal = await self._get_signal_by_item(item.id) + if signal: + await self.db.execute( + delete(ProjectSignalLink).where( + and_( + ProjectSignalLink.tenant_id == self.tenant_id, + ProjectSignalLink.signal_id == int(signal.id), + ) + ) + ) + signal.status = "待处理" + signal.processed = False + + item.route_status = _pipeline.ROUTE_STATUS_UNDONE + item.queue_visible = False + item.processed_at = _pipeline._utc_now().replace(tzinfo=None) + item.message_class = "auto_done" + item.auto_execute_state = "undone" + item.undoable_until = None + existing_payload = item.result_payload if isinstance(item.result_payload, dict) else {} + item.result_payload = { + **existing_payload, + "result_summary": "该结果已撤销", + "undone": True, + } + await self.db.commit() + return {"id": str(item.id), "status": item.route_status} + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_queue_read_write.py b/backend/app/services/feishu_signal_pipeline/mixin_queue_read_write.py new file mode 100644 index 00000000..be5abd25 --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_queue_read_write.py @@ -0,0 +1,183 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * +from . import shared as _pipeline + + +class FeishuPipelineQueueReadWriteMixin: + async def list_queue(self, *, limit: int = 30) -> dict[str, Any]: + routing_policy = await self._load_routing_policy() + pending_rows = ( + await self.db.execute( + select(FeishuItem) + .where( + and_( + FeishuItem.tenant_id == self.tenant_id, + FeishuItem.queue_visible.is_(True), + FeishuItem.route_status.in_((_pipeline.ROUTE_STATUS_QUEUED, "pending")), + ) + ) + .order_by(FeishuItem.created_at.desc()) + .limit(limit) + ) + ).scalars().all() + pending_rows = self._dedupe_queue_items(pending_rows) + pending_rows = [row for row in pending_rows if not self._should_hide_pending_item_from_inbox(row)] + + # DB 列是 timestamp without time zone,比较值必须使用 naive 北京时间,避免 asyncpg aware/naive 报错。 + today_start = _pipeline._utc_now().astimezone(_pipeline.CN_TZ).replace( + hour=0, + minute=0, + second=0, + microsecond=0, + tzinfo=None, + ) + auto_rows = ( + await self.db.execute( + select(FeishuItem) + .where( + and_( + FeishuItem.tenant_id == self.tenant_id, + FeishuItem.route_status.in_( + (_pipeline.ROUTE_STATUS_AUTO_DONE, _pipeline.ROUTE_STATUS_UNDONE, "auto_processed", "confirmed") + ), + FeishuItem.processed_at.is_not(None), + FeishuItem.processed_at >= today_start, + ) + ) + .order_by(FeishuItem.processed_at.desc()) + .limit(limit) + ) + ).scalars().all() + auto_rows = self._dedupe_queue_items(auto_rows) + auto_rows = [row for row in auto_rows if not self._is_water_talk_message(row.raw_message or "")] + + pending_items = [await self._serialize_item(row) for row in pending_rows] + pending_items.sort( + key=lambda item: ( + -int(item.get("priority_rank") or 0), + str(item.get("created_at") or ""), + ) + ) + auto_items = [await self._serialize_item(row) for row in auto_rows] + + signal_auto = len([item for item in auto_items if item.get("level") == "signal"]) + project_auto = len( + [ + item + for item in auto_items + if item.get("level") == "project" + or ( + item.get("level") == "command" + and isinstance(item.get("command"), dict) + and str(item["command"].get("operation_code") or "") == "project.create" + ) + ] + ) + return { + "pending_count": len(pending_items), + "pending_items": pending_items, + "auto_processed_items": auto_items, + "auto_summary": { + "signals_auto_entered_today": signal_auto, + "projects_auto_created_today": project_auto, + }, + "cold_start": bool(routing_policy["cold_start_enabled"]), + } + + async def get_item_detail(self, item_id: int) -> dict[str, Any]: + item = await self._get_item(item_id) + if not item: + raise ValueError("item_not_found") + return await self._serialize_item(item, include_raw=True, include_history=True) + + async def update_item(self, *, + item_id: int, + payload: dict[str, Any], + user_id: int, + ) -> dict[str, Any]: + item = await self._get_item(item_id) + if not item: + raise ValueError("item_not_found") + + changed = False + if (item.level or "").lower() == "project": + suggestion = await self._get_project_suggestion(item.id) + if not suggestion: + raise ValueError("project_suggestion_not_found") + for field in ("suggested_name", "suggested_description", "suggested_owner", "suggested_timeline"): + if field in payload: + new_value = str(payload.get(field) or "").strip() or None + if getattr(suggestion, field) != new_value: + setattr(suggestion, field, new_value) + changed = True + if changed: + suggestion.status = "pending" + else: + signal = await self._get_signal_by_item(item.id) + if not signal: + raise ValueError("signal_not_found") + editable_fields = ( + "title", + "signal_type", + "priority", + "summary", + "recommended_action", + "matched_project_id", + ) + for field in editable_fields: + if field not in payload: + continue + old_value = getattr(signal, field) + raw_new = payload.get(field) + if field == "matched_project_id": + new_value = int(raw_new) if raw_new is not None else None + else: + new_value = str(raw_new).strip() if raw_new is not None else None + if new_value == "": + new_value = None + if old_value == new_value: + continue + setattr(signal, field, new_value) + self.db.add( + SignalEdit( + tenant_id=self.tenant_id, + signal_id=int(signal.id), + field=field, + old_value=None if old_value is None else str(old_value), + new_value=None if new_value is None else str(new_value), + edited_by=int(user_id), + edited_at=_pipeline._utc_now().replace(tzinfo=None), + ) + ) + changed = True + + if changed: + await self.db.commit() + return await get_item_detail(service, item_id) + + async def dismiss_item(self, *, item_id: int, reason: str) -> dict[str, Any]: + item = await self._get_item(item_id) + if not item: + raise ValueError("item_not_found") + item.route_status = _pipeline.ROUTE_STATUS_DISMISSED + item.queue_visible = False + item.dismissed_reason = (reason or "").strip()[:300] or "用户手动忽略" + item.processed_at = _pipeline._utc_now().replace(tzinfo=None) + item.message_class = "ignored" + item.auto_execute_state = "dismissed" + + if (item.level or "").lower() == "project": + suggestion = await self._get_project_suggestion(item.id) + if suggestion: + suggestion.status = "dismissed" + else: + signal = await self._get_signal_by_item(item.id) + if signal: + signal.processed = True + signal.status = "已关闭" + + await self.db.commit() + return {"id": str(item.id), "status": item.route_status} + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_routing_notify.py b/backend/app/services/feishu_signal_pipeline/mixin_routing_notify.py new file mode 100644 index 00000000..17170d2d --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_routing_notify.py @@ -0,0 +1,266 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * + + +class FeishuPipelineRoutingNotifyMixin: + async def _load_routing_policy(self) -> dict[str, float | bool]: + integration = ( + await self.db.execute( + select(Integration).where( + and_( + Integration.tenant_id == self.tenant_id, + Integration.type == "feishu", + ) + ) + ) + ).scalar_one_or_none() + config: dict[str, Any] = {} + if integration is not None: + if isinstance(integration.config, dict): + config = integration.config + elif isinstance(integration.config, str): + config = _safe_json_object(integration.config) + + cold_start_enabled = bool(config.get("cold_start_enabled")) + chat_context_auto_link_enabled = bool(config.get("chat_context_auto_link_enabled", False)) + + signal_auto_enter_threshold = min( + 1.0, + max( + 0.0, + _safe_float( + config.get("signal_auto_enter_threshold"), + DEFAULT_SIGNAL_AUTO_ENTER_THRESHOLD, + ), + ), + ) + project_auto_create_threshold = min( + 1.0, + max( + 0.0, + _safe_float( + config.get("project_auto_create_threshold"), + DEFAULT_PROJECT_AUTO_CREATE_THRESHOLD, + ), + ), + ) + project_uncertain_threshold = min( + project_auto_create_threshold, + max( + 0.0, + _safe_float( + config.get("project_uncertain_threshold"), + DEFAULT_PROJECT_UNCERTAIN_THRESHOLD, + ), + ), + ) + return { + "cold_start_enabled": cold_start_enabled, + "signal_auto_enter_threshold": signal_auto_enter_threshold, + "project_auto_create_threshold": project_auto_create_threshold, + "project_uncertain_threshold": project_uncertain_threshold, + "chat_context_auto_link_enabled": chat_context_auto_link_enabled, + } + + async def _load_active_projects(self) -> list[dict[str, Any]]: + rows = ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == self.tenant_id, + Project.status != "inactive", + ) + ) + ) + ).scalars().all() + return [ + { + "id": int(p.id), + "name": p.name, + "title": p.title or p.name, + "status": p.status, + "description": p.description or "", + } + for p in rows + ] + + async def _create_project_from_suggestion( + self, + suggestion: ProjectSuggestion, + *, + actor_user_id: Optional[int], + ) -> Project: + owner_id = await self._resolve_user_id_by_name(suggestion.suggested_owner) or actor_user_id + project_name = (suggestion.suggested_name or "新项目").strip()[:255] or "新项目" + project_desc = (suggestion.suggested_description or "").strip()[:4000] or None + await ensure_project_id_sequence(self.db) + project = Project( + tenant_id=self.tenant_id, + name=project_name, + title=project_name, + description=project_desc, + owner_id=owner_id, + status="active", + domain_id=None, + assigned_role="ai_engineer", + signal_count=0, + ) + self.db.add(project) + await self.db.flush() + await ensure_project_default_members( + self.db, + tenant_id=self.tenant_id, + project_id=int(project.id), + owner_id=int(project.owner_id) if project.owner_id is not None else None, + assigned_role=project.assigned_role, + ) + return project + + async def _ensure_signal_link(self, *, signal_id: int, project_id: int) -> None: + existed = ( + await self.db.execute( + select(ProjectSignalLink.id).where( + and_( + ProjectSignalLink.tenant_id == self.tenant_id, + ProjectSignalLink.project_id == project_id, + ProjectSignalLink.signal_id == signal_id, + ) + ) + ) + ).scalar_one_or_none() + if existed is not None: + return + row = ProjectSignalLink( + tenant_id=self.tenant_id, + project_id=int(project_id), + signal_id=int(signal_id), + link_type="feishu_signal", + relevance_score=0.8, + ) + self.db.add(row) + + async def _notify_project_created(self, *, project: Project) -> None: + if not project.owner_id: + return + project_name = project.title or project.name or f"项目#{project.id}" + await NotificationService(self.db).create_notification( + tenant_id=self.tenant_id, + user_id=int(project.owner_id), + category="requirement", + title=f"创建项目:{project_name}", + content=f"AI 已根据飞书消息自动创建项目 [{project_name}],可直接进入 SSTC 看板。", + source="feishu", + entity_type="project", + entity_id=int(project.id), + target_url=f"/projects/{project.id}/board", + kind="auto_done", + priority="high", + status="auto_done", + recommended_action="view_result", + dedupe_key=f"feishu-project-auto:{int(project.id)}", + payload={ + "message_class": "auto_done", + "summary": f"AI 已自动创建项目 {project_name}", + "ai_plan": "下一步可进入项目看板补齐 charter 与执行任务。", + "project_id": int(project.id), + "project_name": project_name, + "result_count": 1, + "impact_objects": [project_name], + "can_undo": True, + "undo_deadline": _build_undo_deadline(_utc_now().replace(tzinfo=None)).isoformat(), + }, + commit=False, + ) + + async def _notify_signal_auto_entered(self, *, project_id: int, signal: Signal) -> None: + project = ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == project_id, + ) + ) + ) + ).scalar_one_or_none() + if not project or not project.owner_id: + return + project_name = project.title or project.name or f"项目#{project.id}" + await NotificationService(self.db).create_notification( + tenant_id=self.tenant_id, + user_id=int(project.owner_id), + category="requirement", + title=f"信号已自动入库:{signal.title or '未命名信号'}", + content="高置信度信号已自动进入项目信号源,可直接继续生成宪章。", + source="feishu", + entity_type="signal", + entity_id=int(signal.id), + target_url=f"/projects/{project_id}/board", + kind="auto_done", + priority=str(signal.priority or "medium"), + status="auto_done", + recommended_action="view_result", + dedupe_key=f"feishu-signal-auto:{int(signal.id)}:{int(project_id)}", + payload={ + "message_class": "auto_done", + "summary": f"AI 已把信号 {signal.title or f'#{signal.id}'} 自动入库到 {project_name}", + "ai_plan": "下一步可继续生成 charter、任务或通知负责人。", + "project_id": int(project_id), + "project_name": project_name, + "result_count": 1, + "impact_objects": [signal.title or f"信号#{signal.id}", project_name], + "can_undo": True, + "undo_deadline": _build_undo_deadline(_utc_now().replace(tzinfo=None)).isoformat(), + }, + commit=False, + ) + + async def _load_project_name(self, project_id: Optional[int]) -> Optional[str]: + if project_id is None: + return None + project = ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == int(project_id), + ) + ) + ) + ).scalar_one_or_none() + if project is None: + return None + return str(project.title or project.name or "").strip() or f"项目#{int(project_id)}" + + @staticmethod + def _resolve_message_class(item: FeishuItem) -> str: + value = str(item.message_class or "").strip().lower() + if value in {"action_required", "auto_running", "auto_done", "info", "chat", "query", "ignored"}: + return value + route_status = FeishuSignalPipelineService._normalize_route_status(item.route_status) + if route_status == ROUTE_STATUS_QUEUED: + return "action_required" + if route_status == ROUTE_STATUS_AUTO_RUNNING: + return "auto_running" + if route_status in {ROUTE_STATUS_AUTO_DONE, ROUTE_STATUS_UNDONE}: + return "auto_done" + return "info" + + @staticmethod + def _resolve_auto_execute_state(item: FeishuItem) -> str: + value = str(item.auto_execute_state or "").strip().lower() + if value: + return value + route_status = FeishuSignalPipelineService._normalize_route_status(item.route_status) + if route_status == ROUTE_STATUS_AUTO_DONE: + return "done" + if route_status == ROUTE_STATUS_QUEUED: + return "blocked" + if route_status == ROUTE_STATUS_UNDONE: + return "undone" + if route_status == ROUTE_STATUS_FAILED: + return "failed" + return "idle" + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_serialization.py b/backend/app/services/feishu_signal_pipeline/mixin_serialization.py new file mode 100644 index 00000000..23302dc3 --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_serialization.py @@ -0,0 +1,204 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * +from . import shared as _pipeline + + +class FeishuPipelineSerializationMixin: + async def _serialize_item(self, item: FeishuItem, *, + include_raw: bool = False, + include_history: bool = False, + ) -> dict[str, Any]: + result_payload = item.result_payload if isinstance(item.result_payload, dict) else {} + ai_output = _pipeline._safe_dict(item.ai_output) + project_name = await self._load_project_name(_pipeline._safe_int(item.matched_project_id)) + row: dict[str, Any] = { + "id": str(item.id), + "feishu_message_id": item.feishu_message_id, + "level": item.level, + "card_type": item.card_type, + "route_status": self._normalize_route_status(item.route_status), + "project_score": item.project_score, + "signal_confidence": item.signal_confidence, + "level_confidence": item.level_confidence, + "level_reasoning": item.level_reasoning, + "matched_project_id": item.matched_project_id, + "project_name": project_name, + "sender": item.source_sender, + "created_at": item.created_at.isoformat() if item.created_at else None, + "processed_at": item.processed_at.isoformat() if item.processed_at else None, + "updated_at": item.updated_at.isoformat() if item.updated_at else None, + "decision_reason": item.decision_reason, + "blocking_reason": item.blocking_reason, + "safe_to_auto_execute": bool(item.safe_to_auto_execute), + "auto_execute_state": self._resolve_auto_execute_state(item), + "dedupe_key": item.dedupe_key, + "message_class": self._resolve_message_class(item), + "occurrence_count": int(item.occurrence_count or 1), + "next_step_text": item.next_step_text, + "undoable_until": item.undoable_until.isoformat() if item.undoable_until else None, + "result_payload": result_payload or None, + "display_kind": self._resolve_message_class(item), + "target_url": result_payload.get("target_url") if isinstance(result_payload, dict) else None, + "priority_rank": 0, + } + if not row["target_url"] and item.matched_project_id: + row["target_url"] = f"/projects/{int(item.matched_project_id)}/board" + + if include_raw: + row["raw_message"] = item.raw_message + + if (item.level or "").lower() == "project": + suggestion = await self._get_project_suggestion(item.id) + operation_payload = result_payload.get("operation_payload") if isinstance(result_payload.get("operation_payload"), dict) else {} + project_fields = { + "title": str( + (suggestion.suggested_name if suggestion is not None else None) + or operation_payload.get("title") + or "" + ).strip(), + "description": str( + (suggestion.suggested_description if suggestion is not None else None) + or operation_payload.get("description") + or "" + ).strip(), + "project_scope": str(result_payload.get("project_scope") or "").strip(), + "missing_fields": [], + "ready": False, + } + if ( + self._looks_like_placeholder_project_text(project_fields["title"]) + or self._looks_like_placeholder_project_text(project_fields["description"]) + or self._looks_like_placeholder_project_text(project_fields["project_scope"]) + ): + project_fields = await self._maybe_prefill_project_fields_from_context( + message=str(item.raw_message or ""), + source_chat_id=str(item.source_chat_id or ""), + source_sender_open_id=str(item.source_sender_open_id or "") or None, + sender=str(item.source_sender or ""), + fields=project_fields, + ) + if suggestion: + created_project_id = _pipeline._safe_int(suggestion.created_project_id) + created_project_name = await self._load_project_name(created_project_id) + row["project_suggestion"] = { + "id": str(suggestion.id), + "suggested_name": suggestion.suggested_name, + "suggested_description": suggestion.suggested_description, + "suggested_owner": suggestion.suggested_owner, + "suggested_timeline": suggestion.suggested_timeline, + "confidence": suggestion.confidence, + "reasoning": _pipeline._normalize_reasoning(suggestion.reasoning), + "status": suggestion.status, + "created_project_id": str(created_project_id) if created_project_id else None, + } + row["project_name"] = created_project_name or row["project_name"] + row["matched_project_id"] = created_project_id or row["matched_project_id"] + row["priority"] = "high" if (item.project_score or 0) >= 0.85 else "medium" + row["priority_rank"] = 4 if (item.project_score or 0) >= 0.85 else 3 + if created_project_id and not row["target_url"]: + row["target_url"] = f"/projects/{created_project_id}/board" + row["title"] = self._build_project_preview_title( + title=str(project_fields.get("title") or ""), + description=str(project_fields.get("description") or ""), + project_scope=str(project_fields.get("project_scope") or ""), + ) + row["summary"] = self._build_project_preview_summary( + description=str(project_fields.get("description") or ""), + project_scope=str(project_fields.get("project_scope") or ""), + blocking_reason=item.blocking_reason, + fallback_text=str(ai_output.get("normalized_text") or item.raw_message or ""), + ) + row["detail_summary"] = row["summary"] + if not row.get("priority"): + row["priority"] = "high" if (item.project_score or 0) >= 0.85 else "medium" + row["priority_rank"] = 4 if (item.project_score or 0) >= 0.85 else 3 + return row + + if (item.level or "").lower() == "command": + operation_code = str(result_payload.get("operation_code") or "").strip() + row["title"] = ( + str(result_payload.get("result_summary") or "").strip() + or operation_code + or "受控动作" + ) + row["summary"] = str(item.blocking_reason or row["title"] or "").strip() + row["priority"] = "high" if row["display_kind"] == "action_required" else "medium" + row["priority_rank"] = 4 if row["display_kind"] == "action_required" else 2 + if not row.get("next_step_text"): + row["next_step_text"] = self._build_pending_operation_next_step(operation_code=operation_code) + if result_payload: + row["command"] = { + "operation_code": operation_code or None, + "executor": str(result_payload.get("executor") or "").strip() or None, + "operation_payload": ( + result_payload.get("operation_payload") + if isinstance(result_payload.get("operation_payload"), dict) + else None + ), + "operation_result": ( + result_payload.get("operation_result") + if isinstance(result_payload.get("operation_result"), dict) + else None + ), + } + return row + + signal = await self._get_signal_by_item(item.id) + if signal: + matched_project_id = _pipeline._safe_int(signal.matched_project_id) or _pipeline._safe_int(item.matched_project_id) + matched_project_name = await self._load_project_name(matched_project_id) + row["signal"] = { + "id": str(signal.id), + "title": signal.title, + "signal_type": signal.signal_type, + "priority": signal.priority, + "summary": signal.summary, + "reasoning": _pipeline._normalize_reasoning(signal.reasoning), + "confidence": signal.confidence, + "recommended_action": signal.recommended_action, + "matched_project_id": signal.matched_project_id, + "status": signal.status, + } + row["title"] = signal.title or "未命名信号" + row["summary"] = signal.summary + row["priority"] = signal.priority + row["priority_rank"] = _pipeline.PROJECT_PRIORITY_ORDER.get(str(signal.priority or "").upper(), 1) + row["matched_project_id"] = matched_project_id + row["project_name"] = matched_project_name or row["project_name"] + if matched_project_id and not row["target_url"]: + row["target_url"] = f"/projects/{matched_project_id}/board" + if not row.get("next_step_text"): + if row["display_kind"] == "auto_done": + row["next_step_text"] = "下一步可继续生成 charter、任务或通知负责人。" + elif not matched_project_id: + row["next_step_text"] = "补齐项目归属后,AI 将自动继续后续链路。" + if not row.get("summary"): + row["summary"] = _pipeline._compact_text(signal.content or item.raw_message, max_len=140) + if include_history: + history_rows = ( + await self.db.execute( + select(SignalEdit) + .where( + and_( + SignalEdit.tenant_id == self.tenant_id, + SignalEdit.signal_id == int(signal.id), + ) + ) + .order_by(SignalEdit.edited_at.desc()) + .limit(20) + ) + ).scalars().all() + row["edit_history"] = [ + { + "field": history.field, + "old_value": history.old_value, + "new_value": history.new_value, + "edited_by": str(history.edited_by) if history.edited_by else None, + "edited_at": history.edited_at.isoformat() if history.edited_at else None, + } + for history in history_rows + ] + return row + diff --git a/backend/app/services/feishu_signal_pipeline/mixin_task_command_queue.py b/backend/app/services/feishu_signal_pipeline/mixin_task_command_queue.py new file mode 100644 index 00000000..c82e58ae --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/mixin_task_command_queue.py @@ -0,0 +1,288 @@ +"""Feishu signal pipeline mixin.""" +from __future__ import annotations + +from .shared import * + + +class FeishuPipelineTaskCommandQueueMixin: + def _extract_task_intent_fields(self, *, text: str, inferred_project_id: Optional[int]) -> dict[str, Any]: + cleaned = re.sub(r"\s+", " ", str(text or "")).strip() + title = "" + quoted_match = re.search(r"[\"'“](.+?)[\"'”]", cleaned) + if quoted_match: + title = quoted_match.group(1).strip() + if not title: + for pattern in ( + r"(?:新增|新建|创建|加一个|加个).{0,8}任务(?:叫|名为|标题是|是|:|:)?\s*(.+)", + r"任务(?:叫|名为|标题是|是|:|:)\s*(.+)", + ): + match = re.search(pattern, cleaned, re.IGNORECASE) + if not match: + continue + candidate = re.sub(r"[。;;!!??].*$", "", match.group(1)).strip(" ,,::") + candidate = re.sub(r"^(?:到|进|放到)\s*项目.*$", "", candidate, flags=re.IGNORECASE).strip(" ,,::") + if candidate: + title = candidate[:120] + break + + description = "" + if title: + description = cleaned.replace(title, "", 1).strip(" ,,::") + description = re.sub(r"^(?:新增|新建|创建|加一个|加个).{0,8}任务", "", description, flags=re.IGNORECASE).strip(" ,,::") + + missing_fields: list[str] = [] + if inferred_project_id is None: + missing_fields.append("project_id") + if not title: + missing_fields.append("title") + return { + "project_id": int(inferred_project_id) if inferred_project_id is not None else None, + "title": title, + "description": description[:5000] or None, + "missing_fields": missing_fields, + "ready": not missing_fields, + } + + @staticmethod + def _build_task_clarification_message(missing_fields: list[str]) -> str: + labels = { + "project_id": "归属项目", + "title": "任务标题", + } + ordered = [labels[field] for field in missing_fields if field in labels] + suffix = "、".join(ordered) if ordered else "归属项目和任务标题" + return f"未执行:当前建任务请求还缺少 {suffix}。请补充后我再创建。" + + @staticmethod + def _build_pending_operation_summary(*, operation_code: str, payload: dict[str, Any]) -> str: + if operation_code == "project.create": + return f"待创建项目:{str(payload.get('title') or '未命名项目').strip()}" + if operation_code == "task.create": + return f"待创建任务:{str(payload.get('title') or '未命名任务').strip()}" + return f"待执行动作:{operation_code}" + + @staticmethod + def _build_pending_operation_next_step(*, operation_code: str) -> str: + if operation_code == "project.create": + return "补齐信息或确认后,系统会创建项目并绑定真实项目 ID。" + if operation_code == "task.create": + return "确认后,系统会在目标项目中创建任务。" + return "补齐上下文后,系统会继续执行该动作。" + + @staticmethod + def _build_gate_source_object(*, intent: str, fields: dict[str, Any], message: str) -> str: + if intent == INTENT_CREATE_PROJECT: + return str(fields.get("title") or fields.get("project_scope") or message) + if intent == INTENT_CREATE_TASK: + return str(fields.get("title") or fields.get("project_id") or message) + return str(fields.get("project_id") or intent or message) + + async def _queue_project_intent_item( + self, + *, + message: str, + sender: str, + source_chat_id: str, + source_sender_open_id: Optional[str], + feishu_message_id: Optional[str], + occurred_at: datetime, + intent_gate: dict[str, Any], + blocking_reason: str, + decision_reason: str, + ) -> dict[str, Any]: + fields = intent_gate.get("fields") if isinstance(intent_gate.get("fields"), dict) else {} + source_object = self._build_gate_source_object( + intent=str(intent_gate.get("intent") or INTENT_CREATE_PROJECT), + fields=fields, + message=message, + ) + dedupe_key = self._build_queue_dedupe_key( + level="project", + intent=str(intent_gate.get("intent") or INTENT_CREATE_PROJECT), + message=message, + source_chat_id=source_chat_id, + matched_project_id=None, + source_object=source_object, + ) + existing_item = await self._find_active_dedupe_item(dedupe_key) + if existing_item is not None: + existing_item.occurrence_count = max(1, int(existing_item.occurrence_count or 1)) + 1 + existing_item.blocking_reason = blocking_reason[:2000] + existing_item.decision_reason = decision_reason[:1000] + existing_item.raw_message = message[:4000] + await self.db.flush() + result = await self._build_existing_item_result(existing_item) + result["clarification_message"] = blocking_reason + return result + + item = FeishuItem( + tenant_id=self.tenant_id, + feishu_message_id=feishu_message_id, + project_score=1.0, + signal_confidence=0.0, + raw_message=message[:4000], + split_from_item_id=None, + level="project", + level_confidence=1.0, + level_reasoning="intent_gate:create_project", + card_type="new_project_candidate", + route_status=ROUTE_STATUS_QUEUED, + matched_project_id=None, + source_chat_id=(source_chat_id or "")[:128] or None, + source_sender=(sender or "")[:120] or None, + source_sender_open_id=(source_sender_open_id or "")[:128] or None, + source_occurred_at=occurred_at.replace(tzinfo=None), + ai_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False)[:8000], + decision_reason=decision_reason[:1000] or None, + blocking_reason=blocking_reason[:2000] or None, + safe_to_auto_execute=False, + auto_execute_state="blocked", + dedupe_key=dedupe_key, + message_class="action_required", + occurrence_count=1, + next_step_text=self._build_pending_operation_next_step(operation_code="project.create"), + queue_visible=True, + result_payload={ + "intent": INTENT_CREATE_PROJECT, + "executor": "explicit_service", + "operation_code": "project.create", + "operation_payload": { + "title": str(fields.get("title") or "").strip(), + "description": str(fields.get("description") or "").strip(), + }, + "project_scope": str(fields.get("project_scope") or "").strip() or None, + "result_summary": self._build_pending_operation_summary( + operation_code="project.create", + payload={ + "title": str(fields.get("title") or "").strip(), + }, + ), + }, + ) + self.db.add(item) + await self.db.flush() + + suggestion = ProjectSuggestion( + tenant_id=self.tenant_id, + feishu_item_id=int(item.id), + suggested_name=(str(fields.get("title") or "").strip() or message[:20] or "新项目建议")[:255], + suggested_description=(str(fields.get("description") or "").strip() or message[:400])[:4000], + suggested_owner=(sender or "待指定")[:120], + suggested_timeline="待确认", + confidence=1.0, + reasoning=json.dumps(["命中 intent gate:create_project,等待确认或补齐字段。"], ensure_ascii=False), + status="pending", + ) + self.db.add(suggestion) + await self.db.flush() + return { + "item_id": int(item.id), + "level": "project", + "project_id": None, + "signal_id": None, + "route_status": ROUTE_STATUS_QUEUED, + "clarification_message": blocking_reason, + } + + async def _queue_command_intent_item( + self, + *, + message: str, + sender: str, + source_chat_id: str, + source_sender_open_id: Optional[str], + feishu_message_id: Optional[str], + occurred_at: datetime, + intent_gate: dict[str, Any], + operation_code: str, + operation_payload: dict[str, Any], + blocking_reason: str, + decision_reason: str, + matched_project_id: Optional[int], + ) -> dict[str, Any]: + fields = intent_gate.get("fields") if isinstance(intent_gate.get("fields"), dict) else {} + source_object = self._build_gate_source_object( + intent=str(intent_gate.get("intent") or ""), + fields=fields, + message=message, + ) + dedupe_key = self._build_queue_dedupe_key( + level="command", + intent=str(intent_gate.get("intent") or ""), + message=message, + source_chat_id=source_chat_id, + matched_project_id=matched_project_id, + source_object=source_object, + ) + existing_item = await self._find_active_dedupe_item(dedupe_key) + if existing_item is not None: + existing_item.occurrence_count = max(1, int(existing_item.occurrence_count or 1)) + 1 + existing_item.blocking_reason = blocking_reason[:2000] + existing_item.decision_reason = decision_reason[:1000] + existing_item.raw_message = message[:4000] + existing_item.result_payload = { + **(existing_item.result_payload if isinstance(existing_item.result_payload, dict) else {}), + "intent": str(intent_gate.get("intent") or ""), + "executor": "explicit_service", + "operation_code": operation_code, + "operation_payload": operation_payload, + "result_summary": self._build_pending_operation_summary( + operation_code=operation_code, + payload=operation_payload, + ), + } + await self.db.flush() + result = await self._build_existing_item_result(existing_item) + result["clarification_message"] = blocking_reason + return result + + item = FeishuItem( + tenant_id=self.tenant_id, + feishu_message_id=feishu_message_id, + project_score=1.0 if matched_project_id is not None else None, + signal_confidence=0.0 if matched_project_id is not None else None, + raw_message=message[:4000], + split_from_item_id=None, + level="command", + level_confidence=1.0, + level_reasoning=f"intent_gate:{str(intent_gate.get('intent') or '')}", + card_type=operation_code.replace(".", "_")[:30], + route_status=ROUTE_STATUS_QUEUED, + matched_project_id=int(matched_project_id) if matched_project_id is not None else None, + source_chat_id=(source_chat_id or "")[:128] or None, + source_sender=(sender or "")[:120] or None, + source_sender_open_id=(source_sender_open_id or "")[:128] or None, + source_occurred_at=occurred_at.replace(tzinfo=None), + ai_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False)[:8000], + decision_reason=decision_reason[:1000] or None, + blocking_reason=blocking_reason[:2000] or None, + safe_to_auto_execute=False, + auto_execute_state="blocked", + dedupe_key=dedupe_key, + message_class="action_required", + occurrence_count=1, + next_step_text=self._build_pending_operation_next_step(operation_code=operation_code), + queue_visible=True, + result_payload={ + "intent": str(intent_gate.get("intent") or ""), + "executor": "explicit_service", + "operation_code": operation_code, + "operation_payload": operation_payload, + "result_summary": self._build_pending_operation_summary( + operation_code=operation_code, + payload=operation_payload, + ), + "project_id": int(matched_project_id) if matched_project_id is not None else None, + }, + ) + self.db.add(item) + await self.db.flush() + return { + "item_id": int(item.id), + "level": "command", + "project_id": int(matched_project_id) if matched_project_id is not None else None, + "signal_id": None, + "route_status": ROUTE_STATUS_QUEUED, + "clarification_message": blocking_reason, + } + diff --git a/backend/app/services/feishu_signal_pipeline/service.py b/backend/app/services/feishu_signal_pipeline/service.py new file mode 100644 index 00000000..5b786040 --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/service.py @@ -0,0 +1,66 @@ +"""Feishu signal pipeline service entry class.""" +from __future__ import annotations + +from .mixin_core_base import FeishuPipelineCoreBaseMixin +from .mixin_intent_gate_core import FeishuPipelineIntentGateCoreMixin +from .mixin_context_prefill_a import FeishuPipelineContextPrefillAMixin +from .mixin_context_prefill_b import FeishuPipelineContextPrefillBMixin +from .mixin_task_command_queue import FeishuPipelineTaskCommandQueueMixin +from .mixin_command_exec_core import FeishuPipelineCommandExecCoreMixin +from .mixin_command_commit_project import FeishuPipelineCommandCommitProjectMixin +from .mixin_command_commit_task import FeishuPipelineCommandCommitTaskMixin +from .mixin_intent_support import FeishuPipelineIntentSupportMixin +from .mixin_gate_project import FeishuPipelineGateProjectMixin +from .mixin_gate_task import FeishuPipelineGateTaskMixin +from .mixin_gate_dispatch import FeishuPipelineGateDispatchMixin +from .mixin_process_message_core import FeishuPipelineProcessMessageCoreMixin +from .mixin_process_message_default import FeishuPipelineProcessMessageDefaultMixin +from .mixin_process_message_helpers_a import FeishuPipelineProcessMessageHelpersAMixin +from .mixin_process_message_helpers_b import FeishuPipelineProcessMessageHelpersBMixin +from .mixin_followup_a import FeishuPipelineFollowupAMixin +from .mixin_followup_b import FeishuPipelineFollowupBMixin +from .mixin_lookup_and_block import FeishuPipelineLookupAndBlockMixin +from .mixin_queue_read_write import FeishuPipelineQueueReadWriteMixin +from .mixin_queue_confirm_undo import FeishuPipelineQueueConfirmUndoMixin +from .mixin_item_processing import FeishuPipelineItemProcessingMixin +from .mixin_item_finalize_routing import FeishuPipelineItemFinalizeRoutingMixin +from .mixin_extraction import FeishuPipelineExtractionMixin +from .mixin_routing_notify import FeishuPipelineRoutingNotifyMixin +from .mixin_serialization import FeishuPipelineSerializationMixin +from .mixin_persistence_lookup import FeishuPipelinePersistenceLookupMixin +from .mixin_message_utils_a import FeishuPipelineMessageUtilsAMixin +from .mixin_message_utils_b import FeishuPipelineMessageUtilsBMixin + + +class FeishuSignalPipelineService( + FeishuPipelineCoreBaseMixin, + FeishuPipelineIntentGateCoreMixin, + FeishuPipelineContextPrefillAMixin, + FeishuPipelineContextPrefillBMixin, + FeishuPipelineTaskCommandQueueMixin, + FeishuPipelineCommandExecCoreMixin, + FeishuPipelineCommandCommitProjectMixin, + FeishuPipelineCommandCommitTaskMixin, + FeishuPipelineIntentSupportMixin, + FeishuPipelineGateProjectMixin, + FeishuPipelineGateTaskMixin, + FeishuPipelineGateDispatchMixin, + FeishuPipelineProcessMessageCoreMixin, + FeishuPipelineProcessMessageDefaultMixin, + FeishuPipelineProcessMessageHelpersAMixin, + FeishuPipelineProcessMessageHelpersBMixin, + FeishuPipelineFollowupAMixin, + FeishuPipelineFollowupBMixin, + FeishuPipelineLookupAndBlockMixin, + FeishuPipelineQueueReadWriteMixin, + FeishuPipelineQueueConfirmUndoMixin, + FeishuPipelineItemProcessingMixin, + FeishuPipelineItemFinalizeRoutingMixin, + FeishuPipelineExtractionMixin, + FeishuPipelineRoutingNotifyMixin, + FeishuPipelineSerializationMixin, + FeishuPipelinePersistenceLookupMixin, + FeishuPipelineMessageUtilsAMixin, + FeishuPipelineMessageUtilsBMixin, +): + pass diff --git a/backend/app/services/feishu_signal_pipeline/shared.py b/backend/app/services/feishu_signal_pipeline/shared.py new file mode 100644 index 00000000..63ddf1c5 --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/shared.py @@ -0,0 +1,5 @@ +"""Shared exports for Feishu signal pipeline mixins.""" +from __future__ import annotations + +from .shared_core import * +from .shared_schema_utils import * diff --git a/backend/app/services/feishu_signal_pipeline/shared_core.py b/backend/app/services/feishu_signal_pipeline/shared_core.py new file mode 100644 index 00000000..eeb5ecdc --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/shared_core.py @@ -0,0 +1,217 @@ +"""飞书消息分流与决策队列服务。""" +from __future__ import annotations + +import asyncio +import json +import hashlib +import re +import time +from datetime import datetime, timedelta, timezone +from typing import Any, Literal, Optional + +from pydantic import BaseModel, Field, ValidationError +from sqlalchemy import and_, delete, func, or_, select, text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from ..config.ai_prompts import ( + PROMPT_FEISHU_LEVEL_CLASSIFICATION, + PROMPT_FEISHU_PROJECT_PREFILL, + PROMPT_FEISHU_SIGNAL_EXTRACTION, +) +from ..core.logging import get_logger +from ..models import ( + FeishuItem, + AIProcessingLog, + FeishuReplyOutbox, + Integration, + Project, + ProjectSignalLink, + ProjectSuggestion, + Signal, + SignalEdit, + TruthNode, + User, +) +from .llm import LLMService +from .adaptive_rag_service import AdaptiveRAGService +from .ai_agent_kernel import ( + build_decision_trace, + gate_execution as kernel_gate_execution, + resolve_correction as kernel_resolve_correction, +) +from .context_cache import context_cache_service +from .notification_service import NotificationService +from .operation_log_service import log_operation +from .project_command_utils import ( + extract_project_create_description, + extract_project_create_title, + normalize_project_title_candidate, +) +from .sstc_service import ALIGNED_STATUSES +from .task_persistence import build_task_create_draft, persist_task +from ..utils.project_members import ensure_project_default_members +from ..utils.sequence import ensure_project_id_sequence + +from ..utils.timezone import CN_TZ, now_cn_aware +logger = get_logger(__name__) + +PROJECT_PRIORITY_ORDER = { + "P0": 4, + "P1": 3, + "P2": 2, + "P3": 1, + "high": 3, + "medium": 2, + "low": 1, +} + +SIGNAL_TYPE_OPTIONS = {"竞品动态", "用户反馈", "项目风险", "新需求"} +PROJECT_INTENT_RE = re.compile(r"(立项|新项目|我们要做|需要开发|启动|规划)", re.IGNORECASE) +SIGNAL_FACT_RE = re.compile(r"(竞品|上线|发布|反馈|风险|bug|故障|告警|投诉)", re.IGNORECASE) +TIMELINE_RE = re.compile(r"(\d+\s*(天|周|月)|本周|本月|月底|下周|下月|Q[1-4]|\d{1,2}月\d{1,2}日)") +OWNER_RE = re.compile(r"(由|负责人|owner|owner:|@)(\S+)") +INDEPENDENT_REQUIREMENT_RE = re.compile( + r"(独立需求|单独需求|单开|另起|新增需求|新建项目|新增项目|单独立项|开个新|做个新)", + re.IGNORECASE, +) +SIMPLE_SIGNAL_REQUEST_RE = re.compile( + r"(新增|增加|加一个|加个|补充|支持|实现|接入|优化|修复|改进).{0,18}(功能|能力|接口|页面|按钮|流程|识别|提醒|校验|报表|字段|规则|模块)", + re.IGNORECASE, +) +PROJECT_COMPLEXITY_RE = re.compile( + r"(立项|新项目|项目章程|里程碑|预算|排期|roadmap|跨团队|跨部门|季度目标|年度目标|okr)", + re.IGNORECASE, +) +QUESTION_REQUIREMENT_VERB_RE = re.compile( + r"(新增|增加|加一个|加个|支持|接入|实现|优化|修复|改进|调整|补充|完善|同步|对齐|落库|入库)", + re.IGNORECASE, +) +QUESTION_REQUIREMENT_OBJECT_RE = re.compile( + r"(功能|能力|接口|页面|按钮|流程|校验|报表|字段|规则|模块|任务|项目|需求|通知|提醒|配置|归属|队列|信号)", + re.IGNORECASE, +) +PROJECT_LIST_QUERY_RE = re.compile( + r"(现在|当前|目前|系统里|系统中|项目里|项目中)?.{0,4}(有哪些项目|项目列表|项目清单|哪(些|几个)项目)", + re.IGNORECASE, +) +PROJECT_ID_QUERY_RE = re.compile( + r"项目\s*id\s*[::]?\s*(\d+).{0,12}(有吗|存在吗|还在吗|是否存在|是不是)", + re.IGNORECASE, +) +LEGACY_COMMAND_PROJECT_CREATE_RE = re.compile( + r"(创建|新建|建立|开个|开一个|建个|建一个|做个|做一个).{0,6}项目", + re.IGNORECASE, +) +LEGACY_COMMAND_VERB_RE = re.compile( + r"(创建|新建|建立|开个|开一个|建个|建一个|做个|做一个|修改|更新|改成|改为|设为|设成|分配|指派|归档|重开|重新打开|恢复|标记完成|完成|提醒|通知|调整工时|调整工作量)", + re.IGNORECASE, +) +LEGACY_COMMAND_OBJECT_RE = re.compile( + r"(项目|任务|负责人|优先级|状态|通知|提醒|工时|工作量)", + re.IGNORECASE, +) +FOLLOWUP_CONTINUE_RE = re.compile(r"(继续处理|继续推进|继续执行|接着处理|继续跑)", re.IGNORECASE) +FOLLOWUP_CREATE_CONTINUE_RE = re.compile(r"(继续创建|接着创建|继续建|接着建)", re.IGNORECASE) +FOLLOWUP_STATUS_RE = re.compile( + r"(进度|状态|处理到哪|处理了吗|现在怎么样|怎么样了|结果呢|是否已经创建|已经创建了吗|创建了吗|创建成功了吗|建好了吗|建成了吗)", + re.IGNORECASE, +) +FOLLOWUP_REFERENCE_RE = re.compile( + r"(刚才|刚刚|前面|之前|上次|上一条|我刚才要求|我刚才说|我说的|那个)", + re.IGNORECASE, +) +OBVIOUS_QUESTION_RE = re.compile( + r"([??]$|吗$|呢$|呀$|吧$|怎么|如何|为什么|多少|几号|几点|周几|有没有|是否|是不是|是什么|什么|哪些|有哪些|在哪|哪一个|哪几个|特点|能力|介绍|说明|风险)", + re.IGNORECASE, +) +QUERY_INTENT_RE = re.compile( + r"(哪些|有哪些|特点|技术特点|能力|介绍|说明|怎么|如何|是什么|什么|能做什么|可以做什么|项目有哪些风险)", + re.IGNORECASE, +) +FEISHU_BOT_MENTION_RE = re.compile(r"(?:]*>[^<]*|@pmflow\b|@_user_1\b)", re.IGNORECASE) +FEISHU_QUOTE_PREFIX_RE = re.compile(r"(?m)^\s*(?:回复|引用|转发)[::]\s*") +FEISHU_QUOTE_LINE_RE = re.compile(r"(?m)^\s*(?:>+|【引用】|引用:|回复:).*$") +FEISHU_NOISE_TOKEN_RE = re.compile(r"\[(?:附带图片|图片|表情|文件|语音|消息卡片)\]", re.IGNORECASE) +EXTERNAL_INTEGRATION_RE = re.compile( + r"(github|gitlab|jira|slack|webhook|飞书|企业微信|企微|lark|api|接入|授权|同步)", + re.IGNORECASE, +) +CROSS_SYSTEM_RE = re.compile(r"(跨系统|跨平台|跨团队|外部系统|第三方系统)", re.IGNORECASE) +TASK_CREATE_RE = re.compile( + r"(新增|新建|创建|加一个|加个|补一个|补个).{0,8}任务", + re.IGNORECASE, +) +PROJECT_CREATE_GATE_RE = re.compile( + r"(新增|新建|创建|建立|立项|放到|放进|加入|录入|进).{0,18}(pmflow|项目列表|项目库|项目台账|项目主数据|一个项目|项目)", + re.IGNORECASE, +) +HEALTHCHECK_RE = re.compile( + r"^(?:ping|pong|health(?:check)?|在线吗|活着吗|存活吗|还在吗|服务正常吗)\??$", + re.IGNORECASE, +) +SHORT_CONFIRM_RE = re.compile( + r"^(?:收到|好的|好|ok|okay|明白|了解|知道了|收到啦|嗯嗯|行|辛苦了|thanks?|thx)[!!.。]?$", + re.IGNORECASE, +) +CREATE_ACTION_RE = re.compile(r"(新增|新建|创建|建立|接入|配置|录入|加入|放到|放进|立项)", re.IGNORECASE) +CORRECTION_CUE_RE = re.compile( + r"(不对|错了|理解错|不是这个|不是这个意思|我说的是|我要的是|不是啊|不是新增 md|不是写 markdown)", + re.IGNORECASE, +) + +ROUTING_AUTO_CREATE_PROJECT = "auto_create_project" +ROUTING_AUTO_ENTER_SIGNAL = "auto_enter_signal" +ROUTING_PENDING_LOW_CONFIDENCE = "pending_low_confidence" +ROUTING_PENDING_COLD_START = "pending_cold_start" +ROUTING_PENDING_UNCERTAIN_PROJECT = "pending_uncertain_project" +ROUTING_DUPLICATE_FILTERED = "duplicate_filtered" +ROUTING_NOISE_FILTERED = "noise_filtered" +ROUTING_FAILED = "failed" +ROUTING_DIRECT_QA = "direct_qa" +ROUTING_LEGACY_COMMAND_EXECUTED = "legacy_command_executed" +ROUTING_LEGACY_COMMAND_BLOCKED = "legacy_command_blocked" +ROUTING_QUEUE_FOLLOWUP_QUERY = "queue_followup_query" +ROUTING_QUEUE_FOLLOWUP_CONFIRM = "queue_followup_confirm" +ROUTING_QUEUE_FOLLOWUP_BLOCKED = "queue_followup_blocked" +ROUTING_INTENT_GATE_QUERY = "intent_gate_query" +ROUTING_INTENT_GATE_HEALTHCHECK = "intent_gate_healthcheck" +ROUTING_INTENT_GATE_CLARIFICATION = "intent_gate_clarification" +ROUTING_INTENT_GATE_EXTERNAL_CONNECT = "intent_gate_external_connect" +ROUTING_OPERATION_COMMITTED = "operation_committed" +ROUTING_OPERATION_FAILED = "operation_failed" + +INTENT_QUERY = "query" +INTENT_SMALLTALK = "smalltalk" +INTENT_HEALTHCHECK = "healthcheck" +INTENT_CREATE_PROJECT = "create_project" +INTENT_CREATE_TASK = "create_task" +INTENT_SIGNAL_INTAKE = "signal_intake" +INTENT_EXTERNAL_CONNECT = "external_connect" +INTENT_CLARIFICATION_REQUIRED = "clarification_required" + +ROUTE_STATUS_CLASSIFIED = "classified" +ROUTE_STATUS_QUEUED = "queued_for_decision" +ROUTE_STATUS_AUTO_RUNNING = "auto_running" +ROUTE_STATUS_AUTO_DONE = "auto_done" +ROUTE_STATUS_FAILED = "failed" +ROUTE_STATUS_DISMISSED = "dismissed" +ROUTE_STATUS_UNDONE = "undone" + +ACTIVE_ITEM_ROUTE_STATUSES = { + ROUTE_STATUS_CLASSIFIED, + ROUTE_STATUS_QUEUED, + ROUTE_STATUS_AUTO_RUNNING, + "pending", +} +AUTO_DONE_ITEM_ROUTE_STATUSES = { + ROUTE_STATUS_AUTO_DONE, + "auto_processed", + "confirmed", +} + +DEFAULT_SIGNAL_AUTO_ENTER_THRESHOLD = 0.80 +DEFAULT_PROJECT_AUTO_CREATE_THRESHOLD = 0.85 +DEFAULT_PROJECT_UNCERTAIN_THRESHOLD = 0.60 + + diff --git a/backend/app/services/feishu_signal_pipeline/shared_schema_utils.py b/backend/app/services/feishu_signal_pipeline/shared_schema_utils.py new file mode 100644 index 00000000..2657bd61 --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline/shared_schema_utils.py @@ -0,0 +1,171 @@ +class _LevelSplitItemSchema(BaseModel): + level: Literal["project", "signal"] + content: str = Field(min_length=1, max_length=4000) + + +class _LevelClassificationSchema(BaseModel): + build_intent_score: float = Field(ge=0.0, le=1.0) + scope_score: float = Field(ge=0.0, le=1.0) + timeline_score: float = Field(ge=0.0, le=1.0) + owner_score: float = Field(ge=0.0, le=1.0) + project_score: float = Field(ge=0.0, le=1.0) + level: Literal["project", "signal"] + level_confidence: float = Field(ge=0.0, le=1.0) + level_reasoning: str = Field(min_length=1, max_length=600) + should_split: bool = False + split_items: list[_LevelSplitItemSchema] = Field(default_factory=list, max_length=2) + + +class _SignalExtractionSchema(BaseModel): + title: str = Field(min_length=1, max_length=200) + type: Literal["竞品动态", "用户反馈", "项目风险", "新需求"] + priority: Literal["P0", "P1", "P2", "P3"] + matched_project_id: Optional[int] = None + summary: str = Field(min_length=1, max_length=3000) + reasoning: list[str] = Field(min_length=1, max_length=3) + confidence: float = Field(ge=0.0, le=1.0) + recommended_action: Literal["enter_only", "create_charter", "trigger_alert"] + + +class _ProjectPrefillSchema(BaseModel): + suggested_name: str = Field(min_length=1, max_length=255) + suggested_description: str = Field(min_length=1, max_length=4000) + suggested_owner: str = Field(min_length=1, max_length=120) + suggested_timeline: str = Field(min_length=1, max_length=120) + confidence: float = Field(ge=0.0, le=1.0) + reasoning: list[str] = Field(min_length=1, max_length=3) + + +def _normalize_reasoning(value: Any, max_items: int = 3) -> list[str]: + if isinstance(value, list): + rows = [str(x).strip() for x in value if str(x).strip()] + return rows[:max_items] + if isinstance(value, str): + parts = [p.strip(" -•\t") for p in value.splitlines() if p.strip()] + return parts[:max_items] + return [] + + +def _safe_json_object(value: str) -> dict[str, Any]: + raw = (value or "").strip() + if raw.startswith("```"): + chunks = raw.split("```") + if len(chunks) >= 2: + raw = chunks[1].strip() + if raw.startswith("json"): + raw = raw[4:].strip() + try: + parsed = json.loads(raw) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + + +def _validate_schema( + schema: type[BaseModel], + payload: dict[str, Any], + *, + context: str, +) -> dict[str, Any]: + if not isinstance(payload, dict): + raise ValueError(f"{context}_schema_invalid: payload_not_object") + try: + if hasattr(schema, "model_validate"): + model = schema.model_validate(payload) # type: ignore[attr-defined] + else: + model = schema.parse_obj(payload) # type: ignore[attr-defined] + except ValidationError as exc: + raise ValueError(f"{context}_schema_invalid: {str(exc)[:400]}") from exc + if hasattr(model, "model_dump"): + return model.model_dump() # type: ignore[no-any-return] + return model.dict() # type: ignore[no-any-return] + + +def _safe_float(value: Any, default: float = 0.0) -> float: + try: + return float(value) + except (TypeError, ValueError): + return float(default) + + +def _safe_bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + text = str(value).strip().lower() + if text in {"1", "true", "yes", "y", "on", "是", "真"}: + return True + if text in {"0", "false", "no", "n", "off", "否", "假"}: + return False + return default + + +def _safe_int(value: Any) -> Optional[int]: + if isinstance(value, bool): + return None + try: + return int(str(value).strip()) + except (TypeError, ValueError): + return None + + +def _normalize_level_value(value: Any, default: str = "signal") -> str: + text = str(value or "").strip().lower() + if not text: + return default + if text in {"project", "project-level", "project_level", "new_project"}: + return "project" + if text in {"signal", "signal-level", "signal_level"}: + return "signal" + if text in {"项目", "项目级", "立项", "新项目"}: + return "project" + if text in {"信号", "信号级", "需求", "需求级", "事件"}: + return "signal" + if "project" in text or "项目" in text or "立项" in text: + return "project" + if "signal" in text or "信号" in text or "需求" in text: + return "signal" + return default + + +def _first_non_empty(data: dict[str, Any], keys: tuple[str, ...]) -> Any: + for key in keys: + if key not in data: + continue + value = data.get(key) + if value is None: + continue + if isinstance(value, str) and not value.strip(): + continue + return value + return None + + +def _utc_now() -> datetime: + return now_cn_aware() + + +def _compact_text(value: Any, max_len: int = 160) -> str: + text = str(value or "").strip() + if len(text) <= max_len: + return text + return f"{text[: max_len - 3]}..." + + +def _safe_dict(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if isinstance(value, str): + return _safe_json_object(value) + return {} + + +def _build_undo_deadline(value: Optional[datetime], *, minutes: int = 10) -> Optional[datetime]: + if value is None: + return None + if value.tzinfo is not None: + value = value.astimezone(CN_TZ).replace(tzinfo=None) + return value + timedelta(minutes=minutes) + + diff --git a/backend/app/services/integration.py b/backend/app/services/integration.py index 61057f26..687db3d9 100644 --- a/backend/app/services/integration.py +++ b/backend/app/services/integration.py @@ -1,541 +1,27 @@ -""" -Neural Sync 2.0 - Integration Service - -集成服务 - 管理 Plane 和飞书集成 -- 配置管理 -- 状态监控 -- 健康检查 -- 故障降级 -""" - -import asyncio -import logging -from typing import Optional, Dict, List, Any -from pydantic import BaseModel, Field -from datetime import datetime, timedelta -from enum import Enum -import httpx - -from ..utils.timezone import now_cn_naive -logger = logging.getLogger(__name__) - - -class IntegrationType(str, Enum): - """集成类型""" - PLANE = "plane" - LARK = "lark" - GITHUB = "github" - GITLAB = "gitlab" - - -class IntegrationStatus(str, Enum): - """集成状态""" - ACTIVE = "active" - INACTIVE = "inactive" - ERROR = "error" - DEGRADED = "degraded" - PENDING = "pending" - - -class PlaneConfig(BaseModel): - """Plane 配置""" - api_url: str = "https://api.plane.so/api/v1" - api_key: str = "" - workspace_slug: str = "" - project_id: str = "" - enabled: bool = True - - -class LarkConfig(BaseModel): - """飞书配置""" - app_id: str = "" - app_secret: str = "" - verification_token: str = "" - encrypt_key: str = "" - default_chat_id: str = "" - enabled: bool = True - - -class IntegrationHealth(BaseModel): - """集成健康状态""" - type: IntegrationType - status: IntegrationStatus - last_check: datetime - last_success: Optional[datetime] = None - last_error: Optional[str] = None - error_count: int = 0 - consecutive_failures: int = 0 - latency_ms: Optional[float] = None - message: str = "" - - -class IntegrationConfig(BaseModel): - """集成配置""" - plane: PlaneConfig = Field(default_factory=PlaneConfig) - lark: LarkConfig = Field(default_factory=LarkConfig) - - -class TaskSyncResult(BaseModel): - """任务同步结果""" - success: bool - integration_type: IntegrationType - tasks_created: int = 0 - tasks_updated: int = 0 - errors: List[str] = Field(default_factory=list) - details: Dict[str, Any] = Field(default_factory=dict) - - -class NotificationResult(BaseModel): - """通知发送结果""" - success: bool - integration_type: IntegrationType - recipients: int = 0 - error: Optional[str] = None - - -class IntegrationService: - """ - 集成服务 - 管理外部集成 - - 特性: - - 配置管理(动态加载/保存) - - 健康检查和监控 - - 自动故障降级 - - 重试机制 - - 事件通知 - """ - - # 健康检查配置 - HEALTH_CHECK_INTERVAL = 60 # 秒 - MAX_CONSECUTIVE_FAILURES = 3 - DEGRADE_THRESHOLD = 5 # 错误次数后降级 - - def __init__(self, config: Optional[IntegrationConfig] = None, event_bus=None): - self.config = config or IntegrationConfig() - self.event_bus = event_bus - - # 健康状态 - self._health_status: Dict[IntegrationType, IntegrationHealth] = { - IntegrationType.PLANE: IntegrationHealth( - type=IntegrationType.PLANE, - status=IntegrationStatus.PENDING, - last_check=now_cn_naive(), - ), - IntegrationType.LARK: IntegrationHealth( - type=IntegrationType.LARK, - status=IntegrationStatus.PENDING, - last_check=now_cn_naive(), - ), - } - - # 统计信息 - self._stats = { - IntegrationType.PLANE: {"synced": 0, "failed": 0}, - IntegrationType.LARK: {"sent": 0, "failed": 0}, - } - - # 服务实例(延迟加载) - self._plane_service = None - self._lark_service = None - - # 健康检查任务 - self._health_check_task: Optional[asyncio.Task] = None - - async def start(self): - """启动集成服务""" - self._health_check_task = asyncio.create_task(self._health_check_loop()) - logger.info("[Integration] Service started") - - async def stop(self): - """停止集成服务""" - if self._health_check_task: - self._health_check_task.cancel() - try: - await self._health_check_task - except asyncio.CancelledError: - pass - logger.info("[Integration] Service stopped") - - async def sync_tasks_to_plane( - self, - tasks: List[Dict[str, Any]], - is_urgent: bool = False - ) -> TaskSyncResult: - """ - 同步任务到 Plane - - 增强功能: - - 批量创建 - - 标签管理 - - 优先级映射 - - 错误重试 - """ - integration_type = IntegrationType.PLANE - health = self._health_status[integration_type] - - # 检查服务是否可用 - if not self._is_integration_available(integration_type): - return TaskSyncResult( - success=False, - integration_type=integration_type, - errors=[f"Plane integration is {health.status.value}"], - ) - - start_time = now_cn_naive() - - try: - # 获取 Plane 服务 - plane = await self._get_plane_service() - - # 批量同步 - result = await plane.sync_tasks(tasks, is_urgent) - - # 更新统计 - if result.get("status") == "success": - self._stats[integration_type]["synced"] += result.get("created_count", 0) - await self._record_success(integration_type) - else: - self._stats[integration_type]["failed"] += len(result.get("errors", [])) - await self._record_failure(integration_type, ", ".join(result.get("errors", []))) - - # 计算延迟 - latency = (now_cn_naive() - start_time).total_seconds() * 1000 - health.latency_ms = latency - - return TaskSyncResult( - success=result.get("status") == "success", - integration_type=integration_type, - tasks_created=result.get("created_count", 0), - errors=result.get("errors", []), - details=result, - ) - - except Exception as e: - logger.error(f"[Integration] Plane sync failed: {e}") - await self._record_failure(integration_type, str(e)) - return TaskSyncResult( - success=False, - integration_type=integration_type, - errors=[str(e)], - ) - - async def send_strategic_alert( - self, - chat_id: str, - signal_content: str, - decision: Dict[str, Any], - tasks: List[Dict[str, Any]] - ) -> NotificationResult: - """ - 发送战略预警到飞书 - - 增强功能: - - 富文本卡片 - - 可操作按钮 - - @提及支持 - - 消息去重 - """ - integration_type = IntegrationType.LARK - health = self._health_status[integration_type] - - # 检查服务是否可用 - if not self._is_integration_available(integration_type): - return NotificationResult( - success=False, - integration_type=integration_type, - error=f"Lark integration is {health.status.value}", - ) - - start_time = now_cn_naive() - - try: - # 获取飞书服务 - lark = await self._get_lark_service() - - # 发送卡片消息 - await lark.send_strategic_alert(chat_id, signal_content, decision, tasks) - - # 更新统计 - self._stats[integration_type]["sent"] += 1 - await self._record_success(integration_type) - - # 计算延迟 - latency = (now_cn_naive() - start_time).total_seconds() * 1000 - health.latency_ms = latency - - # 发送事件 - if self.event_bus: - from ..events import EventType, Event - await self.event_bus.publish( - Event( - type=EventType.LARK_NOTIFY_SUCCESS, - data={ - "type": "strategic_alert", - "chat_id": chat_id, - }, - ) - ) - - return NotificationResult( - success=True, - integration_type=integration_type, - recipients=1, - ) - - except Exception as e: - logger.error(f"[Integration] Lark notification failed: {e}") - await self._record_failure(integration_type, str(e)) - - # 发送失败事件 - if self.event_bus: - from ..events import EventType, Event - await self.event_bus.publish( - Event( - type=EventType.LARK_NOTIFY_FAILED, - data={ - "type": "strategic_alert", - "error": str(e), - }, - ) - ) - - return NotificationResult( - success=False, - integration_type=integration_type, - error=str(e), - ) - - async def send_card_message( - self, - chat_id: str, - content: str, - tasks: List[Dict[str, Any]] - ) -> NotificationResult: - """ - 发送卡片消息到飞书 - """ - integration_type = IntegrationType.LARK - health = self._health_status[integration_type] - - if not self._is_integration_available(integration_type): - return NotificationResult( - success=False, - integration_type=integration_type, - error=f"Lark integration is {health.status.value}", - ) - - try: - lark = await self._get_lark_service() - await lark.send_card_message(chat_id, content, tasks) - - self._stats[integration_type]["sent"] += 1 - await self._record_success(integration_type) - - return NotificationResult( - success=True, - integration_type=integration_type, - recipients=1, - ) - - except Exception as e: - logger.error(f"[Integration] Lark card message failed: {e}") - await self._record_failure(integration_type, str(e)) - return NotificationResult( - success=False, - integration_type=integration_type, - error=str(e), - ) - - async def update_plane_issue_status( - self, - issue_id: str, - status: str = "Done" - ) -> bool: - """更新 Plane 工单状态""" - integration_type = IntegrationType.PLANE - - if not self._is_integration_available(integration_type): - return False - - try: - plane = await self._get_plane_service() - result = await plane.update_issue_status(issue_id, status) - - if "error" not in result: - await self._record_success(integration_type) - return True - - await self._record_failure(integration_type, result.get("error", "Unknown error")) - return False - - except Exception as e: - logger.error(f"[Integration] Plane status update failed: {e}") - await self._record_failure(integration_type, str(e)) - return False - - def get_health_status(self) -> Dict[str, IntegrationHealth]: - """获取所有集成健康状态""" - return {k.value: v for k, v in self._health_status.items()} - - def get_stats(self) -> Dict[str, Dict[str, int]]: - """获取集成统计信息""" - return {k.value: v for k, v in self._stats.items()} - - async def check_plane_health(self) -> IntegrationHealth: - """检查 Plane 集成健康""" - integration_type = IntegrationType.PLANE - health = self._health_status[integration_type] - start_time = now_cn_naive() - - if not self.config.plane.enabled or not self.config.plane.api_key: - health.status = IntegrationStatus.INACTIVE - health.message = "Plane integration is disabled" - health.last_check = now_cn_naive() - return health - - try: - # 简单的健康检查:尝试获取项目标签 - plane = await self._get_plane_service() - label_id = await plane.get_label_id("__health_check__") - - health.status = IntegrationStatus.ACTIVE - health.message = "Connected" - health.last_check = now_cn_naive() - health.latency_ms = (now_cn_naive() - start_time).total_seconds() * 1000 - - except Exception as e: - logger.warning(f"[Integration] Plane health check failed: {e}") - health.status = IntegrationStatus.ERROR - health.message = f"Health check failed: {str(e)}" - health.last_check = now_cn_naive() - - return health - - async def check_lark_health(self) -> IntegrationHealth: - """检查飞书集成健康""" - integration_type = IntegrationType.LARK - health = self._health_status[integration_type] - start_time = now_cn_naive() - - if not self.config.lark.enabled or not self.config.lark.app_id: - health.status = IntegrationStatus.INACTIVE - health.message = "Lark integration is disabled" - health.last_check = now_cn_naive() - return health - - try: - # 简单的健康检查:尝试获取访问令牌 - lark = await self._get_lark_service() - token = await lark.get_tenant_access_token() - - if token and token != "mock_token": - health.status = IntegrationStatus.ACTIVE - health.message = "Connected" - else: - health.status = IntegrationStatus.DEGRADED - health.message = "Running in simulation mode" - - health.last_check = now_cn_naive() - health.latency_ms = (now_cn_naive() - start_time).total_seconds() * 1000 - - except Exception as e: - logger.warning(f"[Integration] Lark health check failed: {e}") - health.status = IntegrationStatus.ERROR - health.message = f"Health check failed: {str(e)}" - health.last_check = now_cn_naive() - - return health - - async def update_config(self, config: IntegrationConfig) -> None: - """更新集成配置""" - self.config = config - - # 重新评估健康状态 - await self.check_plane_health() - await self.check_lark_health() - - logger.info("[Integration] Configuration updated") - - async def _get_plane_service(self): - """获取 Plane 服务实例""" - if self._plane_service is None: - from .plane import PlaneService - self._plane_service = PlaneService() - # 应用配置 - if self.config.plane.api_url: - self._plane_service.api_url = self.config.plane.api_url - if self.config.plane.api_key: - self._plane_service.api_key = self.config.plane.api_key - if self.config.plane.workspace_slug: - self._plane_service.workspace_slug = self.config.plane.workspace_slug - if self.config.plane.project_id: - self._plane_service.project_id = self.config.plane.project_id - self._plane_service.is_simulation = not self.config.plane.api_key - return self._plane_service - - async def _get_lark_service(self): - """获取飞书服务实例""" - if self._lark_service is None: - from .lark import LarkService - self._lark_service = LarkService() - # 应用配置 - if self.config.lark.app_id: - self._lark_service.app_id = self.config.lark.app_id - if self.config.lark.app_secret: - self._lark_service.app_secret = self.config.lark.app_secret - if self.config.lark.verification_token: - self._lark_service.verification_token = self.config.lark.verification_token - return self._lark_service - - def _is_integration_available(self, integration_type: IntegrationType) -> bool: - """检查集成是否可用""" - health = self._health_status[integration_type] - return health.status in [IntegrationStatus.ACTIVE, IntegrationStatus.DEGRADED] - - async def _record_success(self, integration_type: IntegrationType) -> None: - """记录成功操作""" - health = self._health_status[integration_type] - health.last_success = now_cn_naive() - health.consecutive_failures = 0 - if health.status == IntegrationStatus.ERROR: - health.status = IntegrationStatus.ACTIVE - - async def _record_failure(self, integration_type: IntegrationType, error: str) -> None: - """记录失败操作""" - health = self._health_status[integration_type] - health.last_error = error - health.error_count += 1 - health.consecutive_failures += 1 - health.last_check = now_cn_naive() - - # 达到降级阈值 - if health.consecutive_failures >= self.MAX_CONSECUTIVE_FAILURES: - health.status = IntegrationStatus.ERROR - health.message = f"Service degraded after {health.consecutive_failures} consecutive failures" - logger.warning(f"[Integration] {integration_type.value} degraded after {health.consecutive_failures} failures") - - async def _health_check_loop(self) -> None: - """健康检查循环""" - while True: - try: - await asyncio.sleep(self.HEALTH_CHECK_INTERVAL) - - await self.check_plane_health() - await self.check_lark_health() - - except asyncio.CancelledError: - break - except Exception as e: - logger.error(f"[Integration] Health check loop error: {e}") - - -# 全局服务实例 -_integration_service = None - - -def get_integration_service(config: Optional[IntegrationConfig] = None, event_bus=None) -> IntegrationService: - """获取集成服务实例""" - global _integration_service - if _integration_service is None: - _integration_service = IntegrationService(config, event_bus) - return _integration_service +"""Compatibility exports for integration service.""" + +from .integration_core import ( + IntegrationConfig, + IntegrationHealth, + IntegrationService, + IntegrationStatus, + IntegrationType, + LarkConfig, + NotificationResult, + PlaneConfig, + TaskSyncResult, + get_integration_service, +) + +__all__ = [ + "IntegrationType", + "IntegrationStatus", + "PlaneConfig", + "LarkConfig", + "IntegrationHealth", + "IntegrationConfig", + "TaskSyncResult", + "NotificationResult", + "IntegrationService", + "get_integration_service", +] diff --git a/backend/app/services/integration_core/__init__.py b/backend/app/services/integration_core/__init__.py new file mode 100644 index 00000000..8152801c --- /dev/null +++ b/backend/app/services/integration_core/__init__.py @@ -0,0 +1,27 @@ +"""Integration service exports.""" + +from .registry import get_integration_service +from .schemas import ( + IntegrationConfig, + IntegrationHealth, + IntegrationStatus, + IntegrationType, + LarkConfig, + NotificationResult, + PlaneConfig, + TaskSyncResult, +) +from .service import IntegrationService + +__all__ = [ + "IntegrationType", + "IntegrationStatus", + "PlaneConfig", + "LarkConfig", + "IntegrationHealth", + "IntegrationConfig", + "TaskSyncResult", + "NotificationResult", + "IntegrationService", + "get_integration_service", +] diff --git a/backend/app/services/integration_core/mixin_clients.py b/backend/app/services/integration_core/mixin_clients.py new file mode 100644 index 00000000..6aead2cd --- /dev/null +++ b/backend/app/services/integration_core/mixin_clients.py @@ -0,0 +1,38 @@ +"""External client factory helpers for integration service.""" + +from __future__ import annotations + + +class IntegrationClientsMixin: + """Lazy-create and configure Plane/Lark service clients.""" + + async def _get_plane_service(self): + """Get Plane service instance.""" + if self._plane_service is None: + from ..plane import PlaneService + + self._plane_service = PlaneService() + if self.config.plane.api_url: + self._plane_service.api_url = self.config.plane.api_url + if self.config.plane.api_key: + self._plane_service.api_key = self.config.plane.api_key + if self.config.plane.workspace_slug: + self._plane_service.workspace_slug = self.config.plane.workspace_slug + if self.config.plane.project_id: + self._plane_service.project_id = self.config.plane.project_id + self._plane_service.is_simulation = not self.config.plane.api_key + return self._plane_service + + async def _get_lark_service(self): + """Get Lark service instance.""" + if self._lark_service is None: + from ..lark import LarkService + + self._lark_service = LarkService() + if self.config.lark.app_id: + self._lark_service.app_id = self.config.lark.app_id + if self.config.lark.app_secret: + self._lark_service.app_secret = self.config.lark.app_secret + if self.config.lark.verification_token: + self._lark_service.verification_token = self.config.lark.verification_token + return self._lark_service diff --git a/backend/app/services/integration_core/mixin_health.py b/backend/app/services/integration_core/mixin_health.py new file mode 100644 index 00000000..26777d6b --- /dev/null +++ b/backend/app/services/integration_core/mixin_health.py @@ -0,0 +1,119 @@ +"""Health/status management for integration service.""" + +from __future__ import annotations + +from .schemas import IntegrationConfig, IntegrationHealth, IntegrationStatus, IntegrationType +from .shared import Dict, logger, now_cn_naive + + +class IntegrationHealthMixin: + """Tracks integration health, stats, and failure recovery.""" + + def get_health_status(self) -> Dict[str, IntegrationHealth]: + """Get health status for all integrations.""" + return {k.value: v for k, v in self._health_status.items()} + + def get_stats(self) -> Dict[str, Dict[str, int]]: + """Get integration stats counters.""" + return {k.value: v for k, v in self._stats.items()} + + async def check_plane_health(self) -> IntegrationHealth: + """Check Plane integration health.""" + integration_type = IntegrationType.PLANE + health = self._health_status[integration_type] + start_time = now_cn_naive() + + if not self.config.plane.enabled or not self.config.plane.api_key: + health.status = IntegrationStatus.INACTIVE + health.message = "Plane integration is disabled" + health.last_check = now_cn_naive() + return health + + try: + plane = await self._get_plane_service() + await plane.get_label_id("__health_check__") + + health.status = IntegrationStatus.ACTIVE + health.message = "Connected" + health.last_check = now_cn_naive() + health.latency_ms = (now_cn_naive() - start_time).total_seconds() * 1000 + + except Exception as exc: + logger.warning(f"[Integration] Plane health check failed: {exc}") + health.status = IntegrationStatus.ERROR + health.message = f"Health check failed: {str(exc)}" + health.last_check = now_cn_naive() + + return health + + async def check_lark_health(self) -> IntegrationHealth: + """Check Lark integration health.""" + integration_type = IntegrationType.LARK + health = self._health_status[integration_type] + start_time = now_cn_naive() + + if not self.config.lark.enabled or not self.config.lark.app_id: + health.status = IntegrationStatus.INACTIVE + health.message = "Lark integration is disabled" + health.last_check = now_cn_naive() + return health + + try: + lark = await self._get_lark_service() + token = await lark.get_tenant_access_token() + + if token and token != "mock_token": + health.status = IntegrationStatus.ACTIVE + health.message = "Connected" + else: + health.status = IntegrationStatus.DEGRADED + health.message = "Running in simulation mode" + + health.last_check = now_cn_naive() + health.latency_ms = (now_cn_naive() - start_time).total_seconds() * 1000 + + except Exception as exc: + logger.warning(f"[Integration] Lark health check failed: {exc}") + health.status = IntegrationStatus.ERROR + health.message = f"Health check failed: {str(exc)}" + health.last_check = now_cn_naive() + + return health + + async def update_config(self, config: IntegrationConfig) -> None: + """Update integration config and refresh health.""" + self.config = config + await self.check_plane_health() + await self.check_lark_health() + logger.info("[Integration] Configuration updated") + + def _is_integration_available(self, integration_type: IntegrationType) -> bool: + """Check if integration is available for runtime operations.""" + health = self._health_status[integration_type] + return health.status in [IntegrationStatus.ACTIVE, IntegrationStatus.DEGRADED] + + async def _record_success(self, integration_type: IntegrationType) -> None: + """Record successful operation and clear failures.""" + health = self._health_status[integration_type] + health.last_success = now_cn_naive() + health.consecutive_failures = 0 + if health.status == IntegrationStatus.ERROR: + health.status = IntegrationStatus.ACTIVE + + async def _record_failure(self, integration_type: IntegrationType, error: str) -> None: + """Record failed operation and degrade when needed.""" + health = self._health_status[integration_type] + health.last_error = error + health.error_count += 1 + health.consecutive_failures += 1 + health.last_check = now_cn_naive() + + if health.consecutive_failures >= self.MAX_CONSECUTIVE_FAILURES: + health.status = IntegrationStatus.ERROR + health.message = ( + f"Service degraded after {health.consecutive_failures} consecutive failures" + ) + logger.warning( + f"[Integration] {integration_type.value} degraded " + f"after {health.consecutive_failures} failures" + ) diff --git a/backend/app/services/integration_core/mixin_lifecycle.py b/backend/app/services/integration_core/mixin_lifecycle.py new file mode 100644 index 00000000..23009a25 --- /dev/null +++ b/backend/app/services/integration_core/mixin_lifecycle.py @@ -0,0 +1,67 @@ +"""Lifecycle and init behaviors for integration service.""" + +from __future__ import annotations + +from .schemas import IntegrationConfig, IntegrationHealth, IntegrationStatus, IntegrationType +from .shared import Dict, Optional, asyncio, logger, now_cn_naive + + +class IntegrationLifecycleMixin: + """Initializes state and manages service lifecycle.""" + + HEALTH_CHECK_INTERVAL = 60 + MAX_CONSECUTIVE_FAILURES = 3 + DEGRADE_THRESHOLD = 5 + + def _init_state(self, config: Optional[IntegrationConfig], event_bus) -> None: + self.config = config or IntegrationConfig() + self.event_bus = event_bus + + self._health_status: Dict[IntegrationType, IntegrationHealth] = { + IntegrationType.PLANE: IntegrationHealth( + type=IntegrationType.PLANE, + status=IntegrationStatus.PENDING, + last_check=now_cn_naive(), + ), + IntegrationType.LARK: IntegrationHealth( + type=IntegrationType.LARK, + status=IntegrationStatus.PENDING, + last_check=now_cn_naive(), + ), + } + + self._stats = { + IntegrationType.PLANE: {"synced": 0, "failed": 0}, + IntegrationType.LARK: {"sent": 0, "failed": 0}, + } + + self._plane_service = None + self._lark_service = None + self._health_check_task: Optional[asyncio.Task] = None + + async def start(self): + """Start integration service.""" + self._health_check_task = asyncio.create_task(self._health_check_loop()) + logger.info("[Integration] Service started") + + async def stop(self): + """Stop integration service.""" + if self._health_check_task: + self._health_check_task.cancel() + try: + await self._health_check_task + except asyncio.CancelledError: + pass + logger.info("[Integration] Service stopped") + + async def _health_check_loop(self) -> None: + """Periodic health check loop.""" + while True: + try: + await asyncio.sleep(self.HEALTH_CHECK_INTERVAL) + await self.check_plane_health() + await self.check_lark_health() + except asyncio.CancelledError: + break + except Exception as exc: + logger.error(f"[Integration] Health check loop error: {exc}") diff --git a/backend/app/services/integration_core/mixin_sync.py b/backend/app/services/integration_core/mixin_sync.py new file mode 100644 index 00000000..acbc1a43 --- /dev/null +++ b/backend/app/services/integration_core/mixin_sync.py @@ -0,0 +1,197 @@ +"""Sync and notification operations for integration service.""" + +from __future__ import annotations + +from .schemas import IntegrationType, NotificationResult, TaskSyncResult +from .shared import Any, Dict, List, logger, now_cn_naive + + +class IntegrationSyncMixin: + """Handles Plane task sync and Lark notifications.""" + + async def sync_tasks_to_plane( + self, + tasks: List[Dict[str, Any]], + is_urgent: bool = False, + ) -> TaskSyncResult: + """Sync tasks to Plane with health-aware fallback.""" + integration_type = IntegrationType.PLANE + health = self._health_status[integration_type] + + if not self._is_integration_available(integration_type): + return TaskSyncResult( + success=False, + integration_type=integration_type, + errors=[f"Plane integration is {health.status.value}"], + ) + + start_time = now_cn_naive() + + try: + plane = await self._get_plane_service() + result = await plane.sync_tasks(tasks, is_urgent) + + if result.get("status") == "success": + self._stats[integration_type]["synced"] += result.get("created_count", 0) + await self._record_success(integration_type) + else: + self._stats[integration_type]["failed"] += len(result.get("errors", [])) + await self._record_failure(integration_type, ", ".join(result.get("errors", []))) + + latency = (now_cn_naive() - start_time).total_seconds() * 1000 + health.latency_ms = latency + + return TaskSyncResult( + success=result.get("status") == "success", + integration_type=integration_type, + tasks_created=result.get("created_count", 0), + errors=result.get("errors", []), + details=result, + ) + + except Exception as exc: + logger.error(f"[Integration] Plane sync failed: {exc}") + await self._record_failure(integration_type, str(exc)) + return TaskSyncResult( + success=False, + integration_type=integration_type, + errors=[str(exc)], + ) + + async def send_strategic_alert( + self, + chat_id: str, + signal_content: str, + decision: Dict[str, Any], + tasks: List[Dict[str, Any]], + ) -> NotificationResult: + """Send strategic alert to Lark.""" + integration_type = IntegrationType.LARK + health = self._health_status[integration_type] + + if not self._is_integration_available(integration_type): + return NotificationResult( + success=False, + integration_type=integration_type, + error=f"Lark integration is {health.status.value}", + ) + + start_time = now_cn_naive() + + try: + lark = await self._get_lark_service() + await lark.send_strategic_alert(chat_id, signal_content, decision, tasks) + + self._stats[integration_type]["sent"] += 1 + await self._record_success(integration_type) + + latency = (now_cn_naive() - start_time).total_seconds() * 1000 + health.latency_ms = latency + + if self.event_bus: + from ...events import Event, EventType + + await self.event_bus.publish( + Event( + type=EventType.LARK_NOTIFY_SUCCESS, + data={ + "type": "strategic_alert", + "chat_id": chat_id, + }, + ) + ) + + return NotificationResult( + success=True, + integration_type=integration_type, + recipients=1, + ) + + except Exception as exc: + logger.error(f"[Integration] Lark notification failed: {exc}") + await self._record_failure(integration_type, str(exc)) + + if self.event_bus: + from ...events import Event, EventType + + await self.event_bus.publish( + Event( + type=EventType.LARK_NOTIFY_FAILED, + data={ + "type": "strategic_alert", + "error": str(exc), + }, + ) + ) + + return NotificationResult( + success=False, + integration_type=integration_type, + error=str(exc), + ) + + async def send_card_message( + self, + chat_id: str, + content: str, + tasks: List[Dict[str, Any]], + ) -> NotificationResult: + """Send card message to Lark.""" + integration_type = IntegrationType.LARK + health = self._health_status[integration_type] + + if not self._is_integration_available(integration_type): + return NotificationResult( + success=False, + integration_type=integration_type, + error=f"Lark integration is {health.status.value}", + ) + + try: + lark = await self._get_lark_service() + await lark.send_card_message(chat_id, content, tasks) + + self._stats[integration_type]["sent"] += 1 + await self._record_success(integration_type) + + return NotificationResult( + success=True, + integration_type=integration_type, + recipients=1, + ) + + except Exception as exc: + logger.error(f"[Integration] Lark card message failed: {exc}") + await self._record_failure(integration_type, str(exc)) + return NotificationResult( + success=False, + integration_type=integration_type, + error=str(exc), + ) + + async def update_plane_issue_status( + self, + issue_id: str, + status: str = "Done", + ) -> bool: + """Update Plane issue status.""" + integration_type = IntegrationType.PLANE + + if not self._is_integration_available(integration_type): + return False + + try: + plane = await self._get_plane_service() + result = await plane.update_issue_status(issue_id, status) + + if "error" not in result: + await self._record_success(integration_type) + return True + + await self._record_failure(integration_type, result.get("error", "Unknown error")) + return False + + except Exception as exc: + logger.error(f"[Integration] Plane status update failed: {exc}") + await self._record_failure(integration_type, str(exc)) + return False diff --git a/backend/app/services/integration_core/registry.py b/backend/app/services/integration_core/registry.py new file mode 100644 index 00000000..e381e732 --- /dev/null +++ b/backend/app/services/integration_core/registry.py @@ -0,0 +1,20 @@ +"""Singleton registry for integration service.""" + +from __future__ import annotations + +from .schemas import IntegrationConfig +from .service import IntegrationService +from .shared import Optional + +_integration_service = None + + +def get_integration_service( + config: Optional[IntegrationConfig] = None, + event_bus=None, +) -> IntegrationService: + """Get singleton integration service instance.""" + global _integration_service + if _integration_service is None: + _integration_service = IntegrationService(config, event_bus) + return _integration_service diff --git a/backend/app/services/integration_core/schemas.py b/backend/app/services/integration_core/schemas.py new file mode 100644 index 00000000..2ebfae0e --- /dev/null +++ b/backend/app/services/integration_core/schemas.py @@ -0,0 +1,98 @@ +"""Data models for integration service.""" + +from __future__ import annotations + +from .shared import Any, BaseModel, Dict, Enum, Field, List, Optional, datetime + + +class IntegrationType(str, Enum): + """Integration type.""" + + PLANE = "plane" + LARK = "lark" + GITHUB = "github" + GITLAB = "gitlab" + + +class IntegrationStatus(str, Enum): + """Integration runtime status.""" + + ACTIVE = "active" + INACTIVE = "inactive" + ERROR = "error" + DEGRADED = "degraded" + PENDING = "pending" + + +class PlaneConfig(BaseModel): + """Plane integration config.""" + + api_url: str = "https://api.plane.so/api/v1" + api_key: str = "" + workspace_slug: str = "" + project_id: str = "" + enabled: bool = True + + +class LarkConfig(BaseModel): + """Lark integration config.""" + + app_id: str = "" + app_secret: str = "" + verification_token: str = "" + encrypt_key: str = "" + default_chat_id: str = "" + enabled: bool = True + + +class IntegrationHealth(BaseModel): + """Integration health record.""" + + type: IntegrationType + status: IntegrationStatus + last_check: datetime + last_success: Optional[datetime] = None + last_error: Optional[str] = None + error_count: int = 0 + consecutive_failures: int = 0 + latency_ms: Optional[float] = None + message: str = "" + + +class IntegrationConfig(BaseModel): + """Composite integration config.""" + + plane: PlaneConfig = Field(default_factory=PlaneConfig) + lark: LarkConfig = Field(default_factory=LarkConfig) + + +class TaskSyncResult(BaseModel): + """Task synchronization result.""" + + success: bool + integration_type: IntegrationType + tasks_created: int = 0 + tasks_updated: int = 0 + errors: List[str] = Field(default_factory=list) + details: Dict[str, Any] = Field(default_factory=dict) + + +class NotificationResult(BaseModel): + """Notification send result.""" + + success: bool + integration_type: IntegrationType + recipients: int = 0 + error: Optional[str] = None + + +__all__ = [ + "IntegrationType", + "IntegrationStatus", + "PlaneConfig", + "LarkConfig", + "IntegrationHealth", + "IntegrationConfig", + "TaskSyncResult", + "NotificationResult", +] diff --git a/backend/app/services/integration_core/service.py b/backend/app/services/integration_core/service.py new file mode 100644 index 00000000..468a301b --- /dev/null +++ b/backend/app/services/integration_core/service.py @@ -0,0 +1,22 @@ +"""Integration service composition.""" + +from __future__ import annotations + +from .mixin_clients import IntegrationClientsMixin +from .mixin_health import IntegrationHealthMixin +from .mixin_lifecycle import IntegrationLifecycleMixin +from .mixin_sync import IntegrationSyncMixin +from .schemas import IntegrationConfig +from .shared import Optional + + +class IntegrationService( + IntegrationLifecycleMixin, + IntegrationClientsMixin, + IntegrationHealthMixin, + IntegrationSyncMixin, +): + """Manage Plane/Lark integrations with health monitoring.""" + + def __init__(self, config: Optional[IntegrationConfig] = None, event_bus=None): + self._init_state(config, event_bus) diff --git a/backend/app/services/integration_core/shared.py b/backend/app/services/integration_core/shared.py new file mode 100644 index 00000000..e8df83dc --- /dev/null +++ b/backend/app/services/integration_core/shared.py @@ -0,0 +1,32 @@ +"""Shared imports and logger for integration service.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timedelta +from enum import Enum +import logging +from typing import Any, Dict, List, Optional + +import httpx +from pydantic import BaseModel, Field + +from ...utils.timezone import now_cn_naive + +logger = logging.getLogger(__name__) + +__all__ = [ + "Any", + "BaseModel", + "Dict", + "Enum", + "Field", + "List", + "Optional", + "asyncio", + "datetime", + "httpx", + "logger", + "now_cn_naive", + "timedelta", +] diff --git a/backend/app/services/llm.py b/backend/app/services/llm.py index 0a52a81e..d08928b0 100644 --- a/backend/app/services/llm.py +++ b/backend/app/services/llm.py @@ -1,3304 +1,5 @@ -"""统一 LLM 服务(DeepSeek / GLM / OpenAI / Ollama)。 - -能力: -1. 多引擎配置持久化(Integration.type=llm_engine) -2. 动态选择激活引擎 -3. OpenAI-Compatible chat/completions 调用 -4. 基础 AI 调用日志写入(ai_call_logs) -""" - from __future__ import annotations -import asyncio -from dataclasses import dataclass -import hashlib -import json -import logging -import os -import re -import time -from datetime import datetime, timedelta, timezone -from pathlib import Path -from typing import Any, Optional -from uuid import uuid4 +from .llm_service import ChatCompletionResult, LLMService, StructuredExtractionResult, llm_service -import httpx -from sqlalchemy import and_, select -from sqlalchemy.exc import OperationalError -from sqlalchemy.ext.asyncio import AsyncSession - -from ..config.ai_prompts import build_default_role_prompt_bundle -from ..database import AsyncSessionLocal -from ..models import AICallLog, Integration -from .model_context_bundle_service import ModelContextBundleService - -from ..utils.timezone import now_cn_aware -try: - import redis.asyncio as redis_async - - REDIS_AVAILABLE = True -except Exception: # pragma: no cover - REDIS_AVAILABLE = False - redis_async = None - -logger = logging.getLogger(__name__) - -SUPPORTED_PROVIDERS = {"deepseek", "glm", "openai", "ollama"} -OPENCLAW_DEFAULT_LOW_MODEL = "GLM-4.7" -OPENCLAW_DEFAULT_HIGH_MODEL = "MiniMax-M2.5" -OPENCLAW_DEFAULT_SAFETY_MODEL = "deepseek-chat" -PROVIDER_DEFAULTS: dict[str, dict[str, str]] = { - "deepseek": { - "name": "DeepSeek", - "base_url": "https://api.deepseek.com/v1", - "model": "deepseek-chat", - "api_env": "DEEPSEEK_API_KEY", - }, - "glm": { - "name": "GLM", - "base_url": "https://open.bigmodel.cn/api/paas/v4", - "model": "GLM-4.7", - "api_env": "GLM_API_KEY", - }, - "openai": { - "name": "OpenAI", - "base_url": "https://api.openai.com/v1", - "model": "gpt-4o-mini", - "api_env": "OPENAI_API_KEY", - }, - "ollama": { - "name": "Ollama", - "base_url": "http://127.0.0.1:11434", - "model": "llama3:latest", - "api_env": "", - }, -} -PROVIDER_ALIASES = { - "zhipu": "glm", - "chatglm": "glm", - "deep-seek": "deepseek", - "local": "ollama", -} -PROMPT_PROFILE_CACHE_TTL_SECONDS = 60.0 -BOOL_TRUE_VALUES = {"1", "true", "yes", "y", "on", "是"} -OPENCLAW_ROUTING_CACHE_TTL_SECONDS = 30.0 -OPENCLAW_DEFAULT_RULES_RELATIVE_PATH = "../.openclaw/pmflow-routing-rules.json" -OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS = 120 -OPENCLAW_AGENTS_TIMEOUT_SECONDS_MAX = 120 -DEFAULT_TIER_MODEL_OVERRIDES = { - "low": OPENCLAW_DEFAULT_LOW_MODEL, - "high": OPENCLAW_DEFAULT_HIGH_MODEL, - "safety": OPENCLAW_DEFAULT_SAFETY_MODEL, -} -DEFAULT_HIGH_TIER_HINTS = ( - "classify_intent_complex", - "project_from_text", - "feishu_project_prefill", - "feishu_task_orchestration", - "feishu_project_signal_classification", - "adaptive_rag_answer_deep", - "rag_db_project_semantic_retrieval", - "analyze_food_calories", - "sstc_ai_breakdown", - "generate_task_breakdown", - "recommend_assignee", - "alignment", - "charter", - "cognitive_conflict", - "blocker", - "model_requirement", - "signal_planner_analyze", -) -DEFAULT_LOW_TIER_HINTS = ( - "feishu_level_classification", - "feishu_signal_extraction", - "feishu_humanized_reply", - "signal_source_onboarding_plan", - "signal_source_ai_worker_execution", - "extract_context_insights", - "project_draft_", - "adaptive_rag_answer_normal", - "rag_intent_classification", - "classify_intent", -) -STRUCTURED_OUTPUT_FUNCTION_HINTS = ( - "feishu_level_classification", - "feishu_signal_extraction", - "signal_source_onboarding_plan", - "signal_source_ai_worker_execution", - "rag_intent_classification", - "classify_intent", - "feishu_project_prefill", - "feishu_task_orchestration", - "generate_task_breakdown", - "generate_charter_from_signal", - "decompose_tasks_from_charter", - "recommend_assignees_for_task", - "evaluate_model_requirement", - "calculate_alignment_score", - "detect_cognitive_conflicts", - "rag_db_project_semantic_retrieval", - "workload_optimize_prompt", - "analyze_cost_roi", - "analyze_food_calories", - "sstc_ai_breakdown", - "sstc_charter_localize_zh", - "sstc_charter_prefill", - "signal_planner_analyze", -) -LOW_TO_HIGH_ESCALATION_FALLBACK_MARKERS = ( - "不知道", - "不清楚", - "无法回答", - "无法确定", - "无法判断", - "信息不足", - "缺少信息", - "没有足够信息", - "请提供更多信息", - "抱歉", - "不能提供", - "无法访问", - "as an ai", - "i'm sorry", - "cannot answer", - "don't know", -) -FAST_LOCAL_FALLBACK_FUNCTION_HINTS = ( - "signal_source_onboarding_plan", - "signal_source_ai_worker_execution", -) -FAST_LOCAL_PLANNER_FUNCTION_HINTS = ( - "signal_source_onboarding_plan", -) -FAST_LOCAL_WORKER_FUNCTION_HINTS = ("signal_source_ai_worker_execution",) -FAST_LOCAL_FALLBACK_FAST_TIMEOUT_SECONDS = 20.0 -FAST_LOCAL_FALLBACK_COMPLETE_TIMEOUT_SECONDS = 35.0 -STRUCTURED_CACHE_TTL_SECONDS = 300 -LLM_ENGINE_SETTINGS_DEFAULTS = { - "enable_local_kb_qa": False, - "local_kb_qa_scope": "kb_qa_only", -} -LOCAL_KB_QA_FUNCTION_PREFIXES = ( - "adaptive_rag_answer_", -) -LOCAL_KB_QA_FUNCTION_EXACT = { - "rag_intent_classification", - "rag_db_project_semantic_retrieval", -} -STRICT_MODEL_ERROR_FUNCTION_PREFIXES = ( - "adaptive_rag_answer_", -) -STRICT_MODEL_ERROR_FUNCTION_EXACT = { - "rag_db_project_semantic_retrieval", -} -FAIL_FAST_ERROR_MARKERS = ( - "primary_engine_empty_response", - "high_tier_empty_response", - "llm 返回空内容", - "返回空内容", - "empty_response", - "provider_circuit_open", -) -CIRCUIT_BREAKER_FAILURE_THRESHOLD = 2 -CIRCUIT_BREAKER_COOLDOWN_SECONDS = 300.0 -CIRCUIT_BREAKER_EXEMPT_FUNCTION_PREFIXES = ( - "project_from_text_", - "project_draft_", -) -DDL_API_GUARDRAIL_FUNCTION_EXACT = { - "classify_intent_ai_pmo_kernel", - "classify_intent_complex", -} -DDL_API_GUARDRAIL_SYSTEM_PROMPT = """ -硬性约束(最高优先级): -1. 禁止直接执行或建议执行数据库 DDL SQL(CREATE/ALTER/DROP/TRUNCATE/RENAME/COMMENT/GRANT/REVOKE)。 -2. 所有数据库 DDL 变更必须通过受控接口或迁移流程(例如 Alembic migration)。 -3. 若输入要求直接改库执行 DDL: - - `classify_intent_ai_pmo_kernel` / `classify_intent_complex` 必须返回 `query.read_only`,并在 reasoning 包含 `ddl_api_required`。 -""".strip() -STRUCTURED_OBJECT_SCHEMAS: dict[str, dict[str, Any]] = { - "ai_pmo_signal_title_refine": { - "required_keys": ("title",), - "type_checks": { - "title": "str", - }, - }, - "signal_source_onboarding_plan": { - "required_keys": ("should_onboard", "summary"), - "type_checks": { - "should_onboard": "bool", - "summary": "str", - }, - "optional_type_checks": { - "worker_roles": ("list",), - "execution_instruction": ("str_or_none",), - "source": ("dict_or_none",), - }, - }, - "signal_source_ai_worker_execution": { - "required_keys": ("summary", "deliverables", "risks", "next_actions"), - "type_checks": { - "summary": "str", - "deliverables": "list", - "risks": "list", - "next_actions": "list", - }, - }, - "classify_intent_ai_pmo_kernel": { - "required_keys": ("confidence", "reasoning"), - "type_checks": { - "confidence": "number", - "reasoning": "str", - }, - "optional_type_checks": { - "intent": ("str_or_none",), - "family": ("str_or_none",), - "action": ("str_or_none",), - "project_id": ("number_or_none",), - "requested_count": ("number_or_none",), - "requires_confirmation": ("bool",), - }, - }, - "classify_intent_complex": { - "required_keys": ("confidence", "reasoning"), - "type_checks": { - "confidence": "number", - "reasoning": "str", - }, - "optional_type_checks": { - "intent": ("str_or_none",), - "family": ("str_or_none",), - "action": ("str_or_none",), - "project_id": ("number_or_none",), - "requested_count": ("number_or_none",), - "requires_confirmation": ("bool",), - }, - }, - "signal_planner_analyze": { - "required_keys": ("summary", "priority", "execution_plan", "risks", "confidence"), - "type_checks": { - "summary": "str", - "priority": "str", - "execution_plan": "list", - "risks": "list", - "confidence": "number", - }, - "optional_type_checks": { - "assignee_user_id": ("number_or_none",), - "assignee_reason": ("str_or_none",), - }, - }, -} - - -def _normalize_provider(raw: Optional[str]) -> str: - provider = str(raw or "").strip().lower() - provider = PROVIDER_ALIASES.get(provider, provider) - if provider not in SUPPORTED_PROVIDERS: - raise ValueError("provider 仅支持 deepseek/glm/openai/ollama") - return provider - - -def _mask_secret(secret: str) -> str: - value = str(secret or "").strip() - if not value: - return "" - if len(value) <= 8: - return "*" * len(value) - return f"{value[:4]}...{value[-4:]}" - - -def _safe_json(value: Any) -> dict[str, Any]: - if isinstance(value, dict): - try: - # 返回深拷贝,避免原地修改导致 SQLAlchemy 不感知 JSON 字段变更 - return json.loads(json.dumps(value)) - except Exception: - return dict(value) - if isinstance(value, str): - try: - parsed = json.loads(value) - return parsed if isinstance(parsed, dict) else {} - except Exception: - return {} - return {} - - -def _env_bool(name: str, default: bool = False) -> bool: - raw = os.getenv(name) - if raw is None: - return default - return str(raw).strip().lower() in BOOL_TRUE_VALUES - - -def _parse_csv_items(raw: str) -> tuple[str, ...]: - rows = [item.strip().lower() for item in str(raw or "").split(",") if item.strip()] - return tuple(rows) - - -def _cn_now_text() -> str: - cn_tz = timezone(timedelta(hours=8)) - return datetime.now(cn_tz).strftime("%Y-%m-%d %H:%M:%S") - - -def _cn_now_text_ms() -> str: - cn_tz = timezone(timedelta(hours=8)) - return datetime.now(cn_tz).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] - - -def _runtime_log(scope: str, message: str) -> None: - print(f"[{_cn_now_text()}][{scope}] {message}") - - -def _looks_like_openclaw_timeout_message(text: str) -> bool: - lowered = str(text or "").strip().lower() - if not lowered: - return False - timeout_signals = ( - "request timed out before a response was generated", - "agents.defaults.timeoutseconds", - "timeoutseconds", - "before a response was generated", - ) - return any(signal in lowered for signal in timeout_signals) - - -@dataclass -class ChatCompletionResult: - text: str - raw_response: Any = None - - -@dataclass -class StructuredExtractionResult: - payload: Optional[dict[str, Any]] - source_field: str - provider: str - model: str - error_code: Optional[str] = None - raw_text: str = "" - validation: str = "invalid" - from_cache: bool = False - - -class LLMService: - _provider_circuit_state: dict[str, dict[str, Any]] = {} - - def __init__(self) -> None: - self.request_timeout = float(os.getenv("LLM_REQUEST_TIMEOUT", "45")) - self.redis_url = os.getenv("REDIS_URL") - self._redis: Optional[Any] = None - self._prompt_profile_cache: dict[int, tuple[float, dict[str, str]]] = {} - self._openclaw_routing_cache: tuple[float, dict[str, Any]] | None = None - - async def _get_redis(self): - if not REDIS_AVAILABLE or not self.redis_url: - return None - if self._redis is None: - self._redis = redis_async.from_url(self.redis_url, decode_responses=True) - return self._redis - - @staticmethod - def _normalize_prompt_for_hash(prompt: str) -> str: - return re.sub(r"\s+", " ", str(prompt or "")).strip() - - @classmethod - def _structured_cache_key(cls, *, function_name: str, model: str, prompt: str) -> str: - normalized_prompt = cls._normalize_prompt_for_hash(prompt) - digest = hashlib.sha256(normalized_prompt.encode("utf-8")).hexdigest() - return f"pmflow:structured:{function_name}:{model}:{digest}" - - async def _structured_cache_get(self, *, key: str) -> Optional[dict[str, Any]]: - redis = await self._get_redis() - if redis is None: - return None - try: - raw = await redis.get(key) - except Exception: - return None - if not raw: - return None - try: - parsed = json.loads(raw) - except Exception: - return None - return parsed if isinstance(parsed, dict) else None - - async def _structured_cache_set(self, *, key: str, value: dict[str, Any], ttl: int = STRUCTURED_CACHE_TTL_SECONDS) -> None: - redis = await self._get_redis() - if redis is None: - return - try: - await redis.setex(key, ttl, json.dumps(value, ensure_ascii=False)) - except Exception: - return - - @staticmethod - def _provider_circuit_key(*, provider: str, base_url: str, model: str) -> str: - return f"{str(provider or '').strip().lower()}|{str(base_url or '').strip().rstrip('/')}|{str(model or '').strip()}" - - @staticmethod - def _http_status_from_exception(exc: Exception) -> Optional[int]: - if isinstance(exc, httpx.HTTPStatusError) and exc.response is not None: - try: - return int(exc.response.status_code) - except Exception: - return None - return None - - @staticmethod - def _is_retryable_http_status(status_code: Optional[int]) -> bool: - if status_code is None: - return False - return int(status_code) in {408, 409, 429, 500, 502, 503, 504} - - @staticmethod - def _parse_retry_after_seconds(raw_value: Optional[str]) -> Optional[float]: - value = str(raw_value or "").strip() - if not value: - return None - try: - seconds = float(value) - except Exception: - return None - if seconds <= 0: - return None - return min(seconds, 10.0) - - @classmethod - def _http_retry_delay_seconds( - cls, - *, - status_code: Optional[int], - attempt: int, - retry_after_header: Optional[str], - ) -> float: - retry_after = cls._parse_retry_after_seconds(retry_after_header) - if retry_after is not None: - return retry_after - base = 0.8 - if int(status_code or 0) == 429: - base = 1.2 - capped_attempt = max(0, min(int(attempt), 6)) - return min(base * (2 ** capped_attempt), 6.0) - - @classmethod - def _is_fail_fast_exception(cls, exc: Exception) -> bool: - status_code = cls._http_status_from_exception(exc) - if status_code in {401, 403}: - return True - text = str(exc or "").strip().lower() - return any(marker in text for marker in FAIL_FAST_ERROR_MARKERS) - - @classmethod - def _is_provider_circuit_open_exception(cls, exc: Exception) -> bool: - return "provider_circuit_open" in str(exc or "").strip().lower() - - @classmethod - def _is_circuit_breaker_worthy_exception(cls, exc: Exception) -> bool: - if cls._is_fail_fast_exception(exc): - return True - if isinstance(exc, httpx.RequestError): - return True - status_code = cls._http_status_from_exception(exc) - return cls._is_retryable_http_status(status_code) - - @classmethod - def _is_provider_circuit_open(cls, circuit_key: str) -> bool: - state = cls._provider_circuit_state.get(str(circuit_key)) - if not state: - return False - try: - open_until = float(state.get("open_until") or 0.0) - except Exception: - open_until = 0.0 - now = time.monotonic() - if open_until <= now: - if open_until: - cls._provider_circuit_state.pop(str(circuit_key), None) - return False - return True - - @classmethod - def _record_provider_success(cls, circuit_key: str) -> None: - cls._provider_circuit_state.pop(str(circuit_key), None) - - @classmethod - def _record_provider_failure(cls, circuit_key: str, exc: Exception) -> None: - if cls._is_provider_circuit_open_exception(exc): - return - if not cls._is_circuit_breaker_worthy_exception(exc): - return - now = time.monotonic() - state = dict(cls._provider_circuit_state.get(str(circuit_key)) or {}) - consecutive = int(state.get("consecutive_failures") or 0) + 1 - open_until = float(state.get("open_until") or 0.0) - if consecutive >= CIRCUIT_BREAKER_FAILURE_THRESHOLD: - open_until = now + CIRCUIT_BREAKER_COOLDOWN_SECONDS - cls._provider_circuit_state[str(circuit_key)] = { - "consecutive_failures": consecutive, - "open_until": open_until, - "last_error": str(exc)[:240], - "updated_at_monotonic": now, - } - - # --------------------------------------------------------------------- - # Engine config management - # --------------------------------------------------------------------- - async def list_engines(self, db: AsyncSession, tenant_id: int = 1) -> dict[str, Any]: - _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) - engines = bundle.get("engines", []) - return { - "active_engine_id": bundle.get("active_engine_id"), - "engines": [self._sanitize_engine_for_response(item) for item in engines], - "settings": self._normalize_engine_settings(bundle.get("settings")), - } - - async def update_engine_settings( - self, - db: AsyncSession, - *, - tenant_id: int, - enable_local_kb_qa: Optional[bool] = None, - ) -> dict[str, Any]: - integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) - settings = self._normalize_engine_settings(bundle.get("settings")) - if enable_local_kb_qa is not None: - settings["enable_local_kb_qa"] = bool(enable_local_kb_qa) - bundle["settings"] = settings - integration.config = json.loads(json.dumps(bundle)) - await db.commit() - return settings - - async def create_engine( - self, - db: AsyncSession, - *, - tenant_id: int, - name: str, - provider: str, - api_key: Optional[str], - model: Optional[str] = None, - base_url: Optional[str] = None, - set_active: bool = True, - ) -> dict[str, Any]: - provider = _normalize_provider(provider) - api_key = str(api_key or "").strip() - if provider != "ollama" and not api_key: - raise ValueError("api_key 不能为空") - - integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) - now = now_cn_aware().isoformat() - engine_id = str(uuid4()) - defaults = PROVIDER_DEFAULTS[provider] - engine = { - "id": engine_id, - "name": (name or defaults["name"]).strip()[:80] or defaults["name"], - "provider": provider, - "api_key": api_key, - "model": (model or defaults["model"]).strip() or defaults["model"], - "base_url": (base_url or defaults["base_url"]).strip().rstrip("/") or defaults["base_url"], - "created_at": now, - "updated_at": now, - } - engines = bundle.get("engines", []) - engines.append(engine) - bundle["engines"] = engines - if set_active or not bundle.get("active_engine_id"): - bundle["active_engine_id"] = engine_id - integration.config = json.loads(json.dumps(bundle)) - integration.is_active = True - await db.commit() - return self._sanitize_engine_for_response(engine) - - async def update_engine( - self, - db: AsyncSession, - *, - tenant_id: int, - engine_id: str, - name: Optional[str] = None, - provider: Optional[str] = None, - api_key: Optional[str] = None, - model: Optional[str] = None, - base_url: Optional[str] = None, - ) -> dict[str, Any]: - integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) - engines = bundle.get("engines", []) - target: Optional[dict[str, Any]] = None - for item in engines: - if str(item.get("id")) == str(engine_id): - target = item - break - if not target: - raise ValueError("engine 不存在") - - if provider is not None: - target["provider"] = _normalize_provider(provider) - active_provider = _normalize_provider(target.get("provider")) - defaults = PROVIDER_DEFAULTS[active_provider] - - if name is not None: - target["name"] = (name or "").strip()[:80] or target.get("name") or defaults["name"] - if api_key is not None: - api_key_val = str(api_key).strip() - if api_key_val: - target["api_key"] = api_key_val - if model is not None: - target["model"] = (model or "").strip() or defaults["model"] - if base_url is not None: - target["base_url"] = (base_url or "").strip().rstrip("/") or defaults["base_url"] - - target["updated_at"] = now_cn_aware().isoformat() - integration.config = json.loads(json.dumps(bundle)) - await db.commit() - return self._sanitize_engine_for_response(target) - - async def delete_engine( - self, - db: AsyncSession, - *, - tenant_id: int, - engine_id: str, - ) -> dict[str, Any]: - integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) - engines = bundle.get("engines", []) - kept = [item for item in engines if str(item.get("id")) != str(engine_id)] - if len(kept) == len(engines): - raise ValueError("engine 不存在") - bundle["engines"] = kept - active_engine_id = str(bundle.get("active_engine_id") or "") - if active_engine_id == str(engine_id): - bundle["active_engine_id"] = kept[0]["id"] if kept else None - integration.config = json.loads(json.dumps(bundle)) - await db.commit() - return { - "deleted_engine_id": str(engine_id), - "active_engine_id": bundle.get("active_engine_id"), - "remaining_count": len(kept), - } - - async def activate_engine( - self, - db: AsyncSession, - *, - tenant_id: int, - engine_id: str, - ) -> dict[str, Any]: - integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) - engines = bundle.get("engines", []) - exists = any(str(item.get("id")) == str(engine_id) for item in engines) - if not exists: - raise ValueError("engine 不存在") - bundle["active_engine_id"] = str(engine_id) - integration.config = json.loads(json.dumps(bundle)) - await db.commit() - return {"active_engine_id": str(engine_id)} - - async def test_engine_connection( - self, - db: AsyncSession, - *, - tenant_id: int, - engine_id: Optional[str] = None, - prompt: Optional[str] = None, - max_tokens: int = 64, - ) -> dict[str, Any]: - test_prompt = (prompt or "你是连通性测试助手。请只返回: OK").strip() - if not test_prompt: - raise ValueError("测试 prompt 不能为空") - - engine = await self._resolve_engine_for_test( - db, - tenant_id=tenant_id, - engine_id=engine_id, - ) - response_text = "" - status = "success" - started = time.perf_counter() - try: - transient_retry_budget = 2 - transient_retry_attempt = 0 - while True: - try: - response_text = await self._chat_completion( - provider=str(engine.get("provider") or ""), - prompt=test_prompt, - system_prompt="", - api_key=str(engine.get("api_key") or ""), - base_url=str(engine.get("base_url") or ""), - model=str(engine.get("model") or ""), - temperature=0.0, - max_tokens=max_tokens, - ) - break - except Exception as transient_exc: - status_code = self._http_status_from_exception(transient_exc) - can_retry_transient = ( - transient_retry_budget > 0 - and ( - self._is_retryable_http_status(status_code) - or isinstance(transient_exc, httpx.RequestError) - ) - ) - if not can_retry_transient: - raise - retry_after_header = None - if isinstance(transient_exc, httpx.HTTPStatusError) and transient_exc.response is not None: - retry_after_header = transient_exc.response.headers.get("Retry-After") - retry_delay = self._http_retry_delay_seconds( - status_code=status_code, - attempt=transient_retry_attempt, - retry_after_header=retry_after_header, - ) - _runtime_log( - "LLM", - ( - "连通测试重试:" - f"engine={engine.get('provider')}/{engine.get('model')}, " - f"status={status_code or 'request_error'}, " - f"retry_in={retry_delay:.2f}s, left={transient_retry_budget - 1}" - ), - ) - transient_retry_budget -= 1 - transient_retry_attempt += 1 - await asyncio.sleep(retry_delay) - if not response_text.strip(): - raise ValueError("测试响应为空") - latency_ms = int((time.perf_counter() - started) * 1000) - return { - "ok": True, - "engine_id": str(engine.get("id") or ""), - "name": str(engine.get("name") or ""), - "provider": str(engine.get("provider") or ""), - "model": str(engine.get("model") or ""), - "base_url": str(engine.get("base_url") or ""), - "api_key_masked": _mask_secret(str(engine.get("api_key") or "")), - "latency_ms": latency_ms, - "response_preview": response_text[:300], - } - except Exception as exc: - status = "failed" - response_text = f"[error] {exc}" - status_code = self._http_status_from_exception(exc) - if int(status_code or 0) == 429: - raise ValueError("引擎连通测试失败: 上游限流(429),请稍后重试或更换 API Key。") from exc - raise ValueError(f"引擎连通测试失败: {exc}") from exc - finally: - try: - await self._log_ai_call( - tenant_id=tenant_id, - function_name="test_engine_connection", - prompt=test_prompt, - response=response_text, - status=status, - provider=str(engine.get("provider") or ""), - model=str(engine.get("model") or ""), - ) - except Exception as log_exc: - _runtime_log("LLM", f"测试调用日志写入失败:{log_exc}") - - # --------------------------------------------------------------------- - # LLM calls - # --------------------------------------------------------------------- - async def generate(self, prompt: str, **kwargs: Any) -> str: - call_kwargs = dict(kwargs) - db = call_kwargs.pop("db", None) or call_kwargs.pop("db_session", None) - if db is None: - async with AsyncSessionLocal() as session: - return await self._generate_with_db(session, prompt, **call_kwargs) - return await self._generate_with_db(db, prompt, **call_kwargs) - - async def generate_structured_object(self, prompt: str, **kwargs: Any) -> StructuredExtractionResult: - call_kwargs = dict(kwargs) - db = call_kwargs.pop("db", None) or call_kwargs.pop("db_session", None) - if db is None: - async with AsyncSessionLocal() as session: - return await self._generate_structured_with_db(session, prompt, **call_kwargs) - return await self._generate_structured_with_db(db, prompt, **call_kwargs) - - async def _inject_model_context_bundle_prompt( - self, - *, - db: Any, - tenant_id: int, - function_name: str, - prompt: str, - ) -> str: - raw_prompt = str(prompt or "") - normalized_function = str(function_name or "").strip().lower() - if not ModelContextBundleService.should_inject_context(normalized_function): - return raw_prompt - # 单测里可能传入 object()/mock 作为 db 占位,此时直接跳过注入。 - if not hasattr(db, "execute"): - return raw_prompt - try: - return await ModelContextBundleService.inject_prompt_with_bundle( - db, - tenant_id=int(tenant_id), - function_name=normalized_function, - prompt=raw_prompt, - ) - except Exception as exc: - _runtime_log("LLM上下文", f"上下文注入失败,已降级原始 prompt:function={normalized_function}, error={exc}") - return raw_prompt - - async def _generate_with_db(self, db: AsyncSession, prompt: str, **kwargs: Any) -> str: - started_at = time.perf_counter() - tenant_id = int(kwargs.get("tenant_id") or 1) - function_name = str(kwargs.get("function_name") or "llm_generate") - temperature = float(kwargs.get("temperature") or 0.2) - max_tokens = int(kwargs.get("max_tokens") or 1024) - use_prompt_profile = bool(kwargs.get("use_prompt_profile", True)) - response_mode = str(kwargs.get("response_mode") or "complete").strip().lower() or "complete" - response_text = "" - status = "success" - routing_decision = "primary_engine" - engine_meta: dict[str, Any] = {} - prompt_profile = {"role_name": "", "system_prompt": ""} - prompt_with_context = await self._inject_model_context_bundle_prompt( - db=db, - tenant_id=tenant_id, - function_name=function_name, - prompt=prompt, - ) - merged_prompt = self._merge_prompt_with_profile( - prompt=prompt_with_context, - prompt_profile=prompt_profile, - function_name=function_name, - ) - - def _log_stub_failure(error_obj: Exception, *, used_stub: bool = False) -> None: - elapsed_ms = (time.perf_counter() - started_at) * 1000 - provider = str(engine_meta.get("provider") or "") - model = str(engine_meta.get("model") or "") - outcome = "兜底 stub" if used_stub else "显式报错" - logger.warning( - "[LLM_FAIL] 生成失败 → %s function=%s provider=%s model=%s error=%r duration_ms=%.0f", - outcome, - function_name, - provider, - model, - error_obj, - elapsed_ms, - ) - - try: - if use_prompt_profile: - prompt_profile = await self._resolve_active_prompt_profile(db, tenant_id=tenant_id) - merged_prompt = self._merge_prompt_with_profile( - prompt=prompt_with_context, - prompt_profile=prompt_profile, - function_name=function_name, - ) - engine = await self._resolve_engine_for_call( - db, - tenant_id=tenant_id, - function_name=function_name, - ) - engine_meta = engine - provider = str(engine.get("provider") or "") - model = str(engine.get("model") or "") - response_text = await self._chat_completion( - provider=provider, - prompt=prompt_with_context, - system_prompt=prompt_profile.get("system_prompt") or "", - api_key=str(engine.get("api_key") or ""), - base_url=str(engine.get("base_url") or ""), - model=model, - temperature=temperature, - max_tokens=max_tokens, - function_name=function_name, - response_mode=response_mode, - ) - if not response_text.strip(): - raise ValueError("primary_engine_empty_response") - - if self._is_invalid_structured_output(function_name=function_name, response_text=response_text): - raise ValueError("invalid_structured_output") - return response_text - except Exception as exc: - failover_response, failover_engine = await self._retry_with_high_tier_after_low_model_failure( - db=db, - tenant_id=tenant_id, - function_name=function_name, - failed_engine=engine_meta, - prompt=prompt_with_context, - system_prompt=str(prompt_profile.get("system_prompt") or ""), - temperature=temperature, - max_tokens=max_tokens, - response_mode=response_mode, - error=exc, - ) - if failover_response and isinstance(failover_engine, dict): - routing_decision = "glm47_error_escalate_to_high" - status = "success_failover" - response_text = failover_response - engine_meta = failover_engine - return failover_response - status = "failed" - reason_text = self._build_model_error_message( - function_name=function_name, - provider=str(engine_meta.get("provider") or ""), - model=str(engine_meta.get("model") or ""), - error=exc, - ) - response_text = reason_text - _log_stub_failure(exc, used_stub=False) - raise RuntimeError(reason_text) from exc - finally: - try: - merged_prompt = self._merge_prompt_with_profile( - prompt=prompt_with_context, - prompt_profile=prompt_profile, - function_name=function_name, - ) - await self._log_ai_call( - tenant_id=tenant_id, - function_name=function_name, - prompt=merged_prompt, - response=response_text, - status=status, - provider=str(engine_meta.get("provider") or ""), - model=str(engine_meta.get("model") or ""), - ) - if routing_decision != "primary_engine": - _runtime_log( - "LLM路由", - ( - f"routing_decision={routing_decision}, " - f"function={function_name}, provider={engine_meta.get('provider')}, model={engine_meta.get('model')}" - ), - ) - except Exception as log_exc: - _runtime_log("LLM", f"调用日志写入失败:{log_exc}") - - async def _generate_structured_with_db( - self, - db: AsyncSession, - prompt: str, - **kwargs: Any, - ) -> StructuredExtractionResult: - tenant_id = int(kwargs.get("tenant_id") or 1) - function_name = str(kwargs.get("function_name") or "llm_generate").strip().lower() or "llm_generate" - temperature = float(kwargs.get("temperature") or 0.0) - max_tokens = int(kwargs.get("max_tokens") or 1024) - use_prompt_profile = bool(kwargs.get("use_prompt_profile", True)) - response_mode = str(kwargs.get("response_mode") or "complete").strip().lower() or "complete" - prompt_profile = {"role_name": "", "system_prompt": ""} - prompt_with_context = await self._inject_model_context_bundle_prompt( - db=db, - tenant_id=tenant_id, - function_name=function_name, - prompt=prompt, - ) - merged_prompt = prompt_with_context - provider = "" - model = "" - engine_meta: dict[str, Any] = {} - status = "success" - response_text = "" - try: - if use_prompt_profile: - prompt_profile = await self._resolve_active_prompt_profile(db, tenant_id=tenant_id) - merged_prompt = self._merge_prompt_with_profile( - prompt=prompt_with_context, - prompt_profile=prompt_profile, - function_name=function_name, - ) - engine = await self._resolve_engine_for_call( - db, - tenant_id=tenant_id, - function_name=function_name, - ) - engine_meta = engine - provider = str(engine.get("provider") or "") - model = str(engine.get("model") or "") - cache_key = "" - if self._structured_cache_enabled(function_name): - cache_key = self._structured_cache_key( - function_name=function_name, - model=model or "unknown", - prompt=merged_prompt, - ) - cached = await self._structured_cache_get(key=cache_key) - if cached is not None: - status = "success_cache_hit" - result = StructuredExtractionResult( - payload=cached, - source_field="cache", - provider=provider, - model=model, - raw_text=json.dumps(cached, ensure_ascii=False), - validation="ok", - from_cache=True, - ) - _runtime_log( - "LLM结构化", - f"function={function_name}, structured_source=cache, validation=ok, fallback=false", - ) - return result - - completion = await self._chat_completion_raw( - provider=provider, - prompt=prompt_with_context, - system_prompt=prompt_profile.get("system_prompt") or "", - api_key=str(engine.get("api_key") or ""), - base_url=str(engine.get("base_url") or ""), - model=model, - temperature=temperature, - max_tokens=max_tokens, - function_name=function_name, - response_mode=response_mode, - ) - response_text = completion.text - extraction = self._extract_structured_object_from_response( - function_name=function_name, - response=completion.raw_response, - fallback_text=completion.text, - provider=provider, - model=model, - ) - _runtime_log( - "LLM结构化", - ( - f"function={function_name}, structured_source={extraction.source_field or 'none'}, " - f"validation={extraction.validation}, fallback=false" - ), - ) - if extraction.payload is None: - raise ValueError(str(extraction.error_code or "structured_schema_invalid")) - if extraction.payload is not None and cache_key: - await self._structured_cache_set(key=cache_key, value=extraction.payload) - status = extraction.error_code or "success" - return extraction - except Exception as exc: - failover_result, failover_engine = await self._retry_structured_with_high_tier_after_low_model_failure( - db=db, - tenant_id=tenant_id, - function_name=function_name, - failed_engine=engine_meta, - prompt=prompt_with_context, - system_prompt=str(prompt_profile.get("system_prompt") or ""), - temperature=temperature, - max_tokens=max_tokens, - response_mode=response_mode, - error=exc, - ) - if isinstance(failover_result, StructuredExtractionResult) and isinstance(failover_engine, dict): - provider = str(failover_engine.get("provider") or "") - model = str(failover_engine.get("model") or "") - response_text = failover_result.raw_text or json.dumps(failover_result.payload or {}, ensure_ascii=False) - status = failover_result.error_code or "success_failover" - return failover_result - error_code = self._classify_structured_exception(exc) - status = error_code - reason_text = self._build_model_error_message( - function_name=function_name, - provider=provider, - model=model, - error=exc, - ) - response_text = reason_text - _runtime_log( - "LLM结构化", - f"function={function_name}, structured_source=none, validation=invalid, fallback=false", - ) - raise RuntimeError(reason_text) from exc - finally: - try: - log_payload = response_text or status - await self._log_ai_call( - tenant_id=tenant_id, - function_name=function_name, - prompt=merged_prompt, - response=log_payload, - status=status, - provider=provider, - model=model, - ) - except Exception as log_exc: - _runtime_log("LLM", f"结构化调用日志写入失败:{log_exc}") - - async def _chat_completion( - self, - *, - provider: str, - prompt: str, - system_prompt: str, - api_key: str, - base_url: str, - model: str, - temperature: float, - max_tokens: int, - function_name: str = "", - response_mode: str = "complete", - ) -> str: - result = await self._chat_completion_raw( - provider=provider, - prompt=prompt, - system_prompt=system_prompt, - api_key=api_key, - base_url=base_url, - model=model, - temperature=temperature, - max_tokens=max_tokens, - function_name=function_name, - response_mode=response_mode, - ) - return result.text - - async def _chat_completion_raw( - self, - *, - provider: str, - prompt: str, - system_prompt: str, - api_key: str, - base_url: str, - model: str, - temperature: float, - max_tokens: int, - function_name: str = "", - response_mode: str = "complete", - ) -> ChatCompletionResult: - provider_name = _normalize_provider(provider) - structured_output = self._structured_output_required(function_name) - request_timeout = self._request_timeout_for_function(function_name, response_mode=response_mode) - is_openclaw_gateway_request = self._is_openclaw_gateway_request( - provider_name=provider_name, - base_url=base_url, - ) - openclaw_agents_timeout_seconds = self._openclaw_agents_timeout_seconds() - if is_openclaw_gateway_request: - # 网关内部 agent 超时需小于客户端超时,避免本地请求先被中止。 - request_timeout = max(float(request_timeout), float(openclaw_agents_timeout_seconds) + 5.0) - use_provider_circuit_breaker = self._use_provider_circuit_breaker(function_name) - circuit_key = self._provider_circuit_key( - provider=provider_name, - base_url=base_url, - model=model, - ) - if use_provider_circuit_breaker and self._is_provider_circuit_open(circuit_key): - raise ValueError(f"provider_circuit_open:{provider_name}:{model}") - key = api_key.strip() - messages: list[dict[str, str]] = [] - cleaned_system_prompt = self._inject_function_guardrails( - system_prompt=system_prompt, - function_name=function_name, - ) - if cleaned_system_prompt: - messages.append({"role": "system", "content": cleaned_system_prompt}) - messages.append({"role": "user", "content": prompt}) - request_trace_id = uuid4().hex[:12] - request_started_at_perf = time.perf_counter() - function_label = str(function_name or "llm_generate") - openclaw_agent_timeout = int(openclaw_agents_timeout_seconds) if is_openclaw_gateway_request else None - - if is_openclaw_gateway_request: - _runtime_log( - "OpenClaw调用", - ( - f"start ts={_cn_now_text_ms()} trace_id={request_trace_id} " - f"function={function_label} provider={provider_name} model={model} " - f"request_timeout={float(request_timeout):.2f}s " - f"agent_timeout={openclaw_agent_timeout if openclaw_agent_timeout is not None else 'N/A'}" - ), - ) - - try: - if provider_name == "ollama": - endpoint = f"{base_url.rstrip('/')}/api/chat" - payload = { - "model": model, - "messages": messages, - "stream": False, - "options": { - "temperature": temperature, - "num_predict": max_tokens, - }, - } - async with httpx.AsyncClient(timeout=request_timeout, trust_env=False) as client: - resp = await client.post(endpoint, json=payload) - resp.raise_for_status() - data = resp.json() - message = data.get("message") if isinstance(data, dict) else {} - content = message.get("content") if isinstance(message, dict) else None - text = self._sanitize_visible_answer(self._extract_text_from_value(content)) - if not text: - text = self._sanitize_visible_answer(self._extract_text_from_value(data.get("response"))) - if not text and not isinstance(data, dict): - raise ValueError("Ollama 返回空内容") - if use_provider_circuit_breaker: - self._record_provider_success(circuit_key) - return ChatCompletionResult(text=text, raw_response=data) - - if not key: - raise ValueError("未配置 LLM API Key") - endpoint = f"{base_url.rstrip('/')}/chat/completions" - headers = { - "Authorization": f"Bearer {key}", - "Content-Type": "application/json", - } - current_max_tokens = max(int(max_tokens or 0), 1) - empty_content_retry_budget = 1 - http_status_retry_budget = 2 - http_status_retry_attempt = 0 - while True: - payload = { - "model": model, - "messages": messages, - "temperature": temperature, - "max_tokens": current_max_tokens, - } - if is_openclaw_gateway_request: - payload["agents"] = { - "defaults": { - "timeoutSeconds": int(openclaw_agents_timeout_seconds), - } - } - async with httpx.AsyncClient(timeout=request_timeout, trust_env=False) as client: - resp = await client.post(endpoint, headers=headers, json=payload) - if is_openclaw_gateway_request: - elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0 - _runtime_log( - "OpenClaw调用", - ( - f"http ts={_cn_now_text_ms()} trace_id={request_trace_id} " - f"function={function_label} status={resp.status_code} elapsed_ms={elapsed_ms:.0f}" - ), - ) - if resp.status_code >= 400: - body_preview = str(resp.text or "").replace("\n", " ").strip()[:260] - if is_openclaw_gateway_request: - _runtime_log( - "OpenClaw调用", - ( - f"http_error ts={_cn_now_text_ms()} trace_id={request_trace_id} " - f"function={function_label} status={resp.status_code} body={body_preview}" - ), - ) - status_code = int(resp.status_code) - can_retry_http_status = ( - http_status_retry_budget > 0 - and self._is_retryable_http_status(status_code) - ) - if can_retry_http_status: - retry_delay = self._http_retry_delay_seconds( - status_code=status_code, - attempt=http_status_retry_attempt, - retry_after_header=resp.headers.get("Retry-After"), - ) - _runtime_log( - "LLM", - ( - "HTTP状态重试:" - f"function={function_label}, provider={provider_name}, model={model}, " - f"status={status_code}, retry_in={retry_delay:.2f}s, " - f"left={http_status_retry_budget - 1}" - ), - ) - http_status_retry_budget -= 1 - http_status_retry_attempt += 1 - await asyncio.sleep(retry_delay) - continue - resp.raise_for_status() - data = resp.json() - choices = data.get("choices") or [] - if not choices: - raise ValueError("LLM 响应无 choices") - first_choice = choices[0] if isinstance(choices[0], dict) else {} - message = first_choice.get("message") if isinstance(first_choice, dict) else {} - text = self._extract_text_from_chat_response( - message=message, - choice=first_choice, - response=data, - structured_output=structured_output, - ) - if ( - not text - and not structured_output - and self._reasoning_content_fallback_enabled(function_name=function_label) - ): - fallback_text = self._extract_reasoning_fallback_text( - message=message, - choice=first_choice, - response=data, - ) - if fallback_text: - text = fallback_text - _runtime_log( - "LLM", - ( - "空content回退reasoning_content:" - f"function={function_label}, provider={provider_name}, model={model}, " - f"text_len={len(text)}" - ), - ) - if text or structured_output: - break - - msg_keys = list(message.keys()) if isinstance(message, dict) else [] - choice_keys = list(first_choice.keys()) if isinstance(first_choice, dict) else [] - choice_preview = "" - try: - choice_preview = json.dumps(first_choice, ensure_ascii=False)[:300] - except Exception: - choice_preview = str(first_choice)[:300] - empty_error = ValueError( - "LLM 返回空内容: " - f"message_keys={msg_keys}, choice_keys={choice_keys}, choice_preview={choice_preview}" - ) - can_retry_empty = ( - empty_content_retry_budget > 0 - and self._should_retry_empty_chat_response( - structured_output=structured_output, - message=message, - choice=first_choice, - current_max_tokens=current_max_tokens, - ) - ) - if not can_retry_empty: - raise empty_error - - empty_content_retry_budget -= 1 - next_max_tokens = self._next_retry_max_tokens_for_empty_chat(current_max_tokens) - _runtime_log( - "LLM", - ( - "空内容提额重试:" - f"function={function_label}, provider={provider_name}, model={model}, " - f"max_tokens={current_max_tokens}->{next_max_tokens}" - ), - ) - current_max_tokens = next_max_tokens - if _looks_like_openclaw_timeout_message(text): - elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0 - _runtime_log( - "OpenClaw超时" if is_openclaw_gateway_request else "LLM超时文本", - ( - f"detected ts={_cn_now_text_ms()} trace_id={request_trace_id} " - f"function={function_label} elapsed_ms={elapsed_ms:.0f} " - f"text={text.replace(chr(10), ' ')[:260]}" - ), - ) - logger.warning( - "llm_timeout_payload_detected", - trace_id=request_trace_id, - function_name=function_label, - provider=provider_name, - model=model, - base_url=str(base_url or "")[:120], - request_timeout_seconds=float(request_timeout), - agent_timeout_seconds=openclaw_agent_timeout, - text_preview=text.replace("\n", " ")[:260], - ) - if is_openclaw_gateway_request: - elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0 - _runtime_log( - "OpenClaw调用", - ( - f"end ts={_cn_now_text_ms()} trace_id={request_trace_id} " - f"function={function_label} elapsed_ms={elapsed_ms:.0f} text_len={len(text)}" - ), - ) - if use_provider_circuit_breaker: - self._record_provider_success(circuit_key) - return ChatCompletionResult(text=text, raw_response=data) - except Exception as exc: - if _looks_like_openclaw_timeout_message(str(exc)): - elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0 - _runtime_log( - "LLM超时异常", - ( - f"detected ts={_cn_now_text_ms()} trace_id={request_trace_id} " - f"function={function_label} elapsed_ms={elapsed_ms:.0f} " - f"error={type(exc).__name__}:{str(exc)[:260]}" - ), - ) - logger.warning( - "llm_timeout_exception_detected", - trace_id=request_trace_id, - function_name=function_label, - provider=provider_name, - model=model, - base_url=str(base_url or "")[:120], - request_timeout_seconds=float(request_timeout), - agent_timeout_seconds=openclaw_agent_timeout, - error_type=type(exc).__name__, - error=str(exc)[:260], - ) - if is_openclaw_gateway_request: - elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0 - _runtime_log( - "OpenClaw调用", - ( - f"exception ts={_cn_now_text_ms()} trace_id={request_trace_id} " - f"function={function_label} elapsed_ms={elapsed_ms:.0f} " - f"error={type(exc).__name__}:{str(exc)[:260]}" - ), - ) - if use_provider_circuit_breaker: - self._record_provider_failure(circuit_key, exc) - raise - - async def generate_task_breakdown_async(self, description: str, **kwargs: Any) -> list[dict]: - if not description.strip(): - return [] - prompt = ( - "请将以下需求拆解为 5-8 条执行任务,返回 JSON 数组," - "每项格式: {\"task_name\":\"\",\"type\":\"backend|frontend|database|ai|devops\"," - "\"estimated_hours\":4,\"priority\":\"high|medium|low\"}。\n" - f"需求:{description[:1200]}" - ) - text = await self.generate(prompt, function_name="generate_task_breakdown", **kwargs) - rows = self._parse_json_array(text) - if rows: - return rows - return [ - { - "task_name": "需求分析", - "type": "ai", - "priority": "medium", - "estimated_hours": 4, - "description": description[:200], - } - ] - - async def get_embedding_async(self, text: str, **kwargs: Any) -> list[float]: - db = kwargs.get("db") or kwargs.get("db_session") - tenant_id = int(kwargs.get("tenant_id") or 1) - if db is None: - async with AsyncSessionLocal() as session: - return await self.get_embedding_async(text, db=session, tenant_id=tenant_id) - try: - engine = await self._resolve_active_engine(db, tenant_id=tenant_id) - provider = str(engine.get("provider") or "") - if provider == "openai": - endpoint = f"{str(engine.get('base_url') or '').rstrip('/')}/embeddings" - headers = { - "Authorization": f"Bearer {str(engine.get('api_key') or '').strip()}", - "Content-Type": "application/json", - } - payload = { - "model": str(engine.get("model") or "text-embedding-3-small"), - "input": text, - } - async with httpx.AsyncClient(timeout=self.request_timeout, trust_env=False) as client: - resp = await client.post(endpoint, headers=headers, json=payload) - resp.raise_for_status() - data = resp.json() - return list(data.get("data", [{}])[0].get("embedding") or []) - except Exception: - pass - # DeepSeek/GLM 及失败场景:回退伪向量,保持功能可用。 - seed = hashlib.md5(text.encode("utf-8")).digest() - return [((seed[i % 16] / 255.0) * 2 - 1) for i in range(64)] - - async def extract_context_insights_async(self, text: str, **kwargs: Any) -> dict: - prompt = ( - "请提炼以下技术文档上下文的关键结论,返回 JSON:" - "{\"summary\":\"\",\"keywords\":[\"\",\"\"],\"risks\":[\"\",\"\"]}\n" - f"上下文:{text[:2000]}" - ) - raw = await self.generate(prompt, function_name="extract_context_insights", **kwargs) - parsed = self._parse_json_object(raw) - if parsed: - return parsed - return {"summary": text[:120], "keywords": []} - - async def find_relevant_context(self, **_: Any) -> list[dict]: - return [] - - async def evaluate_with_voting_async(self, signal_content: str, impact_score: float, **_: Any) -> dict: - is_strategic = impact_score >= 0.8 - return { - "is_strategic": is_strategic, - "confidence": min(1.0, max(0.2, impact_score)), - "decision_proposal": { - "summary": signal_content[:120], - "action": "follow_up" if is_strategic else "observe", - }, - "tasks": [], - "_voting": { - "enabled": False, - "models_used": [], - "strategic_votes": 0, - "total_votes": 0, - "unanimous": True, - }, - } - - async def audit_code_change_async( - self, - task_name: str, - commit_message: str, - code_diff: str, - **_: Any, - ) -> dict: - passed = bool(task_name and commit_message and code_diff is not None) - return { - "passed": passed, - "reason": "basic_stub_check_passed" if passed else "invalid_input", - } - - # --------------------------------------------------------------------- - # internals - # --------------------------------------------------------------------- - def _stub_response(self, prompt: str) -> str: - _ = prompt # 保留签名,避免调用方改动 - return ( - "我暂时无法生成可靠回答(模型服务异常)。" - "为避免误导,本次不直接给结论;请稍后重试," - "或补充更具体的目标对象后继续提问。" - ) - - def _sanitize_engine_for_response(self, item: dict[str, Any]) -> dict[str, Any]: - return { - "id": str(item.get("id") or ""), - "name": str(item.get("name") or ""), - "provider": str(item.get("provider") or ""), - "model": str(item.get("model") or ""), - "base_url": str(item.get("base_url") or ""), - "api_key_masked": _mask_secret(str(item.get("api_key") or "")), - "updated_at": item.get("updated_at"), - "created_at": item.get("created_at"), - } - - @staticmethod - def _normalize_engine_settings(raw: Any) -> dict[str, Any]: - settings = dict(LLM_ENGINE_SETTINGS_DEFAULTS) - if isinstance(raw, dict): - settings["enable_local_kb_qa"] = bool(raw.get("enable_local_kb_qa", settings["enable_local_kb_qa"])) - return settings - - @staticmethod - def _is_local_kb_qa_function(function_name: str) -> bool: - fn = str(function_name or "").strip().lower() - if not fn: - return False - if fn in LOCAL_KB_QA_FUNCTION_EXACT: - return True - return any(prefix and fn.startswith(prefix) for prefix in LOCAL_KB_QA_FUNCTION_PREFIXES) - - def _ensure_engine_allowed_for_function( - self, - *, - engine: dict[str, Any], - function_name: str, - settings: dict[str, Any], - engine_label: str, - ) -> dict[str, Any]: - runtime = self._normalize_engine_runtime(engine) - provider = str(runtime.get("source_provider") or runtime.get("provider") or "") - if provider == "ollama": - if not bool(settings.get("enable_local_kb_qa")): - raise ValueError("本地 LLM 未启用,请先在引擎配置中打开“本地 LLM(仅知识库问答)”开关") - if not self._is_local_kb_qa_function(function_name): - raise ValueError(f"本地 LLM 仅允许用于知识库问答,当前 function={function_name}") - return runtime - - if not str(runtime.get("api_key") or "").strip(): - raise ValueError(f"{engine_label} 未配置 API Key") - return runtime - - def _ensure_engine_allowed_for_test( - self, - *, - engine: dict[str, Any], - settings: dict[str, Any], - engine_label: str, - ) -> dict[str, Any]: - runtime = self._normalize_engine_runtime(engine) - provider = str(runtime.get("source_provider") or runtime.get("provider") or "") - if provider == "ollama": - if not bool(settings.get("enable_local_kb_qa")): - raise ValueError("本地 LLM 未启用,请先在引擎配置中打开“本地 LLM(仅知识库问答)”开关") - raise ValueError("本地 LLM 仅允许用于知识库问答,连通性测试请通过知识库问答链路验证") - if not str(runtime.get("api_key") or "").strip(): - raise ValueError(f"{engine_label} 未配置 API Key") - return runtime - - async def _get_or_init_bundle( - self, - db: AsyncSession, - *, - tenant_id: int, - ) -> tuple[Integration, dict[str, Any]]: - result = await db.execute( - select(Integration).where( - and_( - Integration.tenant_id == tenant_id, - Integration.type == "llm_engine", - ) - ) - ) - integration = result.scalar_one_or_none() - if not integration: - integration = Integration( - type="llm_engine", - config={"active_engine_id": None, "engines": [], "settings": self._normalize_engine_settings(None)}, - is_active=True, - tenant_id=tenant_id, - ) - db.add(integration) - await db.flush() - bundle = _safe_json(integration.config) - engines = bundle.get("engines") - if not isinstance(engines, list): - engines = [] - bundle["engines"] = engines - if "active_engine_id" not in bundle: - bundle["active_engine_id"] = None - bundle["settings"] = self._normalize_engine_settings(bundle.get("settings")) - return integration, bundle - - async def _resolve_active_engine( - self, - db: AsyncSession, - *, - tenant_id: int, - ) -> dict[str, Any]: - _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) - return self._resolve_active_engine_from_bundle(bundle) - - async def _resolve_engine_for_call( - self, - db: AsyncSession, - *, - tenant_id: int, - function_name: str, - ) -> dict[str, Any]: - _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) - settings = self._normalize_engine_settings(bundle.get("settings")) - active_engine = self._resolve_active_engine_from_bundle(bundle) - active_engine = self._ensure_openclaw_control_plane_engine(active_engine) - engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)] - - override_engine = await self._resolve_function_override_engine( - db, - tenant_id=tenant_id, - function_name=function_name, - engines=engines, - active_engine=active_engine, - ) - if override_engine is not None: - return self._ensure_engine_allowed_for_function( - engine=override_engine, - function_name=function_name, - settings=settings, - engine_label="override_engine", - ) - - openclaw_routed = self._resolve_openclaw_engine( - function_name=function_name, - engines=engines, - active_engine=active_engine, - ) - if openclaw_routed is not None: - openclaw_routed = self._ensure_openclaw_control_plane_engine(openclaw_routed) - return self._ensure_engine_allowed_for_function( - engine=openclaw_routed, - function_name=function_name, - settings=settings, - engine_label="openclaw_routed_engine", - ) - - if not self._mixed_routing_enabled(): - return self._ensure_engine_allowed_for_function( - engine=active_engine, - function_name=function_name, - settings=settings, - engine_label="active_engine", - ) - - tier = self._resolve_function_tier(function_name) - if tier not in {"high", "low"}: - return self._ensure_engine_allowed_for_function( - engine=active_engine, - function_name=function_name, - settings=settings, - engine_label="active_engine", - ) - - routed = self._resolve_tier_engine( - tier=tier, - engines=engines, - active_engine=active_engine, - ) - if not routed: - return self._ensure_engine_allowed_for_function( - engine=active_engine, - function_name=function_name, - settings=settings, - engine_label="active_engine", - ) - provider = str(routed.get("provider") or "") - _runtime_log( - "LLM路由", - ( - "函数路由生效:" - f"function={function_name}, tier={tier}, provider={provider}, model={routed.get('model')}" - ), - ) - return self._ensure_engine_allowed_for_function( - engine=self._ensure_openclaw_control_plane_engine(routed), - function_name=function_name, - settings=settings, - engine_label=f"{tier}_tier_engine", - ) - - async def _resolve_function_override_engine( - self, - db: AsyncSession, - *, - tenant_id: int, - function_name: str, - engines: list[dict[str, Any]], - active_engine: dict[str, Any], - ) -> Optional[dict[str, Any]]: - fn = str(function_name or "").strip().lower() - if not fn: - return None - try: - row = ( - await db.execute( - select(Integration).where( - and_( - Integration.tenant_id == int(tenant_id), - Integration.type == "model_routing", - ) - ) - ) - ).scalar_one_or_none() - bundle = _safe_json(row.config if row else {}) - mappings = bundle.get("mappings") if isinstance(bundle.get("mappings"), dict) else {} - override = mappings.get(fn) if isinstance(mappings.get(fn), dict) else None - if not override or not bool(override.get("enabled", True)): - return None - rule = { - "tier": str(override.get("tier") or "high").strip().lower() or "high", - "provider": str(override.get("provider") or "").strip().lower(), - "model": str(override.get("model") or "").strip(), - "api_env": str(override.get("api_env") or "").strip(), - "base_url": str(override.get("base_url") or "").strip(), - } - routed = self._resolve_engine_from_rule( - rule=rule, - engines=engines, - active_engine=active_engine, - ) - if routed is None: - return None - provider = str(routed.get("provider") or "") - if provider != "ollama" and not str(routed.get("api_key") or "").strip(): - raise ValueError( - "函数覆盖路由命中但目标引擎缺少 API Key:" - f"function={fn}, provider={provider}, model={routed.get('model')}" - ) - _runtime_log( - "LLM路由", - ( - "函数覆盖路由命中:" - f"function={fn}, provider={routed.get('provider')}, model={routed.get('model')}" - ), - ) - return self._ensure_openclaw_control_plane_engine(routed) - except ValueError: - raise - except Exception as exc: - _runtime_log("LLM路由", f"函数覆盖路由读取失败:function={fn}, error={exc}") - return None - - def _resolve_active_engine_from_bundle(self, bundle: dict[str, Any]) -> dict[str, Any]: - engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)] - active_engine_id = str(bundle.get("active_engine_id") or "") - if active_engine_id: - for engine in engines: - if str(engine.get("id") or "") == active_engine_id: - return self._normalize_engine_runtime(engine) - if engines: - return self._normalize_engine_runtime(engines[0]) - return self._env_engine_runtime() - - def _mixed_routing_enabled(self) -> bool: - return _env_bool("LLM_MIXED_ROUTING_ENABLED", False) - - def _openclaw_routing_enabled(self) -> bool: - return False - - def _openclaw_control_plane_only(self) -> bool: - return False - - def _ensure_openclaw_control_plane_engine(self, engine: dict[str, Any]) -> dict[str, Any]: - runtime = dict(engine or {}) - runtime["source_provider"] = str(runtime.get("source_provider") or runtime.get("provider") or "").strip().lower() - return runtime - - def _openclaw_rules_path(self) -> str: - custom = str( - os.getenv( - "OPENCLAW_ROUTING_RULES_PATH", - OPENCLAW_DEFAULT_RULES_RELATIVE_PATH, - ) - or "" - ).strip() - raw_path = custom or OPENCLAW_DEFAULT_RULES_RELATIVE_PATH - candidate = Path(raw_path) - if not candidate.is_absolute(): - candidate = (Path(__file__).resolve().parents[2] / candidate).resolve() - return str(candidate) - - def _load_openclaw_routing_bundle(self) -> dict[str, Any]: - now = time.time() - if self._openclaw_routing_cache and self._openclaw_routing_cache[0] > now: - return dict(self._openclaw_routing_cache[1]) - - path = Path(self._openclaw_rules_path()) - bundle: dict[str, Any] = {} - if path.exists(): - try: - parsed = json.loads(path.read_text(encoding="utf-8")) - if isinstance(parsed, dict): - bundle = parsed - except Exception as exc: - _runtime_log("OpenClaw路由", f"规则文件解析失败:path={path} error={exc}") - bundle = {} - self._openclaw_routing_cache = (now + OPENCLAW_ROUTING_CACHE_TTL_SECONDS, dict(bundle)) - return dict(bundle) - - @staticmethod - def _rule_matches_function(rule: dict[str, Any], function_name: str) -> bool: - if bool(rule.get("match_all", False)): - return True - fn = str(function_name or "").strip().lower() - if not fn: - return False - exact = str(rule.get("function_exact") or "").strip().lower() - if exact and fn == exact: - return True - prefix = str(rule.get("function_prefix") or "").strip().lower() - if prefix == "*" or prefix == "all": - return True - if prefix and fn.startswith(prefix): - return True - match_any = rule.get("match_any") - if isinstance(match_any, list): - for hint in match_any: - token = str(hint or "").strip().lower() - if token and token in fn: - return True - return False - - def _match_openclaw_rule( - self, - function_name: str, - *, - tier_filter: Optional[set[str]] = None, - ) -> Optional[dict[str, Any]]: - if not self._openclaw_routing_enabled(): - return None - bundle = self._load_openclaw_routing_bundle() - rules = bundle.get("rules") if isinstance(bundle.get("rules"), list) else [] - if not rules: - return None - - indexed: list[tuple[int, int, dict[str, Any]]] = [] - for idx, row in enumerate(rules): - if not isinstance(row, dict): - continue - if not bool(row.get("enabled", True)): - continue - if tier_filter: - tier_name = str(row.get("tier") or "").strip().lower() - if tier_name not in tier_filter: - continue - if not self._rule_matches_function(row, function_name): - continue - priority = int(row.get("priority") or 0) - indexed.append((priority, -idx, row)) - - if not indexed: - return None - indexed.sort(reverse=True) - return dict(indexed[0][2]) - - def _resolve_openclaw_engine( - self, - *, - function_name: str, - engines: list[dict[str, Any]], - active_engine: dict[str, Any], - ) -> Optional[dict[str, Any]]: - rule = self._match_openclaw_rule(function_name, tier_filter={"low", "high"}) - if not rule: - return None - - routed = self._resolve_engine_from_rule( - rule=rule, - engines=engines, - active_engine=active_engine, - ) - if routed is None: - return None - - _runtime_log( - "OpenClaw路由", - ( - "规则命中:" - f"function={function_name}, provider={routed.get('provider')}, " - f"model={routed.get('model')}, rule={rule.get('name') or 'unnamed'}" - ), - ) - return routed - - def _resolve_engine_from_rule( - self, - *, - rule: dict[str, Any], - engines: list[dict[str, Any]], - active_engine: dict[str, Any], - ) -> Optional[dict[str, Any]]: - tier = str(rule.get("tier") or "").strip().lower() - provider_raw = str(rule.get("provider") or "").strip().lower() - engine_id = str(rule.get("engine_id") or "").strip() - model_override = str(rule.get("model") or "").strip() - base_url_override = str(rule.get("base_url") or "").strip().rstrip("/") - api_env_override = str(rule.get("api_env") or "").strip() - - routed: Optional[dict[str, Any]] = None - if engine_id: - for item in engines: - if str(item.get("id") or "").strip() == engine_id: - routed = self._normalize_engine_runtime(item) - break - - if routed is None and provider_raw: - try: - provider = _normalize_provider(provider_raw) - except Exception: - provider = "" - if provider: - for item in engines: - if str(item.get("provider") or "").strip().lower() == provider: - routed = self._normalize_engine_runtime(item) - break - if routed is None: - try: - routed = self._env_provider_runtime(provider) - except Exception: - routed = None - - if routed is None and tier in {"high", "low", "safety"}: - routed = self._resolve_tier_engine( - tier=tier, - engines=engines, - active_engine=active_engine, - ) - - if routed is None: - return None - - if model_override: - routed["model"] = model_override - if base_url_override: - routed["base_url"] = base_url_override - if api_env_override: - routed["api_key"] = str(os.getenv(api_env_override, "") or "").strip() - routed["api_env"] = api_env_override - return routed - - def _resolve_function_tier(self, function_name: str) -> str: - fn = str(function_name or "").strip().lower() - if not fn: - return str(os.getenv("LLM_ROUTING_DEFAULT_TIER", "active") or "active").strip().lower() - - high_hints = _parse_csv_items(os.getenv("LLM_ROUTING_HIGH_FUNCTIONS", "")) - low_hints = _parse_csv_items(os.getenv("LLM_ROUTING_LOW_FUNCTIONS", "")) - high_pool = high_hints or DEFAULT_HIGH_TIER_HINTS - low_pool = low_hints or DEFAULT_LOW_TIER_HINTS - - if any(hint and hint in fn for hint in high_pool): - return "high" - if any(hint and hint in fn for hint in low_pool): - return "low" - default_tier = str(os.getenv("LLM_ROUTING_DEFAULT_TIER", "active") or "active").strip().lower() - if default_tier != "active": - return default_tier - # mixed routing 开启时,未知函数默认走 high,避免被 active 引擎“全吞”。 - return "high" if self._mixed_routing_enabled() else "active" - - def _low_to_high_escalation_enabled(self) -> bool: - return _env_bool("LLM_LOW_TO_HIGH_ESCALATION_ENABLED", True) - - def _is_low_tier_function(self, function_name: str) -> bool: - rule = self._match_openclaw_rule(function_name) - if rule is not None: - tier = str(rule.get("tier") or "").strip().lower() - if tier: - return tier == "low" - return self._resolve_function_tier(function_name) == "low" - - @staticmethod - def _looks_like_unanswerable_response(text: str) -> bool: - cleaned = str(text or "").strip() - lowered = cleaned.lower() - if not lowered: - return True - if len(cleaned) <= 8: - return True - return any(marker in lowered for marker in LOW_TO_HIGH_ESCALATION_FALLBACK_MARKERS) - - def _should_escalate_low_to_high(self, *, function_name: str, response_text: str) -> bool: - if not self._low_to_high_escalation_enabled(): - return False - if not self._is_low_tier_function(function_name): - return False - return self._looks_like_unanswerable_response(response_text) - - @staticmethod - def _is_glm_47_model(model_name: str) -> bool: - normalized = re.sub(r"[\s_]+", "", str(model_name or "")).lower() - if not normalized: - return False - return "glm" in normalized and "4.7" in normalized - - def _should_retry_glm47_with_high_tier( - self, - *, - function_name: str, - failed_engine: dict[str, Any], - ) -> bool: - if not self._low_to_high_escalation_enabled(): - return False - failed_model = str(failed_engine.get("model") or "").strip() - if self._is_glm_47_model(failed_model): - return True - # 兜底兼容:低阶函数遇到 4.x GLM 也允许升级高阶重试。 - if self._is_low_tier_function(function_name): - lowered = failed_model.lower() - return "glm" in lowered and "4." in lowered - return False - - async def _retry_with_high_tier_after_low_model_failure( - self, - *, - db: AsyncSession, - tenant_id: int, - function_name: str, - failed_engine: dict[str, Any], - prompt: str, - system_prompt: str, - temperature: float, - max_tokens: int, - response_mode: str, - error: Exception, - ) -> tuple[Optional[str], Optional[dict[str, Any]]]: - if not self._should_retry_glm47_with_high_tier(function_name=function_name, failed_engine=failed_engine): - return None, None - try: - _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) - engines = [item for item in bundle.get("engines", []) if isinstance(item, dict)] - active_engine = self._resolve_active_engine_from_bundle(bundle) - high_engine = self._resolve_tier_engine( - tier="high", - engines=engines, - active_engine=active_engine, - ) - if not isinstance(high_engine, dict): - return None, None - high_engine = self._ensure_engine_allowed_for_function( - engine=high_engine, - function_name=function_name, - settings=self._normalize_engine_settings(bundle.get("settings")), - engine_label="high_tier_failover", - ) - failed_provider = str(failed_engine.get("provider") or "").strip().lower() - failed_model = str(failed_engine.get("model") or "").strip() - high_provider = str(high_engine.get("provider") or "").strip().lower() - high_model = str(high_engine.get("model") or "").strip() - if failed_provider == high_provider and failed_model == high_model: - return None, None - _runtime_log( - "LLM路由", - ( - "低阶模型失败自动升级:" - f"function={function_name}, from={failed_provider}:{failed_model}, " - f"to={high_provider}:{high_model}, reason={str(error)[:120]}" - ), - ) - text = await self._chat_completion( - provider=high_provider, - prompt=prompt, - system_prompt=system_prompt, - api_key=str(high_engine.get("api_key") or ""), - base_url=str(high_engine.get("base_url") or ""), - model=high_model, - temperature=temperature, - max_tokens=max_tokens, - function_name=function_name, - response_mode=response_mode, - ) - if not text.strip(): - raise ValueError("high_tier_empty_response") - if self._is_invalid_structured_output(function_name=function_name, response_text=text): - raise ValueError("high_tier_invalid_structured_output") - return text, high_engine - except Exception as retry_exc: - _runtime_log( - "LLM路由", - ( - "低阶模型失败升级高阶也失败:" - f"function={function_name}, error={type(retry_exc).__name__}:{str(retry_exc)[:180]}" - ), - ) - return None, None - - async def _retry_structured_with_high_tier_after_low_model_failure( - self, - *, - db: AsyncSession, - tenant_id: int, - function_name: str, - failed_engine: dict[str, Any], - prompt: str, - system_prompt: str, - temperature: float, - max_tokens: int, - response_mode: str, - error: Exception, - ) -> tuple[Optional[StructuredExtractionResult], Optional[dict[str, Any]]]: - if not self._should_retry_glm47_with_high_tier(function_name=function_name, failed_engine=failed_engine): - return None, None - try: - _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) - engines = [item for item in bundle.get("engines", []) if isinstance(item, dict)] - active_engine = self._resolve_active_engine_from_bundle(bundle) - high_engine = self._resolve_tier_engine( - tier="high", - engines=engines, - active_engine=active_engine, - ) - if not isinstance(high_engine, dict): - return None, None - high_engine = self._ensure_engine_allowed_for_function( - engine=high_engine, - function_name=function_name, - settings=self._normalize_engine_settings(bundle.get("settings")), - engine_label="high_tier_structured_failover", - ) - failed_provider = str(failed_engine.get("provider") or "").strip().lower() - failed_model = str(failed_engine.get("model") or "").strip() - high_provider = str(high_engine.get("provider") or "").strip().lower() - high_model = str(high_engine.get("model") or "").strip() - if failed_provider == high_provider and failed_model == high_model: - return None, None - _runtime_log( - "LLM路由", - ( - "低阶结构化失败自动升级:" - f"function={function_name}, from={failed_provider}:{failed_model}, " - f"to={high_provider}:{high_model}, reason={str(error)[:120]}" - ), - ) - completion = await self._chat_completion_raw( - provider=high_provider, - prompt=prompt, - system_prompt=system_prompt, - api_key=str(high_engine.get("api_key") or ""), - base_url=str(high_engine.get("base_url") or ""), - model=high_model, - temperature=temperature, - max_tokens=max_tokens, - function_name=function_name, - response_mode=response_mode, - ) - extraction = self._extract_structured_object_from_response( - function_name=function_name, - response=completion.raw_response, - fallback_text=completion.text, - provider=high_provider, - model=high_model, - ) - if extraction.payload is None: - raise ValueError(str(extraction.error_code or "structured_schema_invalid")) - _runtime_log( - "LLM结构化", - ( - f"function={function_name}, structured_source={extraction.source_field or 'none'}, " - f"validation={extraction.validation}, fallback=true" - ), - ) - return extraction, high_engine - except Exception as retry_exc: - _runtime_log( - "LLM路由", - ( - "低阶结构化失败升级高阶也失败:" - f"function={function_name}, error={type(retry_exc).__name__}:{str(retry_exc)[:180]}" - ), - ) - return None, None - - @staticmethod - def _requires_ddl_api_guardrail(function_name: str) -> bool: - fn = str(function_name or "").strip().lower() - if not fn: - return False - return fn in DDL_API_GUARDRAIL_FUNCTION_EXACT - - @classmethod - def _inject_function_guardrails(cls, *, system_prompt: str, function_name: str) -> str: - base_prompt = str(system_prompt or "").strip() - if not cls._requires_ddl_api_guardrail(function_name): - return base_prompt - extra = str(DDL_API_GUARDRAIL_SYSTEM_PROMPT or "").strip() - if not extra: - return base_prompt - if extra in base_prompt: - return base_prompt - if not base_prompt: - return extra - return f"{base_prompt}\n\n{extra}" - - @classmethod - def _merge_prompt_with_profile( - cls, - *, - prompt: str, - prompt_profile: dict[str, str], - function_name: str = "", - ) -> str: - system_prompt = cls._inject_function_guardrails( - system_prompt=str(prompt_profile.get("system_prompt") or "").strip(), - function_name=function_name, - ) - if not system_prompt: - return prompt - role_name = str(prompt_profile.get("role_name") or "unknown").strip() or "unknown" - return f"[system_role:{role_name}]\n{system_prompt}\n\n{prompt}" - - def _direct_emergency_enabled(self) -> bool: - return _env_bool("LLM_DIRECT_EMERGENCY_ENABLED", False) - - @staticmethod - def _prefer_fast_local_fallback(function_name: str) -> bool: - fn = str(function_name or "").strip().lower() - if not fn: - return False - return any(hint and hint in fn for hint in FAST_LOCAL_FALLBACK_FUNCTION_HINTS) - - @staticmethod - def _should_raise_model_error(function_name: str) -> bool: - fn = str(function_name or "").strip().lower() - if not fn: - return False - if fn in STRICT_MODEL_ERROR_FUNCTION_EXACT: - return True - return any(prefix and fn.startswith(prefix) for prefix in STRICT_MODEL_ERROR_FUNCTION_PREFIXES) - - @staticmethod - def _build_model_error_message( - *, - function_name: str, - provider: str, - model: str, - error: Exception, - ) -> str: - clean_error = re.sub(r"\s+", " ", str(error or "")).strip() - if len(clean_error) > 160: - clean_error = f"{clean_error[:160]}..." - provider_text = str(provider or "unknown").strip() or "unknown" - model_text = str(model or "unknown").strip() or "unknown" - fn_text = str(function_name or "llm_generate").strip() or "llm_generate" - if not clean_error: - clean_error = "upstream_empty_or_invalid_response" - return ( - f"模型调用失败:function={fn_text}, provider={provider_text}, model={model_text}, " - f"reason={clean_error}" - ) - - @staticmethod - def _is_fast_local_planner_function(function_name: str) -> bool: - fn = str(function_name or "").strip().lower() - return any(hint and hint in fn for hint in FAST_LOCAL_PLANNER_FUNCTION_HINTS) - - @staticmethod - def _is_fast_local_worker_function(function_name: str) -> bool: - fn = str(function_name or "").strip().lower() - return any(hint and hint in fn for hint in FAST_LOCAL_WORKER_FUNCTION_HINTS) - - @staticmethod - def _structured_cache_enabled(function_name: str) -> bool: - fn = str(function_name or "").strip().lower() - return fn in { - "signal_source_onboarding_plan", - } - - def _request_timeout_for_function(self, function_name: str, *, response_mode: str = "complete") -> float: - if not self._prefer_fast_local_fallback(function_name): - return self.request_timeout - env_key = "LLM_FAST_LOCAL_FALLBACK_TIMEOUT_COMPLETE" - default_timeout = FAST_LOCAL_FALLBACK_COMPLETE_TIMEOUT_SECONDS - if self._is_fast_local_planner_function(function_name) and str(response_mode or "").strip().lower() == "fast": - env_key = "LLM_FAST_LOCAL_FALLBACK_TIMEOUT_FAST" - default_timeout = FAST_LOCAL_FALLBACK_FAST_TIMEOUT_SECONDS - try: - configured = float(os.getenv(env_key, str(default_timeout))) - except Exception: - configured = default_timeout - if configured <= 0: - return self.request_timeout - return min(self.request_timeout, configured) - - @staticmethod - def _is_openclaw_gateway_request(*, provider_name: str, base_url: str) -> bool: - if str(provider_name or "").strip().lower() != "openai": - return False - normalized_base_url = str(base_url or "").strip().lower() - if not normalized_base_url: - return False - return "openclaw" in normalized_base_url - - @staticmethod - def _openclaw_agents_timeout_seconds() -> int: - raw = os.getenv( - "OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS", - str(OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS), - ) - try: - configured = int(str(raw).strip()) - except Exception: - configured = OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS - if configured <= 0: - configured = OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS - return min(configured, OPENCLAW_AGENTS_TIMEOUT_SECONDS_MAX) - - @staticmethod - def _should_retry_empty_chat_response( - *, - structured_output: bool, - message: Any, - choice: Any, - current_max_tokens: int, - ) -> bool: - if structured_output: - return False - if int(current_max_tokens or 0) >= 2800: - return False - finish_reason = str(choice.get("finish_reason") or "").strip().lower() if isinstance(choice, dict) else "" - if finish_reason not in {"length", "max_tokens"}: - return False - reasoning_text = "" - if isinstance(message, dict): - reasoning_text = str(message.get("reasoning_content") or message.get("reasoning") or "").strip() - if not reasoning_text and isinstance(choice, dict): - reasoning_text = str(choice.get("reasoning_content") or choice.get("reasoning") or "").strip() - return bool(reasoning_text) - - @staticmethod - def _next_retry_max_tokens_for_empty_chat(current_max_tokens: int) -> int: - current = max(int(current_max_tokens or 0), 1) - grown = max(current + 512, int(current * 1.8)) - return min(grown, 3000) - - def _use_provider_circuit_breaker(self, function_name: str) -> bool: - fn = str(function_name or "").strip().lower() - if any(fn.startswith(prefix) for prefix in CIRCUIT_BREAKER_EXEMPT_FUNCTION_PREFIXES): - return False - return not self._prefer_fast_local_fallback(function_name) - - def _structured_output_required(self, function_name: str) -> bool: - fn = str(function_name or "").strip().lower() - if not fn: - return False - custom_hints = _parse_csv_items(os.getenv("LLM_STRUCTURED_OUTPUT_FUNCTIONS", "")) - hint_pool = custom_hints or STRUCTURED_OUTPUT_FUNCTION_HINTS - return any(hint and hint in fn for hint in hint_pool) - - @staticmethod - def _extract_json_candidate(text: str) -> str: - raw = str(text or "").strip() - if not raw: - return "" - if raw.startswith("```"): - chunks = raw.split("```") - if len(chunks) >= 2: - raw = chunks[1].strip() - if raw.startswith("json"): - raw = raw[4:].strip() - if raw.startswith("{") or raw.startswith("["): - return raw - - object_candidate = LLMService._extract_balanced_json_segment(raw, "{", "}") - array_candidate = LLMService._extract_balanced_json_segment(raw, "[", "]") - if object_candidate and array_candidate: - return object_candidate if raw.find("{") <= raw.find("[") else array_candidate - if object_candidate: - return object_candidate - if array_candidate: - return array_candidate - return raw - - @staticmethod - def _looks_like_json_container(text: str) -> bool: - raw = str(text or "").strip() - if not raw: - return False - if raw.startswith("{") or raw.startswith("["): - return True - return "{" in raw or "[" in raw - - @staticmethod - def _extract_balanced_json_segment(text: str, open_char: str, close_char: str) -> str: - raw = str(text or "") - start = raw.find(open_char) - if start < 0: - return "" - depth = 0 - in_str = False - escaped = False - for idx in range(start, len(raw)): - ch = raw[idx] - if in_str: - if escaped: - escaped = False - elif ch == "\\": - escaped = True - elif ch == '"': - in_str = False - continue - if ch == '"': - in_str = True - continue - if ch == open_char: - depth += 1 - elif ch == close_char: - depth -= 1 - if depth == 0: - return raw[start : idx + 1].strip() - return "" - - def _is_invalid_structured_output(self, *, function_name: str, response_text: str) -> bool: - if not self._structured_output_required(function_name): - return False - candidate = self._extract_json_candidate(response_text) - if not candidate: - return True - try: - parsed = json.loads(candidate) - except Exception: - return True - parsed = self._unwrap_structured_payload_once(parsed) - validation_error = self._validate_structured_payload(function_name=function_name, payload=parsed) - if validation_error is not None: - return True - return not isinstance(parsed, (dict, list)) - - @staticmethod - def _strip_code_fence(text: str) -> str: - raw = str(text or "").strip() - if not raw.startswith("```"): - return raw - chunks = raw.split("```") - if len(chunks) >= 2: - raw = chunks[1].strip() - if raw.lower().startswith("json"): - raw = raw[4:].strip() - return raw.strip() - - def _extract_text_from_chat_response( - self, - *, - message: Any, - choice: Any, - response: Any, - structured_output: bool, - ) -> str: - if structured_output: - for _, raw_value in self._iter_structured_candidates(message=message, choice=choice, response=response): - text = self._sanitize_visible_answer(self._extract_text_from_value(raw_value)) - normalized = self._strip_code_fence(text) - if not self._looks_like_json_container(normalized): - continue - candidate = self._extract_json_candidate(normalized) - if candidate: - return candidate - return "" - if isinstance(message, dict): - text = self._sanitize_visible_answer(self._extract_text_from_value(message.get("content"))) - if text: - return text - tool_calls = message.get("tool_calls") - if isinstance(tool_calls, list) and tool_calls: - first = tool_calls[0] if isinstance(tool_calls[0], dict) else {} - fn = first.get("function") if isinstance(first, dict) else {} - if isinstance(fn, dict): - text = self._sanitize_visible_answer(self._extract_text_from_value(fn.get("arguments"))) - if text: - return text - if isinstance(choice, dict): - text = self._sanitize_visible_answer(self._extract_text_from_value(choice.get("text"))) - if text: - return text - if isinstance(response, dict): - text = self._sanitize_visible_answer(self._extract_text_from_value(response.get("output_text"))) - if text: - return text - return "" - - def _extract_reasoning_fallback_text( - self, - *, - message: Any, - choice: Any, - response: Any, - ) -> str: - candidates: list[Any] = [] - if isinstance(message, dict): - candidates.append(message.get("reasoning_content")) - candidates.append(message.get("reasoning")) - if isinstance(choice, dict): - candidates.append(choice.get("reasoning_content")) - candidates.append(choice.get("reasoning")) - if isinstance(response, dict): - candidates.append(response.get("reasoning_content")) - candidates.append(response.get("reasoning")) - for raw in candidates: - text = self._sanitize_visible_answer(self._extract_text_from_value(raw)) - if text: - return text - return "" - - @staticmethod - def _reasoning_content_fallback_enabled(*, function_name: str) -> bool: - fn = str(function_name or "").strip().lower() - if fn and ("title_refine" in fn or "project_draft_analysis_glm47_fallback" in fn): - return True - raw = str(os.getenv("LLM_REASONING_CONTENT_FALLBACK", "0")).strip().lower() - if raw not in {"1", "true", "yes", "on"}: - return False - if not fn: - return True - if fn.startswith("generate_structured") or fn.startswith("llm_generate_structured"): - return False - if "signal_batch_analysis" in fn: - return False - if any(token in fn for token in ("answer", "reply", "qa")): - return True - return False - - def _iter_structured_candidates( - self, - *, - message: Any, - choice: Any, - response: Any, - ) -> list[tuple[str, Any]]: - candidates: list[tuple[str, Any]] = [] - if isinstance(message, dict): - tool_calls = message.get("tool_calls") - if isinstance(tool_calls, list) and tool_calls: - first = tool_calls[0] if isinstance(tool_calls[0], dict) else {} - fn = first.get("function") if isinstance(first, dict) else {} - candidates.append(("tool_calls.function.arguments", fn.get("arguments") if isinstance(fn, dict) else None)) - candidates.append(("message.content", message.get("content"))) - if isinstance(choice, dict): - candidates.append(("choice.text", choice.get("text"))) - if isinstance(response, dict): - candidates.append(("response.output_text", response.get("output_text"))) - if isinstance(message, dict): - candidates.append(("message.reasoning_content", message.get("reasoning_content"))) - candidates.append(("message.reasoning_content", message.get("reasoning"))) - if isinstance(choice, dict): - candidates.append(("choice.reasoning_content", choice.get("reasoning_content"))) - candidates.append(("choice.reasoning_content", choice.get("reasoning"))) - if isinstance(response, dict): - candidates.append(("response.reasoning_content", response.get("reasoning_content"))) - candidates.append(("response.reasoning_content", response.get("reasoning"))) - return candidates - - @classmethod - def _unwrap_structured_payload_once(cls, payload: Any) -> Any: - if not isinstance(payload, dict): - return payload - for key in ("json", "data", "result", "arguments"): - if key not in payload: - continue - inner = payload.get(key) - if isinstance(inner, str): - candidate = cls._extract_json_candidate(cls._strip_code_fence(inner)) - if not candidate: - continue - try: - return json.loads(candidate) - except Exception: - return inner - if isinstance(inner, (dict, list)): - return inner - return payload - - @staticmethod - def _matches_expected_type(value: Any, expected: str) -> bool: - if expected == "bool": - return isinstance(value, bool) - if expected == "str": - return isinstance(value, str) - if expected == "list": - return isinstance(value, list) - if expected == "number": - return isinstance(value, (int, float)) and not isinstance(value, bool) - if expected == "number_or_none": - return value is None or (isinstance(value, (int, float)) and not isinstance(value, bool)) - if expected == "str_or_none": - return value is None or isinstance(value, str) - if expected == "dict_or_none": - return value is None or isinstance(value, dict) - return True - - def _validate_structured_payload(self, *, function_name: str, payload: Any) -> Optional[str]: - fn = str(function_name or "").strip().lower() - schema = STRUCTURED_OBJECT_SCHEMAS.get(fn) - if schema is None: - return None if isinstance(payload, (dict, list)) else "structured_json_invalid" - if not isinstance(payload, dict): - return "structured_schema_invalid" - required_keys = schema.get("required_keys") or () - for key in required_keys: - if key not in payload: - return "structured_schema_invalid" - type_checks = schema.get("type_checks") or {} - for key, expected in type_checks.items(): - if not self._matches_expected_type(payload.get(key), str(expected)): - return "structured_schema_invalid" - optional_type_checks = schema.get("optional_type_checks") or {} - for key, expected_values in optional_type_checks.items(): - if key not in payload: - continue - if not any(self._matches_expected_type(payload.get(key), str(expected)) for expected in expected_values): - return "structured_schema_invalid" - return None - - def _extract_structured_object_from_response( - self, - *, - function_name: str, - response: Any, - fallback_text: str, - provider: str, - model: str, - ) -> StructuredExtractionResult: - message = {} - choice: dict[str, Any] = {} - if isinstance(response, dict): - choices = response.get("choices") if isinstance(response.get("choices"), list) else [] - choice = choices[0] if choices and isinstance(choices[0], dict) else {} - message = choice.get("message") if isinstance(choice.get("message"), dict) else {} - - last_nonempty_field = "" - last_nonempty_text = "" - for field, raw_value in self._iter_structured_candidates(message=message, choice=choice, response=response): - text = self._sanitize_visible_answer(self._extract_text_from_value(raw_value)) - normalized = self._strip_code_fence(text) - if normalized: - last_nonempty_field = field - last_nonempty_text = normalized[:4000] - if not self._looks_like_json_container(normalized): - continue - candidate = self._extract_json_candidate(normalized) - if not candidate: - continue - try: - parsed = json.loads(candidate) - except Exception: - return StructuredExtractionResult( - payload=None, - source_field=field, - provider=provider, - model=model, - error_code="structured_json_invalid", - raw_text=candidate[:4000], - validation="invalid", - ) - parsed = self._unwrap_structured_payload_once(parsed) - validation_error = self._validate_structured_payload(function_name=function_name, payload=parsed) - if validation_error is not None: - return StructuredExtractionResult( - payload=None, - source_field=field, - provider=provider, - model=model, - error_code=validation_error, - raw_text=candidate[:4000], - validation="invalid", - ) - return StructuredExtractionResult( - payload=parsed if isinstance(parsed, dict) else None, - source_field=field, - provider=provider, - model=model, - raw_text=candidate[:4000], - validation="ok", - ) - - fallback_normalized = self._strip_code_fence(fallback_text) - fallback_candidate = ( - self._extract_json_candidate(fallback_normalized) - if self._looks_like_json_container(fallback_normalized) - else "" - ) - if fallback_candidate: - try: - parsed = json.loads(fallback_candidate) - except Exception: - return StructuredExtractionResult( - payload=None, - source_field="generated_text", - provider=provider, - model=model, - error_code="structured_json_invalid", - raw_text=fallback_candidate[:4000], - validation="invalid", - ) - parsed = self._unwrap_structured_payload_once(parsed) - validation_error = self._validate_structured_payload(function_name=function_name, payload=parsed) - if validation_error is None and isinstance(parsed, dict): - return StructuredExtractionResult( - payload=parsed, - source_field="generated_text", - provider=provider, - model=model, - raw_text=fallback_candidate[:4000], - validation="ok", - ) - - if not last_nonempty_field and str(fallback_text or "").strip(): - last_nonempty_field = "generated_text" - last_nonempty_text = str(fallback_text or "").strip()[:4000] - error_code = "structured_json_not_found" if last_nonempty_field else "structured_empty" - return StructuredExtractionResult( - payload=None, - source_field=last_nonempty_field, - provider=provider, - model=model, - error_code=error_code, - raw_text=last_nonempty_text, - validation="invalid", - ) - - @staticmethod - def _classify_structured_exception(exc: Exception) -> str: - if isinstance(exc, (asyncio.TimeoutError, httpx.TimeoutException)): - return "structured_timeout" - if isinstance(exc, (httpx.HTTPError, httpx.RequestError)): - return "structured_http_error" - lowered = str(exc or "").strip().lower() - if "timeout" in lowered: - return "structured_timeout" - if "status" in lowered or "http" in lowered: - return "structured_http_error" - return "structured_empty" - - @staticmethod - def _is_same_runtime_engine(a: dict[str, Any], b: dict[str, Any]) -> bool: - return ( - str(a.get("provider") or "").strip().lower() == str(b.get("provider") or "").strip().lower() - and str(a.get("model") or "").strip().lower() == str(b.get("model") or "").strip().lower() - and str(a.get("base_url") or "").strip().rstrip("/").lower() - == str(b.get("base_url") or "").strip().rstrip("/").lower() - ) - - async def _resolve_high_tier_engine_for_escalation( - self, - db: AsyncSession, - *, - tenant_id: int, - primary_engine: dict[str, Any], - ) -> Optional[dict[str, Any]]: - _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) - active_engine = self._resolve_active_engine_from_bundle(bundle) - engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)] - routed = self._resolve_tier_engine( - tier="high", - engines=engines, - active_engine=active_engine, - ) - if not routed: - return None - if self._is_same_runtime_engine(routed, primary_engine): - return None - - provider = str(routed.get("provider") or "").strip().lower() - api_key = str(routed.get("api_key") or "").strip() - if provider != "ollama" and not api_key: - return None - return routed - - async def _resolve_safety_tier_engine( - self, - db: AsyncSession, - *, - tenant_id: int, - function_name: str, - primary_engine: dict[str, Any], - ) -> Optional[dict[str, Any]]: - _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) - active_engine = self._resolve_active_engine_from_bundle(bundle) - engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)] - - safety_rule = self._match_openclaw_rule(function_name, tier_filter={"safety"}) - routed: Optional[dict[str, Any]] = None - if safety_rule is not None: - routed = self._resolve_engine_from_rule( - rule=safety_rule, - engines=engines, - active_engine=active_engine, - ) - if routed is None: - routed = self._resolve_tier_engine( - tier="safety", - engines=engines, - active_engine=active_engine, - ) - if not routed: - return None - routed = self._ensure_openclaw_control_plane_engine(routed) - if self._is_same_runtime_engine(routed, primary_engine): - return None - - provider = str(routed.get("provider") or "").strip().lower() - api_key = str(routed.get("api_key") or "").strip() - if provider != "ollama" and not api_key: - return None - return routed - - async def _try_safety_engine( - self, - db: AsyncSession, - *, - tenant_id: int, - function_name: str, - prompt: str, - system_prompt: str, - temperature: float, - max_tokens: int, - primary_engine: dict[str, Any], - reason: str, - ) -> tuple[str, Optional[dict[str, Any]]]: - safety_engine = await self._resolve_safety_tier_engine( - db, - tenant_id=tenant_id, - function_name=function_name, - primary_engine=primary_engine, - ) - if safety_engine is None: - _runtime_log( - "LLM路由", - f"Safety 引擎不可用,无法触发兜底:function={function_name}, reason={reason}", - ) - return "", None - safety_provider = str(safety_engine.get("provider") or "") - safety_model = str(safety_engine.get("model") or "") - _runtime_log( - "LLM路由", - ( - "触发 Safety 兜底:" - f"function={function_name}, provider={safety_provider}, model={safety_model}, reason={reason}" - ), - ) - try: - text = await self._chat_completion( - provider=safety_provider, - prompt=prompt, - system_prompt=system_prompt, - api_key=str(safety_engine.get("api_key") or ""), - base_url=str(safety_engine.get("base_url") or ""), - model=safety_model, - temperature=temperature, - max_tokens=max_tokens, - function_name=function_name, - ) - if not text.strip(): - raise ValueError("safety_engine_empty_response") - if self._is_invalid_structured_output(function_name=function_name, response_text=text): - raise ValueError("safety_engine_invalid_structured_output") - return text, safety_engine - except Exception as exc: - _runtime_log("LLM路由", f"Safety 兜底失败:function={function_name}, error={exc}") - return "", None - - def _pick_tier_fallback_engine( - self, - *, - tier: str, - engines: list[dict[str, Any]], - active_engine: dict[str, Any], - ) -> Optional[dict[str, Any]]: - for item in engines: - candidate = self._normalize_engine_runtime(item) - provider = str(candidate.get("provider") or "") - api_key = str(candidate.get("api_key") or "").strip() - if provider != "ollama" and not api_key: - continue - if tier == "high" and provider == "ollama": - continue - if str(candidate.get("id") or "") == str(active_engine.get("id") or ""): - continue - _runtime_log( - "LLM路由", - ( - "分层路由回退引擎生效:" - f"tier={tier}, provider={provider}, model={candidate.get('model')}" - ), - ) - return candidate - return None - - def _resolve_tier_engine( - self, - *, - tier: str, - engines: list[dict[str, Any]], - active_engine: dict[str, Any], - ) -> Optional[dict[str, Any]]: - tier = str(tier or "").strip().lower() - upper = tier.upper() - default_provider_by_tier = { - "low": "glm", - "high": "openai", - "safety": "glm", - } - provider_env = str( - os.getenv( - f"LLM_ROUTING_{upper}_PROVIDER", - default_provider_by_tier.get( - tier, - os.getenv("LLM_PROVIDER", "glm"), - ), - ) - or "" - ).strip() - engine_id = str(os.getenv(f"LLM_ROUTING_{upper}_ENGINE_ID", "") or "").strip() - model_override = str(os.getenv(f"LLM_ROUTING_{upper}_MODEL", "") or "").strip() - base_url_override = str(os.getenv(f"LLM_ROUTING_{upper}_BASE_URL", "") or "").strip().rstrip("/") - - routed: Optional[dict[str, Any]] = None - if engine_id: - for item in engines: - if str(item.get("id") or "") == engine_id: - routed = self._normalize_engine_runtime(item) - break - - if routed is None and provider_env: - try: - provider = _normalize_provider(provider_env) - except Exception: - provider = "" - if provider: - for item in engines: - if str(item.get("provider") or "").strip().lower() == provider: - routed = self._normalize_engine_runtime(item) - break - if routed is None: - try: - routed = self._env_provider_runtime(provider) - except Exception: - routed = None - - if routed is None: - return None - - provider = str(routed.get("provider") or "").strip().lower() - if model_override: - routed["model"] = model_override - elif self._openclaw_routing_enabled() and provider == "openai": - default_model = DEFAULT_TIER_MODEL_OVERRIDES.get(tier) - if default_model: - routed["model"] = default_model - if base_url_override: - routed["base_url"] = base_url_override - elif self._openclaw_routing_enabled() and provider == "openai": - configured_openai_base = str(os.getenv("OPENAI_BASE_URL", "") or "").strip().rstrip("/") - routed["base_url"] = configured_openai_base or "https://api.openai.com/v1" - - if str(routed.get("id") or "") != str(active_engine.get("id") or ""): - _runtime_log( - "LLM路由", - ( - "混合路由已命中:" - f"层级={tier}, provider={routed.get('provider')}, model={routed.get('model')}" - ), - ) - return self._ensure_openclaw_control_plane_engine(routed) - - async def _resolve_engine_for_test( - self, - db: AsyncSession, - *, - tenant_id: int, - engine_id: Optional[str], - ) -> dict[str, Any]: - _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) - settings = self._normalize_engine_settings(bundle.get("settings")) - if not engine_id: - engine = self._resolve_active_engine_from_bundle(bundle) - return self._ensure_engine_allowed_for_test( - engine=engine, - settings=settings, - engine_label="当前激活引擎", - ) - - engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)] - for item in engines: - if str(item.get("id") or "") == str(engine_id): - engine = self._normalize_engine_runtime(item) - return self._ensure_engine_allowed_for_test( - engine=engine, - settings=settings, - engine_label="目标引擎", - ) - raise ValueError("engine 不存在") - - async def _resolve_active_prompt_profile(self, db: AsyncSession, *, tenant_id: int) -> dict[str, str]: - cached = self._prompt_profile_cache.get(int(tenant_id)) - now = time.time() - if cached and cached[0] > now: - return dict(cached[1]) - - role_name = "" - system_prompt = "" - role_key = "" - try: - row = ( - await db.execute( - select(Integration).where( - and_( - Integration.tenant_id == int(tenant_id), - Integration.type == "ai_prompt_profiles", - ) - ) - ) - ).scalar_one_or_none() - bundle = _safe_json(row.config if row else {}) - active_key = str(bundle.get("active_role_key") or "").strip() - roles = bundle.get("roles") if isinstance(bundle.get("roles"), list) else [] - selected = None - for item in roles: - if not isinstance(item, dict): - continue - item_key = str(item.get("role_key") or "").strip() - if item_key == active_key: - selected = item - break - if selected is None: - for item in roles: - if isinstance(item, dict) and bool(item.get("enabled", True)): - selected = item - break - if isinstance(selected, dict): - role_name = str(selected.get("role_name") or "").strip() - system_prompt = str(selected.get("system_prompt") or "").strip() - role_key = str(selected.get("role_key") or "").strip() - except Exception: - role_name = "" - system_prompt = "" - role_key = "" - - if len(system_prompt) < 20: - fallback = build_default_role_prompt_bundle() - fallback_key = str(fallback.get("active_role_key") or "").strip() - roles = fallback.get("roles") if isinstance(fallback.get("roles"), list) else [] - role = None - for item in roles: - if not isinstance(item, dict): - continue - if str(item.get("role_key") or "").strip() == fallback_key: - role = item - break - if role is None and roles: - role = roles[0] if isinstance(roles[0], dict) else None - if isinstance(role, dict): - role_name = str(role.get("role_name") or role_name or "默认角色").strip() - system_prompt = str(role.get("system_prompt") or "").strip() - role_key = str(role.get("role_key") or role_key or fallback_key).strip() - - result = { - "role_key": role_key, - "role_name": role_name, - "system_prompt": system_prompt, - } - self._prompt_profile_cache[int(tenant_id)] = (now + PROMPT_PROFILE_CACHE_TTL_SECONDS, result) - return dict(result) - - def _build_fallback_engines(self, primary_engine: dict[str, Any]) -> list[dict[str, Any]]: - candidates: list[dict[str, Any]] = [] - provider = _normalize_provider(primary_engine.get("provider")) - current_model = str(primary_engine.get("model") or "").strip() - - provider_runtime = self._env_provider_runtime(provider) - default_model = str(provider_runtime.get("model") or PROVIDER_DEFAULTS.get(provider, {}).get("model") or "").strip() - if default_model and default_model != current_model: - same_provider = dict(primary_engine) - same_provider["model"] = default_model - candidates.append(self._normalize_engine_runtime(same_provider)) - - fallback_provider_raw = str(os.getenv("LLM_FALLBACK_PROVIDER", "ollama")).strip() - if fallback_provider_raw: - fallback_provider = _normalize_provider(fallback_provider_raw) - if fallback_provider != provider: - candidates.append(self._env_provider_runtime(fallback_provider)) - - return candidates - - def _env_provider_runtime(self, provider: str) -> dict[str, Any]: - provider_name = _normalize_provider(provider) - defaults = PROVIDER_DEFAULTS[provider_name] - api_env = str(defaults.get("api_env") or "").strip() - api_key = os.getenv(api_env, "") if api_env else "" - if provider_name == "glm": - api_key = os.getenv("GLM_API_KEY", os.getenv("ZHIPUAI_API_KEY", api_key)) - - base_url = os.getenv(f"{provider_name.upper()}_BASE_URL", defaults["base_url"]) - model = os.getenv(f"{provider_name.upper()}_MODEL", defaults["model"]) - if provider_name == "ollama": - base_url = os.getenv("OLLAMA_BASE_URL", os.getenv("OLLAMA_HOST", defaults["base_url"])) - model = os.getenv("OLLAMA_MODEL", os.getenv("OLLAMA_CHAT_MODEL", defaults["model"])) - - return { - "id": f"env-{provider_name}", - "name": f"{defaults['name']} (ENV)", - "provider": provider_name, - "api_key": str(api_key or "").strip(), - "base_url": str(base_url or defaults["base_url"]).strip().rstrip("/"), - "model": str(model or defaults["model"]).strip(), - } - - def _env_engine_runtime(self) -> dict[str, Any]: - provider = _normalize_provider(os.getenv("LLM_PROVIDER", "glm")) - runtime = self._env_provider_runtime(provider) - runtime["id"] = "env-default" - return runtime - - def _normalize_engine_runtime(self, engine: dict[str, Any]) -> dict[str, Any]: - provider = _normalize_provider(engine.get("provider")) - defaults = PROVIDER_DEFAULTS[provider] - runtime = dict(engine) - runtime["provider"] = provider - runtime["base_url"] = str(runtime.get("base_url") or defaults["base_url"]).strip().rstrip("/") - runtime["model"] = str(runtime.get("model") or defaults["model"]).strip() - runtime["api_key"] = str(runtime.get("api_key") or "").strip() - runtime["name"] = str(runtime.get("name") or defaults["name"]).strip() - if not runtime.get("id"): - runtime["id"] = str(uuid4()) - return runtime - - async def _log_ai_call( - self, - *, - tenant_id: int, - function_name: str, - prompt: str, - response: str, - status: str, - provider: str = "", - model: str = "", - ) -> None: - tokens_used = max(len(prompt) // 4, 1) + max(len(response) // 4, 0) - cost = self._estimate_cost(provider=provider, model=model, tokens=tokens_used) - row_payload = { - "tenant_id": tenant_id, - "user_id": None, - "function_name": function_name, - "prompt": prompt[:8000], - "response": (response or "")[:8000], - "tokens_used": tokens_used, - "cost": cost, - "status": status, - } - async with AsyncSessionLocal() as session: - for attempt in range(2): - try: - session.add(AICallLog(**row_payload)) - await session.commit() - return - except OperationalError as exc: - await session.rollback() - if "database is locked" not in str(exc).lower() or attempt >= 1: - return - await asyncio.sleep(0.05 * (attempt + 1)) - except Exception: - await session.rollback() - return - - @staticmethod - def _estimate_cost(provider: str, model: str, tokens: int) -> float: - _ = model - per_1k = { - "deepseek": 0.0003, - "glm": 0.0006, - "openai": 0.0012, - "ollama": 0.0, - }.get(provider, 0.0005) - return round((tokens / 1000.0) * per_1k, 6) - - @staticmethod - def _parse_json_object(text: str) -> dict[str, Any]: - raw = (text or "").strip() - if raw.startswith("```"): - parts = raw.split("```") - if len(parts) >= 2: - raw = parts[1].strip() - if raw.startswith("json"): - raw = raw[4:].strip() - try: - parsed = json.loads(raw) - return parsed if isinstance(parsed, dict) else {} - except Exception: - return {} - - @staticmethod - def _parse_json_array(text: str) -> list[dict]: - raw = (text or "").strip() - if raw.startswith("```"): - parts = raw.split("```") - if len(parts) >= 2: - raw = parts[1].strip() - if raw.startswith("json"): - raw = raw[4:].strip() - try: - parsed = json.loads(raw) - if isinstance(parsed, list): - return [item for item in parsed if isinstance(item, dict)] - except Exception: - return [] - return [] - - @staticmethod - def _extract_text_from_value(value: Any) -> str: - if value is None: - return "" - if isinstance(value, str): - return value - if isinstance(value, (int, float, bool)): - return str(value) - if isinstance(value, dict): - parts: list[str] = [] - for key in ("text", "content", "value", "output_text", "input_text", "arguments", "json"): - if key in value: - parts.append(LLMService._extract_text_from_value(value.get(key))) - return "".join(parts) - if isinstance(value, list): - return "".join(LLMService._extract_text_from_value(item) for item in value) - return str(value) - - @staticmethod - def _sanitize_visible_answer(text: str) -> str: - cleaned = str(text or "") - if not cleaned: - return "" - cleaned = re.sub(r".*?", "", cleaned, flags=re.IGNORECASE | re.DOTALL) - return cleaned.strip() - - -llm_service = LLMService() +__all__ = ["ChatCompletionResult", "StructuredExtractionResult", "LLMService", "llm_service"] diff --git a/backend/app/services/llm_service/__init__.py b/backend/app/services/llm_service/__init__.py new file mode 100644 index 00000000..3cb5fb99 --- /dev/null +++ b/backend/app/services/llm_service/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .service import ChatCompletionResult, LLMService, StructuredExtractionResult, llm_service + +__all__ = ["ChatCompletionResult", "StructuredExtractionResult", "LLMService", "llm_service"] diff --git a/backend/app/services/llm_service/mixin_cache_circuit.py b/backend/app/services/llm_service/mixin_cache_circuit.py new file mode 100644 index 00000000..fcd65fdc --- /dev/null +++ b/backend/app/services/llm_service/mixin_cache_circuit.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from .shared import * + +class LLMCacheCircuitMixin: + def __init__(self) -> None: + self.request_timeout = float(os.getenv("LLM_REQUEST_TIMEOUT", "45")) + self.redis_url = os.getenv("REDIS_URL") + self._redis: Optional[Any] = None + self._prompt_profile_cache: dict[int, tuple[float, dict[str, str]]] = {} + self._openclaw_routing_cache: tuple[float, dict[str, Any]] | None = None + + async def _get_redis(self): + if not REDIS_AVAILABLE or not self.redis_url: + return None + if self._redis is None: + self._redis = redis_async.from_url(self.redis_url, decode_responses=True) + return self._redis + + @staticmethod + def _normalize_prompt_for_hash(prompt: str) -> str: + return re.sub(r"\s+", " ", str(prompt or "")).strip() + + @classmethod + def _structured_cache_key(cls, *, function_name: str, model: str, prompt: str) -> str: + normalized_prompt = cls._normalize_prompt_for_hash(prompt) + digest = hashlib.sha256(normalized_prompt.encode("utf-8")).hexdigest() + return f"pmflow:structured:{function_name}:{model}:{digest}" + + async def _structured_cache_get(self, *, key: str) -> Optional[dict[str, Any]]: + redis = await self._get_redis() + if redis is None: + return None + try: + raw = await redis.get(key) + except Exception: + return None + if not raw: + return None + try: + parsed = json.loads(raw) + except Exception: + return None + return parsed if isinstance(parsed, dict) else None + + async def _structured_cache_set(self, *, key: str, value: dict[str, Any], ttl: int = STRUCTURED_CACHE_TTL_SECONDS) -> None: + redis = await self._get_redis() + if redis is None: + return + try: + await redis.setex(key, ttl, json.dumps(value, ensure_ascii=False)) + except Exception: + return + + @staticmethod + def _provider_circuit_key(*, provider: str, base_url: str, model: str) -> str: + return f"{str(provider or '').strip().lower()}|{str(base_url or '').strip().rstrip('/')}|{str(model or '').strip()}" + + @staticmethod + def _http_status_from_exception(exc: Exception) -> Optional[int]: + if isinstance(exc, httpx.HTTPStatusError) and exc.response is not None: + try: + return int(exc.response.status_code) + except Exception: + return None + return None + + @staticmethod + def _is_retryable_http_status(status_code: Optional[int]) -> bool: + if status_code is None: + return False + return int(status_code) in {408, 409, 429, 500, 502, 503, 504} + + @staticmethod + def _parse_retry_after_seconds(raw_value: Optional[str]) -> Optional[float]: + value = str(raw_value or "").strip() + if not value: + return None + try: + seconds = float(value) + except Exception: + return None + if seconds <= 0: + return None + return min(seconds, 10.0) + + @classmethod + def _http_retry_delay_seconds( + cls, + *, + status_code: Optional[int], + attempt: int, + retry_after_header: Optional[str], + ) -> float: + retry_after = cls._parse_retry_after_seconds(retry_after_header) + if retry_after is not None: + return retry_after + base = 0.8 + if int(status_code or 0) == 429: + base = 1.2 + capped_attempt = max(0, min(int(attempt), 6)) + return min(base * (2 ** capped_attempt), 6.0) + + @classmethod + def _is_fail_fast_exception(cls, exc: Exception) -> bool: + status_code = cls._http_status_from_exception(exc) + if status_code in {401, 403}: + return True + text = str(exc or "").strip().lower() + return any(marker in text for marker in FAIL_FAST_ERROR_MARKERS) + + @classmethod + def _is_provider_circuit_open_exception(cls, exc: Exception) -> bool: + return "provider_circuit_open" in str(exc or "").strip().lower() + + @classmethod + def _is_circuit_breaker_worthy_exception(cls, exc: Exception) -> bool: + if cls._is_fail_fast_exception(exc): + return True + if isinstance(exc, httpx.RequestError): + return True + status_code = cls._http_status_from_exception(exc) + return cls._is_retryable_http_status(status_code) + + @classmethod + def _is_provider_circuit_open(cls, circuit_key: str) -> bool: + state = cls._provider_circuit_state.get(str(circuit_key)) + if not state: + return False + try: + open_until = float(state.get("open_until") or 0.0) + except Exception: + open_until = 0.0 + now = time.monotonic() + if open_until <= now: + if open_until: + cls._provider_circuit_state.pop(str(circuit_key), None) + return False + return True + + @classmethod + def _record_provider_success(cls, circuit_key: str) -> None: + cls._provider_circuit_state.pop(str(circuit_key), None) + + @classmethod + def _record_provider_failure(cls, circuit_key: str, exc: Exception) -> None: + if cls._is_provider_circuit_open_exception(exc): + return + if not cls._is_circuit_breaker_worthy_exception(exc): + return + now = time.monotonic() + state = dict(cls._provider_circuit_state.get(str(circuit_key)) or {}) + consecutive = int(state.get("consecutive_failures") or 0) + 1 + open_until = float(state.get("open_until") or 0.0) + if consecutive >= CIRCUIT_BREAKER_FAILURE_THRESHOLD: + open_until = now + CIRCUIT_BREAKER_COOLDOWN_SECONDS + cls._provider_circuit_state[str(circuit_key)] = { + "consecutive_failures": consecutive, + "open_until": open_until, + "last_error": str(exc)[:240], + "updated_at_monotonic": now, + } + diff --git a/backend/app/services/llm_service/mixin_chat_openai.py b/backend/app/services/llm_service/mixin_chat_openai.py new file mode 100644 index 00000000..b7f78b65 --- /dev/null +++ b/backend/app/services/llm_service/mixin_chat_openai.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +from .shared import * + +class LLMChatOpenAIMixin: + async def _chat_completion_via_openai_compatible( + self, + *, + key: str, + base_url: str, + model: str, + temperature: float, + max_tokens: int, + messages: list[dict[str, str]], + request_timeout: float, + structured_output: bool, + function_label: str, + provider_name: str, + is_openclaw_gateway_request: bool, + openclaw_agents_timeout_seconds: int, + request_started_at_perf: float, + request_trace_id: str, + openclaw_agent_timeout: Optional[int], + ) -> ChatCompletionResult: + endpoint = f"{base_url.rstrip('/')}/chat/completions" + headers = { + "Authorization": f"Bearer {key}", + "Content-Type": "application/json", + } + current_max_tokens = max(int(max_tokens or 0), 1) + empty_content_retry_budget = 1 + http_status_retry_budget = 2 + http_status_retry_attempt = 0 + while True: + payload = { + "model": model, + "messages": messages, + "temperature": temperature, + "max_tokens": current_max_tokens, + } + if is_openclaw_gateway_request: + payload["agents"] = { + "defaults": { + "timeoutSeconds": int(openclaw_agents_timeout_seconds), + } + } + async with httpx.AsyncClient(timeout=request_timeout, trust_env=False) as client: + resp = await client.post(endpoint, headers=headers, json=payload) + if is_openclaw_gateway_request: + elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0 + _runtime_log( + "OpenClaw调用", + ( + f"http ts={_cn_now_text_ms()} trace_id={request_trace_id} " + f"function={function_label} status={resp.status_code} elapsed_ms={elapsed_ms:.0f}" + ), + ) + if resp.status_code >= 400: + body_preview = str(resp.text or "").replace("\n", " ").strip()[:260] + if is_openclaw_gateway_request: + _runtime_log( + "OpenClaw调用", + ( + f"http_error ts={_cn_now_text_ms()} trace_id={request_trace_id} " + f"function={function_label} status={resp.status_code} body={body_preview}" + ), + ) + status_code = int(resp.status_code) + can_retry_http_status = ( + http_status_retry_budget > 0 + and self._is_retryable_http_status(status_code) + ) + if can_retry_http_status: + retry_delay = self._http_retry_delay_seconds( + status_code=status_code, + attempt=http_status_retry_attempt, + retry_after_header=resp.headers.get("Retry-After"), + ) + _runtime_log( + "LLM", + ( + "HTTP状态重试:" + f"function={function_label}, provider={provider_name}, model={model}, " + f"status={status_code}, retry_in={retry_delay:.2f}s, " + f"left={http_status_retry_budget - 1}" + ), + ) + http_status_retry_budget -= 1 + http_status_retry_attempt += 1 + await asyncio.sleep(retry_delay) + continue + resp.raise_for_status() + data = resp.json() + choices = data.get("choices") or [] + if not choices: + raise ValueError("LLM 响应无 choices") + first_choice = choices[0] if isinstance(choices[0], dict) else {} + message = first_choice.get("message") if isinstance(first_choice, dict) else {} + text = self._extract_text_from_chat_response( + message=message, + choice=first_choice, + response=data, + structured_output=structured_output, + ) + if ( + not text + and not structured_output + and self._reasoning_content_fallback_enabled(function_name=function_label) + ): + fallback_text = self._extract_reasoning_fallback_text( + message=message, + choice=first_choice, + response=data, + ) + if fallback_text: + text = fallback_text + _runtime_log( + "LLM", + ( + "空content回退reasoning_content:" + f"function={function_label}, provider={provider_name}, model={model}, " + f"text_len={len(text)}" + ), + ) + if text or structured_output: + break + + msg_keys = list(message.keys()) if isinstance(message, dict) else [] + choice_keys = list(first_choice.keys()) if isinstance(first_choice, dict) else [] + choice_preview = "" + try: + choice_preview = json.dumps(first_choice, ensure_ascii=False)[:300] + except Exception: + choice_preview = str(first_choice)[:300] + empty_error = ValueError( + "LLM 返回空内容: " + f"message_keys={msg_keys}, choice_keys={choice_keys}, choice_preview={choice_preview}" + ) + can_retry_empty = ( + empty_content_retry_budget > 0 + and self._should_retry_empty_chat_response( + structured_output=structured_output, + message=message, + choice=first_choice, + current_max_tokens=current_max_tokens, + ) + ) + if not can_retry_empty: + raise empty_error + + empty_content_retry_budget -= 1 + next_max_tokens = self._next_retry_max_tokens_for_empty_chat(current_max_tokens) + _runtime_log( + "LLM", + ( + "空内容提额重试:" + f"function={function_label}, provider={provider_name}, model={model}, " + f"max_tokens={current_max_tokens}->{next_max_tokens}" + ), + ) + current_max_tokens = next_max_tokens + if _looks_like_openclaw_timeout_message(text): + elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0 + _runtime_log( + "OpenClaw超时" if is_openclaw_gateway_request else "LLM超时文本", + ( + f"detected ts={_cn_now_text_ms()} trace_id={request_trace_id} " + f"function={function_label} elapsed_ms={elapsed_ms:.0f} " + f"text={text.replace(chr(10), ' ')[:260]}" + ), + ) + logger.warning( + "llm_timeout_payload_detected", + trace_id=request_trace_id, + function_name=function_label, + provider=provider_name, + model=model, + base_url=str(base_url or "")[:120], + request_timeout_seconds=float(request_timeout), + agent_timeout_seconds=openclaw_agent_timeout, + text_preview=text.replace("\n", " ")[:260], + ) + if is_openclaw_gateway_request: + elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0 + _runtime_log( + "OpenClaw调用", + ( + f"end ts={_cn_now_text_ms()} trace_id={request_trace_id} " + f"function={function_label} elapsed_ms={elapsed_ms:.0f} text_len={len(text)}" + ), + ) + return ChatCompletionResult(text=text, raw_response=data) + diff --git a/backend/app/services/llm_service/mixin_chat_transport.py b/backend/app/services/llm_service/mixin_chat_transport.py new file mode 100644 index 00000000..46aa21f3 --- /dev/null +++ b/backend/app/services/llm_service/mixin_chat_transport.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +from .shared import * + +class LLMChatTransportMixin: + async def _chat_completion( + self, + *, + provider: str, + prompt: str, + system_prompt: str, + api_key: str, + base_url: str, + model: str, + temperature: float, + max_tokens: int, + function_name: str = "", + response_mode: str = "complete", + ) -> str: + result = await self._chat_completion_raw( + provider=provider, + prompt=prompt, + system_prompt=system_prompt, + api_key=api_key, + base_url=base_url, + model=model, + temperature=temperature, + max_tokens=max_tokens, + function_name=function_name, + response_mode=response_mode, + ) + return result.text + + async def _chat_completion_raw( + self, + *, + provider: str, + prompt: str, + system_prompt: str, + api_key: str, + base_url: str, + model: str, + temperature: float, + max_tokens: int, + function_name: str = "", + response_mode: str = "complete", + ) -> ChatCompletionResult: + provider_name = _normalize_provider(provider) + structured_output = self._structured_output_required(function_name) + request_timeout = self._request_timeout_for_function(function_name, response_mode=response_mode) + is_openclaw_gateway_request = self._is_openclaw_gateway_request( + provider_name=provider_name, + base_url=base_url, + ) + openclaw_agents_timeout_seconds = self._openclaw_agents_timeout_seconds() + if is_openclaw_gateway_request: + # 网关内部 agent 超时需小于客户端超时,避免本地请求先被中止。 + request_timeout = max(float(request_timeout), float(openclaw_agents_timeout_seconds) + 5.0) + use_provider_circuit_breaker = self._use_provider_circuit_breaker(function_name) + circuit_key = self._provider_circuit_key( + provider=provider_name, + base_url=base_url, + model=model, + ) + if use_provider_circuit_breaker and self._is_provider_circuit_open(circuit_key): + raise ValueError(f"provider_circuit_open:{provider_name}:{model}") + key = api_key.strip() + messages: list[dict[str, str]] = [] + cleaned_system_prompt = self._inject_function_guardrails( + system_prompt=system_prompt, + function_name=function_name, + ) + if cleaned_system_prompt: + messages.append({"role": "system", "content": cleaned_system_prompt}) + messages.append({"role": "user", "content": prompt}) + request_trace_id = uuid4().hex[:12] + request_started_at_perf = time.perf_counter() + function_label = str(function_name or "llm_generate") + openclaw_agent_timeout = int(openclaw_agents_timeout_seconds) if is_openclaw_gateway_request else None + + if is_openclaw_gateway_request: + _runtime_log( + "OpenClaw调用", + ( + f"start ts={_cn_now_text_ms()} trace_id={request_trace_id} " + f"function={function_label} provider={provider_name} model={model} " + f"request_timeout={float(request_timeout):.2f}s " + f"agent_timeout={openclaw_agent_timeout if openclaw_agent_timeout is not None else 'N/A'}" + ), + ) + + try: + if provider_name == "ollama": + result = await self._chat_completion_via_ollama( + base_url=base_url, + model=model, + temperature=temperature, + max_tokens=max_tokens, + messages=messages, + request_timeout=request_timeout, + ) + else: + if not key: + raise ValueError("未配置 LLM API Key") + result = await self._chat_completion_via_openai_compatible( + key=key, + base_url=base_url, + model=model, + temperature=temperature, + max_tokens=max_tokens, + messages=messages, + request_timeout=request_timeout, + structured_output=structured_output, + function_label=function_label, + provider_name=provider_name, + is_openclaw_gateway_request=is_openclaw_gateway_request, + openclaw_agents_timeout_seconds=openclaw_agents_timeout_seconds, + request_started_at_perf=request_started_at_perf, + request_trace_id=request_trace_id, + openclaw_agent_timeout=openclaw_agent_timeout, + ) + if use_provider_circuit_breaker: + self._record_provider_success(circuit_key) + return result + except Exception as exc: + if _looks_like_openclaw_timeout_message(str(exc)): + elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0 + _runtime_log( + "LLM超时异常", + ( + f"detected ts={_cn_now_text_ms()} trace_id={request_trace_id} " + f"function={function_label} elapsed_ms={elapsed_ms:.0f} " + f"error={type(exc).__name__}:{str(exc)[:260]}" + ), + ) + logger.warning( + "llm_timeout_exception_detected", + trace_id=request_trace_id, + function_name=function_label, + provider=provider_name, + model=model, + base_url=str(base_url or "")[:120], + request_timeout_seconds=float(request_timeout), + agent_timeout_seconds=openclaw_agent_timeout, + error_type=type(exc).__name__, + error=str(exc)[:260], + ) + if is_openclaw_gateway_request: + elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0 + _runtime_log( + "OpenClaw调用", + ( + f"exception ts={_cn_now_text_ms()} trace_id={request_trace_id} " + f"function={function_label} elapsed_ms={elapsed_ms:.0f} " + f"error={type(exc).__name__}:{str(exc)[:260]}" + ), + ) + if use_provider_circuit_breaker: + self._record_provider_failure(circuit_key, exc) + raise + + async def _chat_completion_via_ollama( + self, + *, + base_url: str, + model: str, + temperature: float, + max_tokens: int, + messages: list[dict[str, str]], + request_timeout: float, + ) -> ChatCompletionResult: + endpoint = f"{base_url.rstrip('/')}/api/chat" + payload = { + "model": model, + "messages": messages, + "stream": False, + "options": { + "temperature": temperature, + "num_predict": max_tokens, + }, + } + async with httpx.AsyncClient(timeout=request_timeout, trust_env=False) as client: + resp = await client.post(endpoint, json=payload) + resp.raise_for_status() + data = resp.json() + message = data.get("message") if isinstance(data, dict) else {} + content = message.get("content") if isinstance(message, dict) else None + text = self._sanitize_visible_answer(self._extract_text_from_value(content)) + if not text: + text = self._sanitize_visible_answer(self._extract_text_from_value(data.get("response"))) + if not text and not isinstance(data, dict): + raise ValueError("Ollama 返回空内容") + return ChatCompletionResult(text=text, raw_response=data) + diff --git a/backend/app/services/llm_service/mixin_engine_admin.py b/backend/app/services/llm_service/mixin_engine_admin.py new file mode 100644 index 00000000..34fc2812 --- /dev/null +++ b/backend/app/services/llm_service/mixin_engine_admin.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +from .shared import * + +class LLMEngineAdminMixin: + async def list_engines(self, db: AsyncSession, tenant_id: int = 1) -> dict[str, Any]: + _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + engines = bundle.get("engines", []) + return { + "active_engine_id": bundle.get("active_engine_id"), + "engines": [self._sanitize_engine_for_response(item) for item in engines], + "settings": self._normalize_engine_settings(bundle.get("settings")), + } + + async def update_engine_settings( + self, + db: AsyncSession, + *, + tenant_id: int, + enable_local_kb_qa: Optional[bool] = None, + ) -> dict[str, Any]: + integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + settings = self._normalize_engine_settings(bundle.get("settings")) + if enable_local_kb_qa is not None: + settings["enable_local_kb_qa"] = bool(enable_local_kb_qa) + bundle["settings"] = settings + integration.config = json.loads(json.dumps(bundle)) + await db.commit() + return settings + + async def create_engine( + self, + db: AsyncSession, + *, + tenant_id: int, + name: str, + provider: str, + api_key: Optional[str], + model: Optional[str] = None, + base_url: Optional[str] = None, + set_active: bool = True, + ) -> dict[str, Any]: + provider = _normalize_provider(provider) + api_key = str(api_key or "").strip() + if provider != "ollama" and not api_key: + raise ValueError("api_key 不能为空") + + integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + now = now_cn_aware().isoformat() + engine_id = str(uuid4()) + defaults = PROVIDER_DEFAULTS[provider] + engine = { + "id": engine_id, + "name": (name or defaults["name"]).strip()[:80] or defaults["name"], + "provider": provider, + "api_key": api_key, + "model": (model or defaults["model"]).strip() or defaults["model"], + "base_url": (base_url or defaults["base_url"]).strip().rstrip("/") or defaults["base_url"], + "created_at": now, + "updated_at": now, + } + engines = bundle.get("engines", []) + engines.append(engine) + bundle["engines"] = engines + if set_active or not bundle.get("active_engine_id"): + bundle["active_engine_id"] = engine_id + integration.config = json.loads(json.dumps(bundle)) + integration.is_active = True + await db.commit() + return self._sanitize_engine_for_response(engine) + + async def update_engine( + self, + db: AsyncSession, + *, + tenant_id: int, + engine_id: str, + name: Optional[str] = None, + provider: Optional[str] = None, + api_key: Optional[str] = None, + model: Optional[str] = None, + base_url: Optional[str] = None, + ) -> dict[str, Any]: + integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + engines = bundle.get("engines", []) + target: Optional[dict[str, Any]] = None + for item in engines: + if str(item.get("id")) == str(engine_id): + target = item + break + if not target: + raise ValueError("engine 不存在") + + if provider is not None: + target["provider"] = _normalize_provider(provider) + active_provider = _normalize_provider(target.get("provider")) + defaults = PROVIDER_DEFAULTS[active_provider] + + if name is not None: + target["name"] = (name or "").strip()[:80] or target.get("name") or defaults["name"] + if api_key is not None: + api_key_val = str(api_key).strip() + if api_key_val: + target["api_key"] = api_key_val + if model is not None: + target["model"] = (model or "").strip() or defaults["model"] + if base_url is not None: + target["base_url"] = (base_url or "").strip().rstrip("/") or defaults["base_url"] + + target["updated_at"] = now_cn_aware().isoformat() + integration.config = json.loads(json.dumps(bundle)) + await db.commit() + return self._sanitize_engine_for_response(target) + + async def delete_engine( + self, + db: AsyncSession, + *, + tenant_id: int, + engine_id: str, + ) -> dict[str, Any]: + integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + engines = bundle.get("engines", []) + kept = [item for item in engines if str(item.get("id")) != str(engine_id)] + if len(kept) == len(engines): + raise ValueError("engine 不存在") + bundle["engines"] = kept + active_engine_id = str(bundle.get("active_engine_id") or "") + if active_engine_id == str(engine_id): + bundle["active_engine_id"] = kept[0]["id"] if kept else None + integration.config = json.loads(json.dumps(bundle)) + await db.commit() + return { + "deleted_engine_id": str(engine_id), + "active_engine_id": bundle.get("active_engine_id"), + "remaining_count": len(kept), + } + + async def activate_engine( + self, + db: AsyncSession, + *, + tenant_id: int, + engine_id: str, + ) -> dict[str, Any]: + integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + engines = bundle.get("engines", []) + exists = any(str(item.get("id")) == str(engine_id) for item in engines) + if not exists: + raise ValueError("engine 不存在") + bundle["active_engine_id"] = str(engine_id) + integration.config = json.loads(json.dumps(bundle)) + await db.commit() + return {"active_engine_id": str(engine_id)} + + async def test_engine_connection( + self, + db: AsyncSession, + *, + tenant_id: int, + engine_id: Optional[str] = None, + prompt: Optional[str] = None, + max_tokens: int = 64, + ) -> dict[str, Any]: + test_prompt = (prompt or "你是连通性测试助手。请只返回: OK").strip() + if not test_prompt: + raise ValueError("测试 prompt 不能为空") + + engine = await self._resolve_engine_for_test( + db, + tenant_id=tenant_id, + engine_id=engine_id, + ) + response_text = "" + status = "success" + started = time.perf_counter() + try: + transient_retry_budget = 2 + transient_retry_attempt = 0 + while True: + try: + response_text = await self._chat_completion( + provider=str(engine.get("provider") or ""), + prompt=test_prompt, + system_prompt="", + api_key=str(engine.get("api_key") or ""), + base_url=str(engine.get("base_url") or ""), + model=str(engine.get("model") or ""), + temperature=0.0, + max_tokens=max_tokens, + ) + break + except Exception as transient_exc: + status_code = self._http_status_from_exception(transient_exc) + can_retry_transient = ( + transient_retry_budget > 0 + and ( + self._is_retryable_http_status(status_code) + or isinstance(transient_exc, httpx.RequestError) + ) + ) + if not can_retry_transient: + raise + retry_after_header = None + if isinstance(transient_exc, httpx.HTTPStatusError) and transient_exc.response is not None: + retry_after_header = transient_exc.response.headers.get("Retry-After") + retry_delay = self._http_retry_delay_seconds( + status_code=status_code, + attempt=transient_retry_attempt, + retry_after_header=retry_after_header, + ) + _runtime_log( + "LLM", + ( + "连通测试重试:" + f"engine={engine.get('provider')}/{engine.get('model')}, " + f"status={status_code or 'request_error'}, " + f"retry_in={retry_delay:.2f}s, left={transient_retry_budget - 1}" + ), + ) + transient_retry_budget -= 1 + transient_retry_attempt += 1 + await asyncio.sleep(retry_delay) + if not response_text.strip(): + raise ValueError("测试响应为空") + latency_ms = int((time.perf_counter() - started) * 1000) + return { + "ok": True, + "engine_id": str(engine.get("id") or ""), + "name": str(engine.get("name") or ""), + "provider": str(engine.get("provider") or ""), + "model": str(engine.get("model") or ""), + "base_url": str(engine.get("base_url") or ""), + "api_key_masked": _mask_secret(str(engine.get("api_key") or "")), + "latency_ms": latency_ms, + "response_preview": response_text[:300], + } + except Exception as exc: + status = "failed" + response_text = f"[error] {exc}" + status_code = self._http_status_from_exception(exc) + if int(status_code or 0) == 429: + raise ValueError("引擎连通测试失败: 上游限流(429),请稍后重试或更换 API Key。") from exc + raise ValueError(f"引擎连通测试失败: {exc}") from exc + finally: + try: + await self._log_ai_call( + tenant_id=tenant_id, + function_name="test_engine_connection", + prompt=test_prompt, + response=response_text, + status=status, + provider=str(engine.get("provider") or ""), + model=str(engine.get("model") or ""), + ) + except Exception as log_exc: + _runtime_log("LLM", f"测试调用日志写入失败:{log_exc}") + diff --git a/backend/app/services/llm_service/mixin_engine_resolution_core.py b/backend/app/services/llm_service/mixin_engine_resolution_core.py new file mode 100644 index 00000000..e2ee7922 --- /dev/null +++ b/backend/app/services/llm_service/mixin_engine_resolution_core.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +from .shared import * + +class LLMEngineResolutionCoreMixin: + def _stub_response(self, prompt: str) -> str: + _ = prompt # 保留签名,避免调用方改动 + return ( + "我暂时无法生成可靠回答(模型服务异常)。" + "为避免误导,本次不直接给结论;请稍后重试," + "或补充更具体的目标对象后继续提问。" + ) + + def _sanitize_engine_for_response(self, item: dict[str, Any]) -> dict[str, Any]: + return { + "id": str(item.get("id") or ""), + "name": str(item.get("name") or ""), + "provider": str(item.get("provider") or ""), + "model": str(item.get("model") or ""), + "base_url": str(item.get("base_url") or ""), + "api_key_masked": _mask_secret(str(item.get("api_key") or "")), + "updated_at": item.get("updated_at"), + "created_at": item.get("created_at"), + } + + @staticmethod + def _normalize_engine_settings(raw: Any) -> dict[str, Any]: + settings = dict(LLM_ENGINE_SETTINGS_DEFAULTS) + if isinstance(raw, dict): + settings["enable_local_kb_qa"] = bool(raw.get("enable_local_kb_qa", settings["enable_local_kb_qa"])) + return settings + + @staticmethod + def _is_local_kb_qa_function(function_name: str) -> bool: + fn = str(function_name or "").strip().lower() + if not fn: + return False + if fn in LOCAL_KB_QA_FUNCTION_EXACT: + return True + return any(prefix and fn.startswith(prefix) for prefix in LOCAL_KB_QA_FUNCTION_PREFIXES) + + def _ensure_engine_allowed_for_function( + self, + *, + engine: dict[str, Any], + function_name: str, + settings: dict[str, Any], + engine_label: str, + ) -> dict[str, Any]: + runtime = self._normalize_engine_runtime(engine) + provider = str(runtime.get("source_provider") or runtime.get("provider") or "") + if provider == "ollama": + if not bool(settings.get("enable_local_kb_qa")): + raise ValueError("本地 LLM 未启用,请先在引擎配置中打开“本地 LLM(仅知识库问答)”开关") + if not self._is_local_kb_qa_function(function_name): + raise ValueError(f"本地 LLM 仅允许用于知识库问答,当前 function={function_name}") + return runtime + + if not str(runtime.get("api_key") or "").strip(): + raise ValueError(f"{engine_label} 未配置 API Key") + return runtime + + def _ensure_engine_allowed_for_test( + self, + *, + engine: dict[str, Any], + settings: dict[str, Any], + engine_label: str, + ) -> dict[str, Any]: + runtime = self._normalize_engine_runtime(engine) + provider = str(runtime.get("source_provider") or runtime.get("provider") or "") + if provider == "ollama": + if not bool(settings.get("enable_local_kb_qa")): + raise ValueError("本地 LLM 未启用,请先在引擎配置中打开“本地 LLM(仅知识库问答)”开关") + raise ValueError("本地 LLM 仅允许用于知识库问答,连通性测试请通过知识库问答链路验证") + if not str(runtime.get("api_key") or "").strip(): + raise ValueError(f"{engine_label} 未配置 API Key") + return runtime + + async def _get_or_init_bundle( + self, + db: AsyncSession, + *, + tenant_id: int, + ) -> tuple[Integration, dict[str, Any]]: + result = await db.execute( + select(Integration).where( + and_( + Integration.tenant_id == tenant_id, + Integration.type == "llm_engine", + ) + ) + ) + integration = result.scalar_one_or_none() + if not integration: + integration = Integration( + type="llm_engine", + config={"active_engine_id": None, "engines": [], "settings": self._normalize_engine_settings(None)}, + is_active=True, + tenant_id=tenant_id, + ) + db.add(integration) + await db.flush() + bundle = _safe_json(integration.config) + engines = bundle.get("engines") + if not isinstance(engines, list): + engines = [] + bundle["engines"] = engines + if "active_engine_id" not in bundle: + bundle["active_engine_id"] = None + bundle["settings"] = self._normalize_engine_settings(bundle.get("settings")) + return integration, bundle + + async def _resolve_active_engine( + self, + db: AsyncSession, + *, + tenant_id: int, + ) -> dict[str, Any]: + _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + return self._resolve_active_engine_from_bundle(bundle) + + async def _resolve_engine_for_call( + self, + db: AsyncSession, + *, + tenant_id: int, + function_name: str, + ) -> dict[str, Any]: + _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + settings = self._normalize_engine_settings(bundle.get("settings")) + active_engine = self._resolve_active_engine_from_bundle(bundle) + active_engine = self._ensure_openclaw_control_plane_engine(active_engine) + engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)] + + override_engine = await self._resolve_function_override_engine( + db, + tenant_id=tenant_id, + function_name=function_name, + engines=engines, + active_engine=active_engine, + ) + if override_engine is not None: + return self._ensure_engine_allowed_for_function( + engine=override_engine, + function_name=function_name, + settings=settings, + engine_label="override_engine", + ) + + openclaw_routed = self._resolve_openclaw_engine( + function_name=function_name, + engines=engines, + active_engine=active_engine, + ) + if openclaw_routed is not None: + openclaw_routed = self._ensure_openclaw_control_plane_engine(openclaw_routed) + return self._ensure_engine_allowed_for_function( + engine=openclaw_routed, + function_name=function_name, + settings=settings, + engine_label="openclaw_routed_engine", + ) + + if not self._mixed_routing_enabled(): + return self._ensure_engine_allowed_for_function( + engine=active_engine, + function_name=function_name, + settings=settings, + engine_label="active_engine", + ) + + tier = self._resolve_function_tier(function_name) + if tier not in {"high", "low"}: + return self._ensure_engine_allowed_for_function( + engine=active_engine, + function_name=function_name, + settings=settings, + engine_label="active_engine", + ) + + routed = self._resolve_tier_engine( + tier=tier, + engines=engines, + active_engine=active_engine, + ) + if not routed: + return self._ensure_engine_allowed_for_function( + engine=active_engine, + function_name=function_name, + settings=settings, + engine_label="active_engine", + ) + provider = str(routed.get("provider") or "") + _runtime_log( + "LLM路由", + ( + "函数路由生效:" + f"function={function_name}, tier={tier}, provider={provider}, model={routed.get('model')}" + ), + ) + return self._ensure_engine_allowed_for_function( + engine=self._ensure_openclaw_control_plane_engine(routed), + function_name=function_name, + settings=settings, + engine_label=f"{tier}_tier_engine", + ) + + async def _resolve_function_override_engine( + self, + db: AsyncSession, + *, + tenant_id: int, + function_name: str, + engines: list[dict[str, Any]], + active_engine: dict[str, Any], + ) -> Optional[dict[str, Any]]: + fn = str(function_name or "").strip().lower() + if not fn: + return None + try: + row = ( + await db.execute( + select(Integration).where( + and_( + Integration.tenant_id == int(tenant_id), + Integration.type == "model_routing", + ) + ) + ) + ).scalar_one_or_none() + bundle = _safe_json(row.config if row else {}) + mappings = bundle.get("mappings") if isinstance(bundle.get("mappings"), dict) else {} + override = mappings.get(fn) if isinstance(mappings.get(fn), dict) else None + if not override or not bool(override.get("enabled", True)): + return None + rule = { + "tier": str(override.get("tier") or "high").strip().lower() or "high", + "provider": str(override.get("provider") or "").strip().lower(), + "model": str(override.get("model") or "").strip(), + "api_env": str(override.get("api_env") or "").strip(), + "base_url": str(override.get("base_url") or "").strip(), + } + routed = self._resolve_engine_from_rule( + rule=rule, + engines=engines, + active_engine=active_engine, + ) + if routed is None: + return None + provider = str(routed.get("provider") or "") + if provider != "ollama" and not str(routed.get("api_key") or "").strip(): + raise ValueError( + "函数覆盖路由命中但目标引擎缺少 API Key:" + f"function={fn}, provider={provider}, model={routed.get('model')}" + ) + _runtime_log( + "LLM路由", + ( + "函数覆盖路由命中:" + f"function={fn}, provider={routed.get('provider')}, model={routed.get('model')}" + ), + ) + return self._ensure_openclaw_control_plane_engine(routed) + except ValueError: + raise + except Exception as exc: + _runtime_log("LLM路由", f"函数覆盖路由读取失败:function={fn}, error={exc}") + return None + + def _resolve_active_engine_from_bundle(self, bundle: dict[str, Any]) -> dict[str, Any]: + engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)] + active_engine_id = str(bundle.get("active_engine_id") or "") + if active_engine_id: + for engine in engines: + if str(engine.get("id") or "") == active_engine_id: + return self._normalize_engine_runtime(engine) + if engines: + return self._normalize_engine_runtime(engines[0]) + return self._env_engine_runtime() + diff --git a/backend/app/services/llm_service/mixin_generate_db_chat.py b/backend/app/services/llm_service/mixin_generate_db_chat.py new file mode 100644 index 00000000..cb97897c --- /dev/null +++ b/backend/app/services/llm_service/mixin_generate_db_chat.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from .shared import * + +class LLMGenerateDbChatMixin: + async def _generate_with_db(self, db: AsyncSession, prompt: str, **kwargs: Any) -> str: + started_at = time.perf_counter() + tenant_id = int(kwargs.get("tenant_id") or 1) + function_name = str(kwargs.get("function_name") or "llm_generate") + temperature = float(kwargs.get("temperature") or 0.2) + max_tokens = int(kwargs.get("max_tokens") or 1024) + use_prompt_profile = bool(kwargs.get("use_prompt_profile", True)) + response_mode = str(kwargs.get("response_mode") or "complete").strip().lower() or "complete" + response_text = "" + status = "success" + routing_decision = "primary_engine" + engine_meta: dict[str, Any] = {} + prompt_profile = {"role_name": "", "system_prompt": ""} + prompt_with_context = await self._inject_model_context_bundle_prompt( + db=db, + tenant_id=tenant_id, + function_name=function_name, + prompt=prompt, + ) + merged_prompt = self._merge_prompt_with_profile( + prompt=prompt_with_context, + prompt_profile=prompt_profile, + function_name=function_name, + ) + + def _log_stub_failure(error_obj: Exception, *, used_stub: bool = False) -> None: + elapsed_ms = (time.perf_counter() - started_at) * 1000 + provider = str(engine_meta.get("provider") or "") + model = str(engine_meta.get("model") or "") + outcome = "兜底 stub" if used_stub else "显式报错" + logger.warning( + "[LLM_FAIL] 生成失败 → %s function=%s provider=%s model=%s error=%r duration_ms=%.0f", + outcome, + function_name, + provider, + model, + error_obj, + elapsed_ms, + ) + + try: + if use_prompt_profile: + prompt_profile = await self._resolve_active_prompt_profile(db, tenant_id=tenant_id) + merged_prompt = self._merge_prompt_with_profile( + prompt=prompt_with_context, + prompt_profile=prompt_profile, + function_name=function_name, + ) + engine = await self._resolve_engine_for_call( + db, + tenant_id=tenant_id, + function_name=function_name, + ) + engine_meta = engine + provider = str(engine.get("provider") or "") + model = str(engine.get("model") or "") + response_text = await self._chat_completion( + provider=provider, + prompt=prompt_with_context, + system_prompt=prompt_profile.get("system_prompt") or "", + api_key=str(engine.get("api_key") or ""), + base_url=str(engine.get("base_url") or ""), + model=model, + temperature=temperature, + max_tokens=max_tokens, + function_name=function_name, + response_mode=response_mode, + ) + if not response_text.strip(): + raise ValueError("primary_engine_empty_response") + + if self._is_invalid_structured_output(function_name=function_name, response_text=response_text): + raise ValueError("invalid_structured_output") + return response_text + except Exception as exc: + failover_response, failover_engine = await self._retry_with_high_tier_after_low_model_failure( + db=db, + tenant_id=tenant_id, + function_name=function_name, + failed_engine=engine_meta, + prompt=prompt_with_context, + system_prompt=str(prompt_profile.get("system_prompt") or ""), + temperature=temperature, + max_tokens=max_tokens, + response_mode=response_mode, + error=exc, + ) + if failover_response and isinstance(failover_engine, dict): + routing_decision = "glm47_error_escalate_to_high" + status = "success_failover" + response_text = failover_response + engine_meta = failover_engine + return failover_response + status = "failed" + reason_text = self._build_model_error_message( + function_name=function_name, + provider=str(engine_meta.get("provider") or ""), + model=str(engine_meta.get("model") or ""), + error=exc, + ) + response_text = reason_text + _log_stub_failure(exc, used_stub=False) + raise RuntimeError(reason_text) from exc + finally: + try: + merged_prompt = self._merge_prompt_with_profile( + prompt=prompt_with_context, + prompt_profile=prompt_profile, + function_name=function_name, + ) + await self._log_ai_call( + tenant_id=tenant_id, + function_name=function_name, + prompt=merged_prompt, + response=response_text, + status=status, + provider=str(engine_meta.get("provider") or ""), + model=str(engine_meta.get("model") or ""), + ) + if routing_decision != "primary_engine": + _runtime_log( + "LLM路由", + ( + f"routing_decision={routing_decision}, " + f"function={function_name}, provider={engine_meta.get('provider')}, model={engine_meta.get('model')}" + ), + ) + except Exception as log_exc: + _runtime_log("LLM", f"调用日志写入失败:{log_exc}") + diff --git a/backend/app/services/llm_service/mixin_generate_db_structured.py b/backend/app/services/llm_service/mixin_generate_db_structured.py new file mode 100644 index 00000000..79f7b8c9 --- /dev/null +++ b/backend/app/services/llm_service/mixin_generate_db_structured.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from .shared import * + +class LLMGenerateDbStructuredMixin: + async def _generate_structured_with_db( + self, + db: AsyncSession, + prompt: str, + **kwargs: Any, + ) -> StructuredExtractionResult: + tenant_id = int(kwargs.get("tenant_id") or 1) + function_name = str(kwargs.get("function_name") or "llm_generate").strip().lower() or "llm_generate" + temperature = float(kwargs.get("temperature") or 0.0) + max_tokens = int(kwargs.get("max_tokens") or 1024) + use_prompt_profile = bool(kwargs.get("use_prompt_profile", True)) + response_mode = str(kwargs.get("response_mode") or "complete").strip().lower() or "complete" + prompt_profile = {"role_name": "", "system_prompt": ""} + prompt_with_context = await self._inject_model_context_bundle_prompt( + db=db, + tenant_id=tenant_id, + function_name=function_name, + prompt=prompt, + ) + merged_prompt = prompt_with_context + provider = "" + model = "" + engine_meta: dict[str, Any] = {} + status = "success" + response_text = "" + try: + if use_prompt_profile: + prompt_profile = await self._resolve_active_prompt_profile(db, tenant_id=tenant_id) + merged_prompt = self._merge_prompt_with_profile( + prompt=prompt_with_context, + prompt_profile=prompt_profile, + function_name=function_name, + ) + engine = await self._resolve_engine_for_call( + db, + tenant_id=tenant_id, + function_name=function_name, + ) + engine_meta = engine + provider = str(engine.get("provider") or "") + model = str(engine.get("model") or "") + cache_key = "" + if self._structured_cache_enabled(function_name): + cache_key = self._structured_cache_key( + function_name=function_name, + model=model or "unknown", + prompt=merged_prompt, + ) + cached = await self._structured_cache_get(key=cache_key) + if cached is not None: + status = "success_cache_hit" + result = StructuredExtractionResult( + payload=cached, + source_field="cache", + provider=provider, + model=model, + raw_text=json.dumps(cached, ensure_ascii=False), + validation="ok", + from_cache=True, + ) + _runtime_log( + "LLM结构化", + f"function={function_name}, structured_source=cache, validation=ok, fallback=false", + ) + return result + + completion = await self._chat_completion_raw( + provider=provider, + prompt=prompt_with_context, + system_prompt=prompt_profile.get("system_prompt") or "", + api_key=str(engine.get("api_key") or ""), + base_url=str(engine.get("base_url") or ""), + model=model, + temperature=temperature, + max_tokens=max_tokens, + function_name=function_name, + response_mode=response_mode, + ) + response_text = completion.text + extraction = self._extract_structured_object_from_response( + function_name=function_name, + response=completion.raw_response, + fallback_text=completion.text, + provider=provider, + model=model, + ) + _runtime_log( + "LLM结构化", + ( + f"function={function_name}, structured_source={extraction.source_field or 'none'}, " + f"validation={extraction.validation}, fallback=false" + ), + ) + if extraction.payload is None: + raise ValueError(str(extraction.error_code or "structured_schema_invalid")) + if extraction.payload is not None and cache_key: + await self._structured_cache_set(key=cache_key, value=extraction.payload) + status = extraction.error_code or "success" + return extraction + except Exception as exc: + failover_result, failover_engine = await self._retry_structured_with_high_tier_after_low_model_failure( + db=db, + tenant_id=tenant_id, + function_name=function_name, + failed_engine=engine_meta, + prompt=prompt_with_context, + system_prompt=str(prompt_profile.get("system_prompt") or ""), + temperature=temperature, + max_tokens=max_tokens, + response_mode=response_mode, + error=exc, + ) + if isinstance(failover_result, StructuredExtractionResult) and isinstance(failover_engine, dict): + provider = str(failover_engine.get("provider") or "") + model = str(failover_engine.get("model") or "") + response_text = failover_result.raw_text or json.dumps(failover_result.payload or {}, ensure_ascii=False) + status = failover_result.error_code or "success_failover" + return failover_result + error_code = self._classify_structured_exception(exc) + status = error_code + reason_text = self._build_model_error_message( + function_name=function_name, + provider=provider, + model=model, + error=exc, + ) + response_text = reason_text + _runtime_log( + "LLM结构化", + f"function={function_name}, structured_source=none, validation=invalid, fallback=false", + ) + raise RuntimeError(reason_text) from exc + finally: + try: + log_payload = response_text or status + await self._log_ai_call( + tenant_id=tenant_id, + function_name=function_name, + prompt=merged_prompt, + response=log_payload, + status=status, + provider=provider, + model=model, + ) + except Exception as log_exc: + _runtime_log("LLM", f"结构化调用日志写入失败:{log_exc}") + diff --git a/backend/app/services/llm_service/mixin_generate_entry.py b/backend/app/services/llm_service/mixin_generate_entry.py new file mode 100644 index 00000000..06aef94a --- /dev/null +++ b/backend/app/services/llm_service/mixin_generate_entry.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from .shared import * + +class LLMGenerateEntryMixin: + async def generate(self, prompt: str, **kwargs: Any) -> str: + call_kwargs = dict(kwargs) + db = call_kwargs.pop("db", None) or call_kwargs.pop("db_session", None) + if db is None: + async with AsyncSessionLocal() as session: + return await self._generate_with_db(session, prompt, **call_kwargs) + return await self._generate_with_db(db, prompt, **call_kwargs) + + async def generate_structured_object(self, prompt: str, **kwargs: Any) -> StructuredExtractionResult: + call_kwargs = dict(kwargs) + db = call_kwargs.pop("db", None) or call_kwargs.pop("db_session", None) + if db is None: + async with AsyncSessionLocal() as session: + return await self._generate_structured_with_db(session, prompt, **call_kwargs) + return await self._generate_structured_with_db(db, prompt, **call_kwargs) + + async def _inject_model_context_bundle_prompt( + self, + *, + db: Any, + tenant_id: int, + function_name: str, + prompt: str, + ) -> str: + raw_prompt = str(prompt or "") + normalized_function = str(function_name or "").strip().lower() + if not ModelContextBundleService.should_inject_context(normalized_function): + return raw_prompt + # 单测里可能传入 object()/mock 作为 db 占位,此时直接跳过注入。 + if not hasattr(db, "execute"): + return raw_prompt + try: + return await ModelContextBundleService.inject_prompt_with_bundle( + db, + tenant_id=int(tenant_id), + function_name=normalized_function, + prompt=raw_prompt, + ) + except Exception as exc: + _runtime_log("LLM上下文", f"上下文注入失败,已降级原始 prompt:function={normalized_function}, error={exc}") + return raw_prompt + diff --git a/backend/app/services/llm_service/mixin_guardrails_prompt.py b/backend/app/services/llm_service/mixin_guardrails_prompt.py new file mode 100644 index 00000000..ef1934a7 --- /dev/null +++ b/backend/app/services/llm_service/mixin_guardrails_prompt.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from .shared import * + +class LLMGuardrailsPromptMixin: + @staticmethod + def _requires_ddl_api_guardrail(function_name: str) -> bool: + fn = str(function_name or "").strip().lower() + if not fn: + return False + return fn in DDL_API_GUARDRAIL_FUNCTION_EXACT + + @classmethod + def _inject_function_guardrails(cls, *, system_prompt: str, function_name: str) -> str: + base_prompt = str(system_prompt or "").strip() + if not cls._requires_ddl_api_guardrail(function_name): + return base_prompt + extra = str(DDL_API_GUARDRAIL_SYSTEM_PROMPT or "").strip() + if not extra: + return base_prompt + if extra in base_prompt: + return base_prompt + if not base_prompt: + return extra + return f"{base_prompt}\n\n{extra}" + + @classmethod + def _merge_prompt_with_profile( + cls, + *, + prompt: str, + prompt_profile: dict[str, str], + function_name: str = "", + ) -> str: + system_prompt = cls._inject_function_guardrails( + system_prompt=str(prompt_profile.get("system_prompt") or "").strip(), + function_name=function_name, + ) + if not system_prompt: + return prompt + role_name = str(prompt_profile.get("role_name") or "unknown").strip() or "unknown" + return f"[system_role:{role_name}]\n{system_prompt}\n\n{prompt}" + + def _direct_emergency_enabled(self) -> bool: + return _env_bool("LLM_DIRECT_EMERGENCY_ENABLED", False) + + @staticmethod + def _prefer_fast_local_fallback(function_name: str) -> bool: + fn = str(function_name or "").strip().lower() + if not fn: + return False + return any(hint and hint in fn for hint in FAST_LOCAL_FALLBACK_FUNCTION_HINTS) + + @staticmethod + def _should_raise_model_error(function_name: str) -> bool: + fn = str(function_name or "").strip().lower() + if not fn: + return False + if fn in STRICT_MODEL_ERROR_FUNCTION_EXACT: + return True + return any(prefix and fn.startswith(prefix) for prefix in STRICT_MODEL_ERROR_FUNCTION_PREFIXES) + + @staticmethod + def _build_model_error_message( + *, + function_name: str, + provider: str, + model: str, + error: Exception, + ) -> str: + clean_error = re.sub(r"\s+", " ", str(error or "")).strip() + if len(clean_error) > 160: + clean_error = f"{clean_error[:160]}..." + provider_text = str(provider or "unknown").strip() or "unknown" + model_text = str(model or "unknown").strip() or "unknown" + fn_text = str(function_name or "llm_generate").strip() or "llm_generate" + if not clean_error: + clean_error = "upstream_empty_or_invalid_response" + return ( + f"模型调用失败:function={fn_text}, provider={provider_text}, model={model_text}, " + f"reason={clean_error}" + ) + + @staticmethod + def _is_fast_local_planner_function(function_name: str) -> bool: + fn = str(function_name or "").strip().lower() + return any(hint and hint in fn for hint in FAST_LOCAL_PLANNER_FUNCTION_HINTS) + + @staticmethod + def _is_fast_local_worker_function(function_name: str) -> bool: + fn = str(function_name or "").strip().lower() + return any(hint and hint in fn for hint in FAST_LOCAL_WORKER_FUNCTION_HINTS) + + @staticmethod + def _structured_cache_enabled(function_name: str) -> bool: + fn = str(function_name or "").strip().lower() + return fn in { + "signal_source_onboarding_plan", + } + + def _request_timeout_for_function(self, function_name: str, *, response_mode: str = "complete") -> float: + if not self._prefer_fast_local_fallback(function_name): + return self.request_timeout + env_key = "LLM_FAST_LOCAL_FALLBACK_TIMEOUT_COMPLETE" + default_timeout = FAST_LOCAL_FALLBACK_COMPLETE_TIMEOUT_SECONDS + if self._is_fast_local_planner_function(function_name) and str(response_mode or "").strip().lower() == "fast": + env_key = "LLM_FAST_LOCAL_FALLBACK_TIMEOUT_FAST" + default_timeout = FAST_LOCAL_FALLBACK_FAST_TIMEOUT_SECONDS + try: + configured = float(os.getenv(env_key, str(default_timeout))) + except Exception: + configured = default_timeout + if configured <= 0: + return self.request_timeout + return min(self.request_timeout, configured) + + @staticmethod + def _is_openclaw_gateway_request(*, provider_name: str, base_url: str) -> bool: + if str(provider_name or "").strip().lower() != "openai": + return False + normalized_base_url = str(base_url or "").strip().lower() + if not normalized_base_url: + return False + return "openclaw" in normalized_base_url + + @staticmethod + def _openclaw_agents_timeout_seconds() -> int: + raw = os.getenv( + "OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS", + str(OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS), + ) + try: + configured = int(str(raw).strip()) + except Exception: + configured = OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS + if configured <= 0: + configured = OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS + return min(configured, OPENCLAW_AGENTS_TIMEOUT_SECONDS_MAX) + + @staticmethod + def _should_retry_empty_chat_response( + *, + structured_output: bool, + message: Any, + choice: Any, + current_max_tokens: int, + ) -> bool: + if structured_output: + return False + if int(current_max_tokens or 0) >= 2800: + return False + finish_reason = str(choice.get("finish_reason") or "").strip().lower() if isinstance(choice, dict) else "" + if finish_reason not in {"length", "max_tokens"}: + return False + reasoning_text = "" + if isinstance(message, dict): + reasoning_text = str(message.get("reasoning_content") or message.get("reasoning") or "").strip() + if not reasoning_text and isinstance(choice, dict): + reasoning_text = str(choice.get("reasoning_content") or choice.get("reasoning") or "").strip() + return bool(reasoning_text) + + @staticmethod + def _next_retry_max_tokens_for_empty_chat(current_max_tokens: int) -> int: + current = max(int(current_max_tokens or 0), 1) + grown = max(current + 512, int(current * 1.8)) + return min(grown, 3000) + + def _use_provider_circuit_breaker(self, function_name: str) -> bool: + fn = str(function_name or "").strip().lower() + if any(fn.startswith(prefix) for prefix in CIRCUIT_BREAKER_EXEMPT_FUNCTION_PREFIXES): + return False + return not self._prefer_fast_local_fallback(function_name) + + def _structured_output_required(self, function_name: str) -> bool: + fn = str(function_name or "").strip().lower() + if not fn: + return False + custom_hints = _parse_csv_items(os.getenv("LLM_STRUCTURED_OUTPUT_FUNCTIONS", "")) + hint_pool = custom_hints or STRUCTURED_OUTPUT_FUNCTION_HINTS + return any(hint and hint in fn for hint in hint_pool) + diff --git a/backend/app/services/llm_service/mixin_openclaw_routing.py b/backend/app/services/llm_service/mixin_openclaw_routing.py new file mode 100644 index 00000000..65ea6a6f --- /dev/null +++ b/backend/app/services/llm_service/mixin_openclaw_routing.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +from .shared import * + +class LLMOpenclawRoutingMixin: + def _mixed_routing_enabled(self) -> bool: + return _env_bool("LLM_MIXED_ROUTING_ENABLED", False) + + def _openclaw_routing_enabled(self) -> bool: + return False + + def _openclaw_control_plane_only(self) -> bool: + return False + + def _ensure_openclaw_control_plane_engine(self, engine: dict[str, Any]) -> dict[str, Any]: + runtime = dict(engine or {}) + runtime["source_provider"] = str(runtime.get("source_provider") or runtime.get("provider") or "").strip().lower() + return runtime + + def _openclaw_rules_path(self) -> str: + custom = str( + os.getenv( + "OPENCLAW_ROUTING_RULES_PATH", + OPENCLAW_DEFAULT_RULES_RELATIVE_PATH, + ) + or "" + ).strip() + raw_path = custom or OPENCLAW_DEFAULT_RULES_RELATIVE_PATH + candidate = Path(raw_path) + if not candidate.is_absolute(): + candidate = (Path(__file__).resolve().parents[2] / candidate).resolve() + return str(candidate) + + def _load_openclaw_routing_bundle(self) -> dict[str, Any]: + now = time.time() + if self._openclaw_routing_cache and self._openclaw_routing_cache[0] > now: + return dict(self._openclaw_routing_cache[1]) + + path = Path(self._openclaw_rules_path()) + bundle: dict[str, Any] = {} + if path.exists(): + try: + parsed = json.loads(path.read_text(encoding="utf-8")) + if isinstance(parsed, dict): + bundle = parsed + except Exception as exc: + _runtime_log("OpenClaw路由", f"规则文件解析失败:path={path} error={exc}") + bundle = {} + self._openclaw_routing_cache = (now + OPENCLAW_ROUTING_CACHE_TTL_SECONDS, dict(bundle)) + return dict(bundle) + + @staticmethod + def _rule_matches_function(rule: dict[str, Any], function_name: str) -> bool: + if bool(rule.get("match_all", False)): + return True + fn = str(function_name or "").strip().lower() + if not fn: + return False + exact = str(rule.get("function_exact") or "").strip().lower() + if exact and fn == exact: + return True + prefix = str(rule.get("function_prefix") or "").strip().lower() + if prefix == "*" or prefix == "all": + return True + if prefix and fn.startswith(prefix): + return True + match_any = rule.get("match_any") + if isinstance(match_any, list): + for hint in match_any: + token = str(hint or "").strip().lower() + if token and token in fn: + return True + return False + + def _match_openclaw_rule( + self, + function_name: str, + *, + tier_filter: Optional[set[str]] = None, + ) -> Optional[dict[str, Any]]: + if not self._openclaw_routing_enabled(): + return None + bundle = self._load_openclaw_routing_bundle() + rules = bundle.get("rules") if isinstance(bundle.get("rules"), list) else [] + if not rules: + return None + + indexed: list[tuple[int, int, dict[str, Any]]] = [] + for idx, row in enumerate(rules): + if not isinstance(row, dict): + continue + if not bool(row.get("enabled", True)): + continue + if tier_filter: + tier_name = str(row.get("tier") or "").strip().lower() + if tier_name not in tier_filter: + continue + if not self._rule_matches_function(row, function_name): + continue + priority = int(row.get("priority") or 0) + indexed.append((priority, -idx, row)) + + if not indexed: + return None + indexed.sort(reverse=True) + return dict(indexed[0][2]) + + def _resolve_openclaw_engine( + self, + *, + function_name: str, + engines: list[dict[str, Any]], + active_engine: dict[str, Any], + ) -> Optional[dict[str, Any]]: + rule = self._match_openclaw_rule(function_name, tier_filter={"low", "high"}) + if not rule: + return None + + routed = self._resolve_engine_from_rule( + rule=rule, + engines=engines, + active_engine=active_engine, + ) + if routed is None: + return None + + _runtime_log( + "OpenClaw路由", + ( + "规则命中:" + f"function={function_name}, provider={routed.get('provider')}, " + f"model={routed.get('model')}, rule={rule.get('name') or 'unnamed'}" + ), + ) + return routed + + def _resolve_engine_from_rule( + self, + *, + rule: dict[str, Any], + engines: list[dict[str, Any]], + active_engine: dict[str, Any], + ) -> Optional[dict[str, Any]]: + tier = str(rule.get("tier") or "").strip().lower() + provider_raw = str(rule.get("provider") or "").strip().lower() + engine_id = str(rule.get("engine_id") or "").strip() + model_override = str(rule.get("model") or "").strip() + base_url_override = str(rule.get("base_url") or "").strip().rstrip("/") + api_env_override = str(rule.get("api_env") or "").strip() + + routed: Optional[dict[str, Any]] = None + if engine_id: + for item in engines: + if str(item.get("id") or "").strip() == engine_id: + routed = self._normalize_engine_runtime(item) + break + + if routed is None and provider_raw: + try: + provider = _normalize_provider(provider_raw) + except Exception: + provider = "" + if provider: + for item in engines: + if str(item.get("provider") or "").strip().lower() == provider: + routed = self._normalize_engine_runtime(item) + break + if routed is None: + try: + routed = self._env_provider_runtime(provider) + except Exception: + routed = None + + if routed is None and tier in {"high", "low", "safety"}: + routed = self._resolve_tier_engine( + tier=tier, + engines=engines, + active_engine=active_engine, + ) + + if routed is None: + return None + + if model_override: + routed["model"] = model_override + if base_url_override: + routed["base_url"] = base_url_override + if api_env_override: + routed["api_key"] = str(os.getenv(api_env_override, "") or "").strip() + routed["api_env"] = api_env_override + return routed + + def _resolve_function_tier(self, function_name: str) -> str: + fn = str(function_name or "").strip().lower() + if not fn: + return str(os.getenv("LLM_ROUTING_DEFAULT_TIER", "active") or "active").strip().lower() + + high_hints = _parse_csv_items(os.getenv("LLM_ROUTING_HIGH_FUNCTIONS", "")) + low_hints = _parse_csv_items(os.getenv("LLM_ROUTING_LOW_FUNCTIONS", "")) + high_pool = high_hints or DEFAULT_HIGH_TIER_HINTS + low_pool = low_hints or DEFAULT_LOW_TIER_HINTS + + if any(hint and hint in fn for hint in high_pool): + return "high" + if any(hint and hint in fn for hint in low_pool): + return "low" + default_tier = str(os.getenv("LLM_ROUTING_DEFAULT_TIER", "active") or "active").strip().lower() + if default_tier != "active": + return default_tier + # mixed routing 开启时,未知函数默认走 high,避免被 active 引擎“全吞”。 + return "high" if self._mixed_routing_enabled() else "active" + diff --git a/backend/app/services/llm_service/mixin_prompt_profile_runtime.py b/backend/app/services/llm_service/mixin_prompt_profile_runtime.py new file mode 100644 index 00000000..7284ab41 --- /dev/null +++ b/backend/app/services/llm_service/mixin_prompt_profile_runtime.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +from .shared import * + +class LLMPromptProfileRuntimeMixin: + async def _resolve_active_prompt_profile(self, db: AsyncSession, *, tenant_id: int) -> dict[str, str]: + cached = self._prompt_profile_cache.get(int(tenant_id)) + now = time.time() + if cached and cached[0] > now: + return dict(cached[1]) + + role_name = "" + system_prompt = "" + role_key = "" + try: + row = ( + await db.execute( + select(Integration).where( + and_( + Integration.tenant_id == int(tenant_id), + Integration.type == "ai_prompt_profiles", + ) + ) + ) + ).scalar_one_or_none() + bundle = _safe_json(row.config if row else {}) + active_key = str(bundle.get("active_role_key") or "").strip() + roles = bundle.get("roles") if isinstance(bundle.get("roles"), list) else [] + selected = None + for item in roles: + if not isinstance(item, dict): + continue + item_key = str(item.get("role_key") or "").strip() + if item_key == active_key: + selected = item + break + if selected is None: + for item in roles: + if isinstance(item, dict) and bool(item.get("enabled", True)): + selected = item + break + if isinstance(selected, dict): + role_name = str(selected.get("role_name") or "").strip() + system_prompt = str(selected.get("system_prompt") or "").strip() + role_key = str(selected.get("role_key") or "").strip() + except Exception: + role_name = "" + system_prompt = "" + role_key = "" + + if len(system_prompt) < 20: + fallback = build_default_role_prompt_bundle() + fallback_key = str(fallback.get("active_role_key") or "").strip() + roles = fallback.get("roles") if isinstance(fallback.get("roles"), list) else [] + role = None + for item in roles: + if not isinstance(item, dict): + continue + if str(item.get("role_key") or "").strip() == fallback_key: + role = item + break + if role is None and roles: + role = roles[0] if isinstance(roles[0], dict) else None + if isinstance(role, dict): + role_name = str(role.get("role_name") or role_name or "默认角色").strip() + system_prompt = str(role.get("system_prompt") or "").strip() + role_key = str(role.get("role_key") or role_key or fallback_key).strip() + + result = { + "role_key": role_key, + "role_name": role_name, + "system_prompt": system_prompt, + } + self._prompt_profile_cache[int(tenant_id)] = (now + PROMPT_PROFILE_CACHE_TTL_SECONDS, result) + return dict(result) + + def _build_fallback_engines(self, primary_engine: dict[str, Any]) -> list[dict[str, Any]]: + candidates: list[dict[str, Any]] = [] + provider = _normalize_provider(primary_engine.get("provider")) + current_model = str(primary_engine.get("model") or "").strip() + + provider_runtime = self._env_provider_runtime(provider) + default_model = str(provider_runtime.get("model") or PROVIDER_DEFAULTS.get(provider, {}).get("model") or "").strip() + if default_model and default_model != current_model: + same_provider = dict(primary_engine) + same_provider["model"] = default_model + candidates.append(self._normalize_engine_runtime(same_provider)) + + fallback_provider_raw = str(os.getenv("LLM_FALLBACK_PROVIDER", "ollama")).strip() + if fallback_provider_raw: + fallback_provider = _normalize_provider(fallback_provider_raw) + if fallback_provider != provider: + candidates.append(self._env_provider_runtime(fallback_provider)) + + return candidates + + def _env_provider_runtime(self, provider: str) -> dict[str, Any]: + provider_name = _normalize_provider(provider) + defaults = PROVIDER_DEFAULTS[provider_name] + api_env = str(defaults.get("api_env") or "").strip() + api_key = os.getenv(api_env, "") if api_env else "" + if provider_name == "glm": + api_key = os.getenv("GLM_API_KEY", os.getenv("ZHIPUAI_API_KEY", api_key)) + + base_url = os.getenv(f"{provider_name.upper()}_BASE_URL", defaults["base_url"]) + model = os.getenv(f"{provider_name.upper()}_MODEL", defaults["model"]) + if provider_name == "ollama": + base_url = os.getenv("OLLAMA_BASE_URL", os.getenv("OLLAMA_HOST", defaults["base_url"])) + model = os.getenv("OLLAMA_MODEL", os.getenv("OLLAMA_CHAT_MODEL", defaults["model"])) + + return { + "id": f"env-{provider_name}", + "name": f"{defaults['name']} (ENV)", + "provider": provider_name, + "api_key": str(api_key or "").strip(), + "base_url": str(base_url or defaults["base_url"]).strip().rstrip("/"), + "model": str(model or defaults["model"]).strip(), + } + + def _env_engine_runtime(self) -> dict[str, Any]: + provider = _normalize_provider(os.getenv("LLM_PROVIDER", "glm")) + runtime = self._env_provider_runtime(provider) + runtime["id"] = "env-default" + return runtime + + def _normalize_engine_runtime(self, engine: dict[str, Any]) -> dict[str, Any]: + provider = _normalize_provider(engine.get("provider")) + defaults = PROVIDER_DEFAULTS[provider] + runtime = dict(engine) + runtime["provider"] = provider + runtime["base_url"] = str(runtime.get("base_url") or defaults["base_url"]).strip().rstrip("/") + runtime["model"] = str(runtime.get("model") or defaults["model"]).strip() + runtime["api_key"] = str(runtime.get("api_key") or "").strip() + runtime["name"] = str(runtime.get("name") or defaults["name"]).strip() + if not runtime.get("id"): + runtime["id"] = str(uuid4()) + return runtime + + async def _log_ai_call( + self, + *, + tenant_id: int, + function_name: str, + prompt: str, + response: str, + status: str, + provider: str = "", + model: str = "", + ) -> None: + tokens_used = max(len(prompt) // 4, 1) + max(len(response) // 4, 0) + cost = self._estimate_cost(provider=provider, model=model, tokens=tokens_used) + row_payload = { + "tenant_id": tenant_id, + "user_id": None, + "function_name": function_name, + "prompt": prompt[:8000], + "response": (response or "")[:8000], + "tokens_used": tokens_used, + "cost": cost, + "status": status, + } + async with AsyncSessionLocal() as session: + for attempt in range(2): + try: + session.add(AICallLog(**row_payload)) + await session.commit() + return + except OperationalError as exc: + await session.rollback() + if "database is locked" not in str(exc).lower() or attempt >= 1: + return + await asyncio.sleep(0.05 * (attempt + 1)) + except Exception: + await session.rollback() + return + + @staticmethod + def _estimate_cost(provider: str, model: str, tokens: int) -> float: + _ = model + per_1k = { + "deepseek": 0.0003, + "glm": 0.0006, + "openai": 0.0012, + "ollama": 0.0, + }.get(provider, 0.0005) + return round((tokens / 1000.0) * per_1k, 6) + + @staticmethod + def _parse_json_object(text: str) -> dict[str, Any]: + raw = (text or "").strip() + if raw.startswith("```"): + parts = raw.split("```") + if len(parts) >= 2: + raw = parts[1].strip() + if raw.startswith("json"): + raw = raw[4:].strip() + try: + parsed = json.loads(raw) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + + @staticmethod + def _parse_json_array(text: str) -> list[dict]: + raw = (text or "").strip() + if raw.startswith("```"): + parts = raw.split("```") + if len(parts) >= 2: + raw = parts[1].strip() + if raw.startswith("json"): + raw = raw[4:].strip() + try: + parsed = json.loads(raw) + if isinstance(parsed, list): + return [item for item in parsed if isinstance(item, dict)] + except Exception: + return [] + return [] + + @staticmethod + def _extract_text_from_value(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, (int, float, bool)): + return str(value) + if isinstance(value, dict): + parts: list[str] = [] + for key in ("text", "content", "value", "output_text", "input_text", "arguments", "json"): + if key in value: + parts.append(LLMService._extract_text_from_value(value.get(key))) + return "".join(parts) + if isinstance(value, list): + return "".join(LLMService._extract_text_from_value(item) for item in value) + return str(value) + + @staticmethod + def _sanitize_visible_answer(text: str) -> str: + cleaned = str(text or "") + if not cleaned: + return "" + cleaned = re.sub(r".*?", "", cleaned, flags=re.IGNORECASE | re.DOTALL) + return cleaned.strip() + diff --git a/backend/app/services/llm_service/mixin_structured_candidates.py b/backend/app/services/llm_service/mixin_structured_candidates.py new file mode 100644 index 00000000..3a794b11 --- /dev/null +++ b/backend/app/services/llm_service/mixin_structured_candidates.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from .shared import * + +class LLMStructuredCandidatesMixin: + def _iter_structured_candidates( + self, + *, + message: Any, + choice: Any, + response: Any, + ) -> list[tuple[str, Any]]: + candidates: list[tuple[str, Any]] = [] + if isinstance(message, dict): + tool_calls = message.get("tool_calls") + if isinstance(tool_calls, list) and tool_calls: + first = tool_calls[0] if isinstance(tool_calls[0], dict) else {} + fn = first.get("function") if isinstance(first, dict) else {} + candidates.append(("tool_calls.function.arguments", fn.get("arguments") if isinstance(fn, dict) else None)) + candidates.append(("message.content", message.get("content"))) + if isinstance(choice, dict): + candidates.append(("choice.text", choice.get("text"))) + if isinstance(response, dict): + candidates.append(("response.output_text", response.get("output_text"))) + if isinstance(message, dict): + candidates.append(("message.reasoning_content", message.get("reasoning_content"))) + candidates.append(("message.reasoning_content", message.get("reasoning"))) + if isinstance(choice, dict): + candidates.append(("choice.reasoning_content", choice.get("reasoning_content"))) + candidates.append(("choice.reasoning_content", choice.get("reasoning"))) + if isinstance(response, dict): + candidates.append(("response.reasoning_content", response.get("reasoning_content"))) + candidates.append(("response.reasoning_content", response.get("reasoning"))) + return candidates + + @classmethod + def _unwrap_structured_payload_once(cls, payload: Any) -> Any: + if not isinstance(payload, dict): + return payload + for key in ("json", "data", "result", "arguments"): + if key not in payload: + continue + inner = payload.get(key) + if isinstance(inner, str): + candidate = cls._extract_json_candidate(cls._strip_code_fence(inner)) + if not candidate: + continue + try: + return json.loads(candidate) + except Exception: + return inner + if isinstance(inner, (dict, list)): + return inner + return payload + + @staticmethod + def _matches_expected_type(value: Any, expected: str) -> bool: + if expected == "bool": + return isinstance(value, bool) + if expected == "str": + return isinstance(value, str) + if expected == "list": + return isinstance(value, list) + if expected == "number": + return isinstance(value, (int, float)) and not isinstance(value, bool) + if expected == "number_or_none": + return value is None or (isinstance(value, (int, float)) and not isinstance(value, bool)) + if expected == "str_or_none": + return value is None or isinstance(value, str) + if expected == "dict_or_none": + return value is None or isinstance(value, dict) + return True + + def _validate_structured_payload(self, *, function_name: str, payload: Any) -> Optional[str]: + fn = str(function_name or "").strip().lower() + schema = STRUCTURED_OBJECT_SCHEMAS.get(fn) + if schema is None: + return None if isinstance(payload, (dict, list)) else "structured_json_invalid" + if not isinstance(payload, dict): + return "structured_schema_invalid" + required_keys = schema.get("required_keys") or () + for key in required_keys: + if key not in payload: + return "structured_schema_invalid" + type_checks = schema.get("type_checks") or {} + for key, expected in type_checks.items(): + if not self._matches_expected_type(payload.get(key), str(expected)): + return "structured_schema_invalid" + optional_type_checks = schema.get("optional_type_checks") or {} + for key, expected_values in optional_type_checks.items(): + if key not in payload: + continue + if not any(self._matches_expected_type(payload.get(key), str(expected)) for expected in expected_values): + return "structured_schema_invalid" + return None + diff --git a/backend/app/services/llm_service/mixin_structured_extract_object.py b/backend/app/services/llm_service/mixin_structured_extract_object.py new file mode 100644 index 00000000..131b0577 --- /dev/null +++ b/backend/app/services/llm_service/mixin_structured_extract_object.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from .shared import * + +class LLMStructuredExtractObjectMixin: + def _extract_structured_object_from_response( + self, + *, + function_name: str, + response: Any, + fallback_text: str, + provider: str, + model: str, + ) -> StructuredExtractionResult: + message = {} + choice: dict[str, Any] = {} + if isinstance(response, dict): + choices = response.get("choices") if isinstance(response.get("choices"), list) else [] + choice = choices[0] if choices and isinstance(choices[0], dict) else {} + message = choice.get("message") if isinstance(choice.get("message"), dict) else {} + + last_nonempty_field = "" + last_nonempty_text = "" + for field, raw_value in self._iter_structured_candidates(message=message, choice=choice, response=response): + text = self._sanitize_visible_answer(self._extract_text_from_value(raw_value)) + normalized = self._strip_code_fence(text) + if normalized: + last_nonempty_field = field + last_nonempty_text = normalized[:4000] + if not self._looks_like_json_container(normalized): + continue + candidate = self._extract_json_candidate(normalized) + if not candidate: + continue + try: + parsed = json.loads(candidate) + except Exception: + return StructuredExtractionResult( + payload=None, + source_field=field, + provider=provider, + model=model, + error_code="structured_json_invalid", + raw_text=candidate[:4000], + validation="invalid", + ) + parsed = self._unwrap_structured_payload_once(parsed) + validation_error = self._validate_structured_payload(function_name=function_name, payload=parsed) + if validation_error is not None: + return StructuredExtractionResult( + payload=None, + source_field=field, + provider=provider, + model=model, + error_code=validation_error, + raw_text=candidate[:4000], + validation="invalid", + ) + return StructuredExtractionResult( + payload=parsed if isinstance(parsed, dict) else None, + source_field=field, + provider=provider, + model=model, + raw_text=candidate[:4000], + validation="ok", + ) + + fallback_normalized = self._strip_code_fence(fallback_text) + fallback_candidate = ( + self._extract_json_candidate(fallback_normalized) + if self._looks_like_json_container(fallback_normalized) + else "" + ) + if fallback_candidate: + try: + parsed = json.loads(fallback_candidate) + except Exception: + return StructuredExtractionResult( + payload=None, + source_field="generated_text", + provider=provider, + model=model, + error_code="structured_json_invalid", + raw_text=fallback_candidate[:4000], + validation="invalid", + ) + parsed = self._unwrap_structured_payload_once(parsed) + validation_error = self._validate_structured_payload(function_name=function_name, payload=parsed) + if validation_error is None and isinstance(parsed, dict): + return StructuredExtractionResult( + payload=parsed, + source_field="generated_text", + provider=provider, + model=model, + raw_text=fallback_candidate[:4000], + validation="ok", + ) + + if not last_nonempty_field and str(fallback_text or "").strip(): + last_nonempty_field = "generated_text" + last_nonempty_text = str(fallback_text or "").strip()[:4000] + error_code = "structured_json_not_found" if last_nonempty_field else "structured_empty" + return StructuredExtractionResult( + payload=None, + source_field=last_nonempty_field, + provider=provider, + model=model, + error_code=error_code, + raw_text=last_nonempty_text, + validation="invalid", + ) + + @staticmethod + def _classify_structured_exception(exc: Exception) -> str: + if isinstance(exc, (asyncio.TimeoutError, httpx.TimeoutException)): + return "structured_timeout" + if isinstance(exc, (httpx.HTTPError, httpx.RequestError)): + return "structured_http_error" + lowered = str(exc or "").strip().lower() + if "timeout" in lowered: + return "structured_timeout" + if "status" in lowered or "http" in lowered: + return "structured_http_error" + return "structured_empty" + diff --git a/backend/app/services/llm_service/mixin_structured_parse.py b/backend/app/services/llm_service/mixin_structured_parse.py new file mode 100644 index 00000000..c696b030 --- /dev/null +++ b/backend/app/services/llm_service/mixin_structured_parse.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from .shared import * + +class LLMStructuredParseMixin: + @staticmethod + def _extract_json_candidate(text: str) -> str: + raw = str(text or "").strip() + if not raw: + return "" + if raw.startswith("```"): + chunks = raw.split("```") + if len(chunks) >= 2: + raw = chunks[1].strip() + if raw.startswith("json"): + raw = raw[4:].strip() + if raw.startswith("{") or raw.startswith("["): + return raw + + object_candidate = LLMService._extract_balanced_json_segment(raw, "{", "}") + array_candidate = LLMService._extract_balanced_json_segment(raw, "[", "]") + if object_candidate and array_candidate: + return object_candidate if raw.find("{") <= raw.find("[") else array_candidate + if object_candidate: + return object_candidate + if array_candidate: + return array_candidate + return raw + + @staticmethod + def _looks_like_json_container(text: str) -> bool: + raw = str(text or "").strip() + if not raw: + return False + if raw.startswith("{") or raw.startswith("["): + return True + return "{" in raw or "[" in raw + + @staticmethod + def _extract_balanced_json_segment(text: str, open_char: str, close_char: str) -> str: + raw = str(text or "") + start = raw.find(open_char) + if start < 0: + return "" + depth = 0 + in_str = False + escaped = False + for idx in range(start, len(raw)): + ch = raw[idx] + if in_str: + if escaped: + escaped = False + elif ch == "\\": + escaped = True + elif ch == '"': + in_str = False + continue + if ch == '"': + in_str = True + continue + if ch == open_char: + depth += 1 + elif ch == close_char: + depth -= 1 + if depth == 0: + return raw[start : idx + 1].strip() + return "" + + def _is_invalid_structured_output(self, *, function_name: str, response_text: str) -> bool: + if not self._structured_output_required(function_name): + return False + candidate = self._extract_json_candidate(response_text) + if not candidate: + return True + try: + parsed = json.loads(candidate) + except Exception: + return True + parsed = self._unwrap_structured_payload_once(parsed) + validation_error = self._validate_structured_payload(function_name=function_name, payload=parsed) + if validation_error is not None: + return True + return not isinstance(parsed, (dict, list)) + + @staticmethod + def _strip_code_fence(text: str) -> str: + raw = str(text or "").strip() + if not raw.startswith("```"): + return raw + chunks = raw.split("```") + if len(chunks) >= 2: + raw = chunks[1].strip() + if raw.lower().startswith("json"): + raw = raw[4:].strip() + return raw.strip() + + def _extract_text_from_chat_response( + self, + *, + message: Any, + choice: Any, + response: Any, + structured_output: bool, + ) -> str: + if structured_output: + for _, raw_value in self._iter_structured_candidates(message=message, choice=choice, response=response): + text = self._sanitize_visible_answer(self._extract_text_from_value(raw_value)) + normalized = self._strip_code_fence(text) + if not self._looks_like_json_container(normalized): + continue + candidate = self._extract_json_candidate(normalized) + if candidate: + return candidate + return "" + if isinstance(message, dict): + text = self._sanitize_visible_answer(self._extract_text_from_value(message.get("content"))) + if text: + return text + tool_calls = message.get("tool_calls") + if isinstance(tool_calls, list) and tool_calls: + first = tool_calls[0] if isinstance(tool_calls[0], dict) else {} + fn = first.get("function") if isinstance(first, dict) else {} + if isinstance(fn, dict): + text = self._sanitize_visible_answer(self._extract_text_from_value(fn.get("arguments"))) + if text: + return text + if isinstance(choice, dict): + text = self._sanitize_visible_answer(self._extract_text_from_value(choice.get("text"))) + if text: + return text + if isinstance(response, dict): + text = self._sanitize_visible_answer(self._extract_text_from_value(response.get("output_text"))) + if text: + return text + return "" + + def _extract_reasoning_fallback_text( + self, + *, + message: Any, + choice: Any, + response: Any, + ) -> str: + candidates: list[Any] = [] + if isinstance(message, dict): + candidates.append(message.get("reasoning_content")) + candidates.append(message.get("reasoning")) + if isinstance(choice, dict): + candidates.append(choice.get("reasoning_content")) + candidates.append(choice.get("reasoning")) + if isinstance(response, dict): + candidates.append(response.get("reasoning_content")) + candidates.append(response.get("reasoning")) + for raw in candidates: + text = self._sanitize_visible_answer(self._extract_text_from_value(raw)) + if text: + return text + return "" + + @staticmethod + def _reasoning_content_fallback_enabled(*, function_name: str) -> bool: + fn = str(function_name or "").strip().lower() + if fn and ("title_refine" in fn or "project_draft_analysis_glm47_fallback" in fn): + return True + raw = str(os.getenv("LLM_REASONING_CONTENT_FALLBACK", "0")).strip().lower() + if raw not in {"1", "true", "yes", "on"}: + return False + if not fn: + return True + if fn.startswith("generate_structured") or fn.startswith("llm_generate_structured"): + return False + if "signal_batch_analysis" in fn: + return False + if any(token in fn for token in ("answer", "reply", "qa")): + return True + return False + diff --git a/backend/app/services/llm_service/mixin_task_helpers.py b/backend/app/services/llm_service/mixin_task_helpers.py new file mode 100644 index 00000000..756c2dc5 --- /dev/null +++ b/backend/app/services/llm_service/mixin_task_helpers.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from .shared import * + +class LLMTaskHelpersMixin: + async def generate_task_breakdown_async(self, description: str, **kwargs: Any) -> list[dict]: + if not description.strip(): + return [] + prompt = ( + "请将以下需求拆解为 5-8 条执行任务,返回 JSON 数组," + "每项格式: {\"task_name\":\"\",\"type\":\"backend|frontend|database|ai|devops\"," + "\"estimated_hours\":4,\"priority\":\"high|medium|low\"}。\n" + f"需求:{description[:1200]}" + ) + text = await self.generate(prompt, function_name="generate_task_breakdown", **kwargs) + rows = self._parse_json_array(text) + if rows: + return rows + return [ + { + "task_name": "需求分析", + "type": "ai", + "priority": "medium", + "estimated_hours": 4, + "description": description[:200], + } + ] + + async def get_embedding_async(self, text: str, **kwargs: Any) -> list[float]: + db = kwargs.get("db") or kwargs.get("db_session") + tenant_id = int(kwargs.get("tenant_id") or 1) + if db is None: + async with AsyncSessionLocal() as session: + return await self.get_embedding_async(text, db=session, tenant_id=tenant_id) + try: + engine = await self._resolve_active_engine(db, tenant_id=tenant_id) + provider = str(engine.get("provider") or "") + if provider == "openai": + endpoint = f"{str(engine.get('base_url') or '').rstrip('/')}/embeddings" + headers = { + "Authorization": f"Bearer {str(engine.get('api_key') or '').strip()}", + "Content-Type": "application/json", + } + payload = { + "model": str(engine.get("model") or "text-embedding-3-small"), + "input": text, + } + async with httpx.AsyncClient(timeout=self.request_timeout, trust_env=False) as client: + resp = await client.post(endpoint, headers=headers, json=payload) + resp.raise_for_status() + data = resp.json() + return list(data.get("data", [{}])[0].get("embedding") or []) + except Exception: + pass + # DeepSeek/GLM 及失败场景:回退伪向量,保持功能可用。 + seed = hashlib.md5(text.encode("utf-8")).digest() + return [((seed[i % 16] / 255.0) * 2 - 1) for i in range(64)] + + async def extract_context_insights_async(self, text: str, **kwargs: Any) -> dict: + prompt = ( + "请提炼以下技术文档上下文的关键结论,返回 JSON:" + "{\"summary\":\"\",\"keywords\":[\"\",\"\"],\"risks\":[\"\",\"\"]}\n" + f"上下文:{text[:2000]}" + ) + raw = await self.generate(prompt, function_name="extract_context_insights", **kwargs) + parsed = self._parse_json_object(raw) + if parsed: + return parsed + return {"summary": text[:120], "keywords": []} + + async def find_relevant_context(self, **_: Any) -> list[dict]: + return [] + + async def evaluate_with_voting_async(self, signal_content: str, impact_score: float, **_: Any) -> dict: + is_strategic = impact_score >= 0.8 + return { + "is_strategic": is_strategic, + "confidence": min(1.0, max(0.2, impact_score)), + "decision_proposal": { + "summary": signal_content[:120], + "action": "follow_up" if is_strategic else "observe", + }, + "tasks": [], + "_voting": { + "enabled": False, + "models_used": [], + "strategic_votes": 0, + "total_votes": 0, + "unanimous": True, + }, + } + + async def audit_code_change_async( + self, + task_name: str, + commit_message: str, + code_diff: str, + **_: Any, + ) -> dict: + passed = bool(task_name and commit_message and code_diff is not None) + return { + "passed": passed, + "reason": "basic_stub_check_passed" if passed else "invalid_input", + } + diff --git a/backend/app/services/llm_service/mixin_tier_engine_selection.py b/backend/app/services/llm_service/mixin_tier_engine_selection.py new file mode 100644 index 00000000..5107e8df --- /dev/null +++ b/backend/app/services/llm_service/mixin_tier_engine_selection.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +from .shared import * + +class LLMTierEngineSelectionMixin: + @staticmethod + def _is_same_runtime_engine(a: dict[str, Any], b: dict[str, Any]) -> bool: + return ( + str(a.get("provider") or "").strip().lower() == str(b.get("provider") or "").strip().lower() + and str(a.get("model") or "").strip().lower() == str(b.get("model") or "").strip().lower() + and str(a.get("base_url") or "").strip().rstrip("/").lower() + == str(b.get("base_url") or "").strip().rstrip("/").lower() + ) + + async def _resolve_high_tier_engine_for_escalation( + self, + db: AsyncSession, + *, + tenant_id: int, + primary_engine: dict[str, Any], + ) -> Optional[dict[str, Any]]: + _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + active_engine = self._resolve_active_engine_from_bundle(bundle) + engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)] + routed = self._resolve_tier_engine( + tier="high", + engines=engines, + active_engine=active_engine, + ) + if not routed: + return None + if self._is_same_runtime_engine(routed, primary_engine): + return None + + provider = str(routed.get("provider") or "").strip().lower() + api_key = str(routed.get("api_key") or "").strip() + if provider != "ollama" and not api_key: + return None + return routed + + async def _resolve_safety_tier_engine( + self, + db: AsyncSession, + *, + tenant_id: int, + function_name: str, + primary_engine: dict[str, Any], + ) -> Optional[dict[str, Any]]: + _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + active_engine = self._resolve_active_engine_from_bundle(bundle) + engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)] + + safety_rule = self._match_openclaw_rule(function_name, tier_filter={"safety"}) + routed: Optional[dict[str, Any]] = None + if safety_rule is not None: + routed = self._resolve_engine_from_rule( + rule=safety_rule, + engines=engines, + active_engine=active_engine, + ) + if routed is None: + routed = self._resolve_tier_engine( + tier="safety", + engines=engines, + active_engine=active_engine, + ) + if not routed: + return None + routed = self._ensure_openclaw_control_plane_engine(routed) + if self._is_same_runtime_engine(routed, primary_engine): + return None + + provider = str(routed.get("provider") or "").strip().lower() + api_key = str(routed.get("api_key") or "").strip() + if provider != "ollama" and not api_key: + return None + return routed + + async def _try_safety_engine( + self, + db: AsyncSession, + *, + tenant_id: int, + function_name: str, + prompt: str, + system_prompt: str, + temperature: float, + max_tokens: int, + primary_engine: dict[str, Any], + reason: str, + ) -> tuple[str, Optional[dict[str, Any]]]: + safety_engine = await self._resolve_safety_tier_engine( + db, + tenant_id=tenant_id, + function_name=function_name, + primary_engine=primary_engine, + ) + if safety_engine is None: + _runtime_log( + "LLM路由", + f"Safety 引擎不可用,无法触发兜底:function={function_name}, reason={reason}", + ) + return "", None + safety_provider = str(safety_engine.get("provider") or "") + safety_model = str(safety_engine.get("model") or "") + _runtime_log( + "LLM路由", + ( + "触发 Safety 兜底:" + f"function={function_name}, provider={safety_provider}, model={safety_model}, reason={reason}" + ), + ) + try: + text = await self._chat_completion( + provider=safety_provider, + prompt=prompt, + system_prompt=system_prompt, + api_key=str(safety_engine.get("api_key") or ""), + base_url=str(safety_engine.get("base_url") or ""), + model=safety_model, + temperature=temperature, + max_tokens=max_tokens, + function_name=function_name, + ) + if not text.strip(): + raise ValueError("safety_engine_empty_response") + if self._is_invalid_structured_output(function_name=function_name, response_text=text): + raise ValueError("safety_engine_invalid_structured_output") + return text, safety_engine + except Exception as exc: + _runtime_log("LLM路由", f"Safety 兜底失败:function={function_name}, error={exc}") + return "", None + + def _pick_tier_fallback_engine( + self, + *, + tier: str, + engines: list[dict[str, Any]], + active_engine: dict[str, Any], + ) -> Optional[dict[str, Any]]: + for item in engines: + candidate = self._normalize_engine_runtime(item) + provider = str(candidate.get("provider") or "") + api_key = str(candidate.get("api_key") or "").strip() + if provider != "ollama" and not api_key: + continue + if tier == "high" and provider == "ollama": + continue + if str(candidate.get("id") or "") == str(active_engine.get("id") or ""): + continue + _runtime_log( + "LLM路由", + ( + "分层路由回退引擎生效:" + f"tier={tier}, provider={provider}, model={candidate.get('model')}" + ), + ) + return candidate + return None + + def _resolve_tier_engine( + self, + *, + tier: str, + engines: list[dict[str, Any]], + active_engine: dict[str, Any], + ) -> Optional[dict[str, Any]]: + tier = str(tier or "").strip().lower() + upper = tier.upper() + default_provider_by_tier = { + "low": "glm", + "high": "openai", + "safety": "glm", + } + provider_env = str( + os.getenv( + f"LLM_ROUTING_{upper}_PROVIDER", + default_provider_by_tier.get( + tier, + os.getenv("LLM_PROVIDER", "glm"), + ), + ) + or "" + ).strip() + engine_id = str(os.getenv(f"LLM_ROUTING_{upper}_ENGINE_ID", "") or "").strip() + model_override = str(os.getenv(f"LLM_ROUTING_{upper}_MODEL", "") or "").strip() + base_url_override = str(os.getenv(f"LLM_ROUTING_{upper}_BASE_URL", "") or "").strip().rstrip("/") + + routed: Optional[dict[str, Any]] = None + if engine_id: + for item in engines: + if str(item.get("id") or "") == engine_id: + routed = self._normalize_engine_runtime(item) + break + + if routed is None and provider_env: + try: + provider = _normalize_provider(provider_env) + except Exception: + provider = "" + if provider: + for item in engines: + if str(item.get("provider") or "").strip().lower() == provider: + routed = self._normalize_engine_runtime(item) + break + if routed is None: + try: + routed = self._env_provider_runtime(provider) + except Exception: + routed = None + + if routed is None: + return None + + provider = str(routed.get("provider") or "").strip().lower() + if model_override: + routed["model"] = model_override + elif self._openclaw_routing_enabled() and provider == "openai": + default_model = DEFAULT_TIER_MODEL_OVERRIDES.get(tier) + if default_model: + routed["model"] = default_model + if base_url_override: + routed["base_url"] = base_url_override + elif self._openclaw_routing_enabled() and provider == "openai": + configured_openai_base = str(os.getenv("OPENAI_BASE_URL", "") or "").strip().rstrip("/") + routed["base_url"] = configured_openai_base or "https://api.openai.com/v1" + + if str(routed.get("id") or "") != str(active_engine.get("id") or ""): + _runtime_log( + "LLM路由", + ( + "混合路由已命中:" + f"层级={tier}, provider={routed.get('provider')}, model={routed.get('model')}" + ), + ) + return self._ensure_openclaw_control_plane_engine(routed) + + async def _resolve_engine_for_test( + self, + db: AsyncSession, + *, + tenant_id: int, + engine_id: Optional[str], + ) -> dict[str, Any]: + _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + settings = self._normalize_engine_settings(bundle.get("settings")) + if not engine_id: + engine = self._resolve_active_engine_from_bundle(bundle) + return self._ensure_engine_allowed_for_test( + engine=engine, + settings=settings, + engine_label="当前激活引擎", + ) + + engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)] + for item in engines: + if str(item.get("id") or "") == str(engine_id): + engine = self._normalize_engine_runtime(item) + return self._ensure_engine_allowed_for_test( + engine=engine, + settings=settings, + engine_label="目标引擎", + ) + raise ValueError("engine 不存在") + diff --git a/backend/app/services/llm_service/mixin_tier_escalation.py b/backend/app/services/llm_service/mixin_tier_escalation.py new file mode 100644 index 00000000..1610fc20 --- /dev/null +++ b/backend/app/services/llm_service/mixin_tier_escalation.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +from .shared import * + +class LLMTierEscalationMixin: + def _low_to_high_escalation_enabled(self) -> bool: + return _env_bool("LLM_LOW_TO_HIGH_ESCALATION_ENABLED", True) + + def _is_low_tier_function(self, function_name: str) -> bool: + rule = self._match_openclaw_rule(function_name) + if rule is not None: + tier = str(rule.get("tier") or "").strip().lower() + if tier: + return tier == "low" + return self._resolve_function_tier(function_name) == "low" + + @staticmethod + def _looks_like_unanswerable_response(text: str) -> bool: + cleaned = str(text or "").strip() + lowered = cleaned.lower() + if not lowered: + return True + if len(cleaned) <= 8: + return True + return any(marker in lowered for marker in LOW_TO_HIGH_ESCALATION_FALLBACK_MARKERS) + + def _should_escalate_low_to_high(self, *, function_name: str, response_text: str) -> bool: + if not self._low_to_high_escalation_enabled(): + return False + if not self._is_low_tier_function(function_name): + return False + return self._looks_like_unanswerable_response(response_text) + + @staticmethod + def _is_glm_47_model(model_name: str) -> bool: + normalized = re.sub(r"[\s_]+", "", str(model_name or "")).lower() + if not normalized: + return False + return "glm" in normalized and "4.7" in normalized + + def _should_retry_glm47_with_high_tier( + self, + *, + function_name: str, + failed_engine: dict[str, Any], + ) -> bool: + if not self._low_to_high_escalation_enabled(): + return False + failed_model = str(failed_engine.get("model") or "").strip() + if self._is_glm_47_model(failed_model): + return True + # 兜底兼容:低阶函数遇到 4.x GLM 也允许升级高阶重试。 + if self._is_low_tier_function(function_name): + lowered = failed_model.lower() + return "glm" in lowered and "4." in lowered + return False + + async def _retry_with_high_tier_after_low_model_failure( + self, + *, + db: AsyncSession, + tenant_id: int, + function_name: str, + failed_engine: dict[str, Any], + prompt: str, + system_prompt: str, + temperature: float, + max_tokens: int, + response_mode: str, + error: Exception, + ) -> tuple[Optional[str], Optional[dict[str, Any]]]: + if not self._should_retry_glm47_with_high_tier(function_name=function_name, failed_engine=failed_engine): + return None, None + try: + _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + engines = [item for item in bundle.get("engines", []) if isinstance(item, dict)] + active_engine = self._resolve_active_engine_from_bundle(bundle) + high_engine = self._resolve_tier_engine( + tier="high", + engines=engines, + active_engine=active_engine, + ) + if not isinstance(high_engine, dict): + return None, None + high_engine = self._ensure_engine_allowed_for_function( + engine=high_engine, + function_name=function_name, + settings=self._normalize_engine_settings(bundle.get("settings")), + engine_label="high_tier_failover", + ) + failed_provider = str(failed_engine.get("provider") or "").strip().lower() + failed_model = str(failed_engine.get("model") or "").strip() + high_provider = str(high_engine.get("provider") or "").strip().lower() + high_model = str(high_engine.get("model") or "").strip() + if failed_provider == high_provider and failed_model == high_model: + return None, None + _runtime_log( + "LLM路由", + ( + "低阶模型失败自动升级:" + f"function={function_name}, from={failed_provider}:{failed_model}, " + f"to={high_provider}:{high_model}, reason={str(error)[:120]}" + ), + ) + text = await self._chat_completion( + provider=high_provider, + prompt=prompt, + system_prompt=system_prompt, + api_key=str(high_engine.get("api_key") or ""), + base_url=str(high_engine.get("base_url") or ""), + model=high_model, + temperature=temperature, + max_tokens=max_tokens, + function_name=function_name, + response_mode=response_mode, + ) + if not text.strip(): + raise ValueError("high_tier_empty_response") + if self._is_invalid_structured_output(function_name=function_name, response_text=text): + raise ValueError("high_tier_invalid_structured_output") + return text, high_engine + except Exception as retry_exc: + _runtime_log( + "LLM路由", + ( + "低阶模型失败升级高阶也失败:" + f"function={function_name}, error={type(retry_exc).__name__}:{str(retry_exc)[:180]}" + ), + ) + return None, None + + async def _retry_structured_with_high_tier_after_low_model_failure( + self, + *, + db: AsyncSession, + tenant_id: int, + function_name: str, + failed_engine: dict[str, Any], + prompt: str, + system_prompt: str, + temperature: float, + max_tokens: int, + response_mode: str, + error: Exception, + ) -> tuple[Optional[StructuredExtractionResult], Optional[dict[str, Any]]]: + if not self._should_retry_glm47_with_high_tier(function_name=function_name, failed_engine=failed_engine): + return None, None + try: + _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + engines = [item for item in bundle.get("engines", []) if isinstance(item, dict)] + active_engine = self._resolve_active_engine_from_bundle(bundle) + high_engine = self._resolve_tier_engine( + tier="high", + engines=engines, + active_engine=active_engine, + ) + if not isinstance(high_engine, dict): + return None, None + high_engine = self._ensure_engine_allowed_for_function( + engine=high_engine, + function_name=function_name, + settings=self._normalize_engine_settings(bundle.get("settings")), + engine_label="high_tier_structured_failover", + ) + failed_provider = str(failed_engine.get("provider") or "").strip().lower() + failed_model = str(failed_engine.get("model") or "").strip() + high_provider = str(high_engine.get("provider") or "").strip().lower() + high_model = str(high_engine.get("model") or "").strip() + if failed_provider == high_provider and failed_model == high_model: + return None, None + _runtime_log( + "LLM路由", + ( + "低阶结构化失败自动升级:" + f"function={function_name}, from={failed_provider}:{failed_model}, " + f"to={high_provider}:{high_model}, reason={str(error)[:120]}" + ), + ) + completion = await self._chat_completion_raw( + provider=high_provider, + prompt=prompt, + system_prompt=system_prompt, + api_key=str(high_engine.get("api_key") or ""), + base_url=str(high_engine.get("base_url") or ""), + model=high_model, + temperature=temperature, + max_tokens=max_tokens, + function_name=function_name, + response_mode=response_mode, + ) + extraction = self._extract_structured_object_from_response( + function_name=function_name, + response=completion.raw_response, + fallback_text=completion.text, + provider=high_provider, + model=high_model, + ) + if extraction.payload is None: + raise ValueError(str(extraction.error_code or "structured_schema_invalid")) + _runtime_log( + "LLM结构化", + ( + f"function={function_name}, structured_source={extraction.source_field or 'none'}, " + f"validation={extraction.validation}, fallback=true" + ), + ) + return extraction, high_engine + except Exception as retry_exc: + _runtime_log( + "LLM路由", + ( + "低阶结构化失败升级高阶也失败:" + f"function={function_name}, error={type(retry_exc).__name__}:{str(retry_exc)[:180]}" + ), + ) + return None, None + diff --git a/backend/app/services/llm_service/service.py b/backend/app/services/llm_service/service.py new file mode 100644 index 00000000..ba277d41 --- /dev/null +++ b/backend/app/services/llm_service/service.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from .mixin_cache_circuit import LLMCacheCircuitMixin +from .mixin_chat_openai import LLMChatOpenAIMixin +from .mixin_chat_transport import LLMChatTransportMixin +from .mixin_engine_admin import LLMEngineAdminMixin +from .mixin_engine_resolution_core import LLMEngineResolutionCoreMixin +from .mixin_generate_db_chat import LLMGenerateDbChatMixin +from .mixin_generate_db_structured import LLMGenerateDbStructuredMixin +from .mixin_generate_entry import LLMGenerateEntryMixin +from .mixin_guardrails_prompt import LLMGuardrailsPromptMixin +from .mixin_openclaw_routing import LLMOpenclawRoutingMixin +from .mixin_prompt_profile_runtime import LLMPromptProfileRuntimeMixin +from .mixin_structured_candidates import LLMStructuredCandidatesMixin +from .mixin_structured_extract_object import LLMStructuredExtractObjectMixin +from .mixin_structured_parse import LLMStructuredParseMixin +from .mixin_task_helpers import LLMTaskHelpersMixin +from .mixin_tier_engine_selection import LLMTierEngineSelectionMixin +from .mixin_tier_escalation import LLMTierEscalationMixin +from .shared import ChatCompletionResult, StructuredExtractionResult + + +class LLMService( + LLMCacheCircuitMixin, + LLMEngineAdminMixin, + LLMGenerateEntryMixin, + LLMGenerateDbChatMixin, + LLMGenerateDbStructuredMixin, + LLMChatTransportMixin, + LLMChatOpenAIMixin, + LLMTaskHelpersMixin, + LLMEngineResolutionCoreMixin, + LLMOpenclawRoutingMixin, + LLMTierEscalationMixin, + LLMGuardrailsPromptMixin, + LLMStructuredParseMixin, + LLMStructuredCandidatesMixin, + LLMStructuredExtractObjectMixin, + LLMTierEngineSelectionMixin, + LLMPromptProfileRuntimeMixin, +): + pass + + +llm_service = LLMService() + +__all__ = [ + "ChatCompletionResult", + "StructuredExtractionResult", + "LLMService", + "llm_service", +] diff --git a/backend/app/services/llm_service/shared.py b/backend/app/services/llm_service/shared.py new file mode 100644 index 00000000..39384b43 --- /dev/null +++ b/backend/app/services/llm_service/shared.py @@ -0,0 +1,4 @@ +from __future__ import annotations + +from .shared_part_01 import * +from .shared_part_02 import * diff --git a/backend/app/services/llm_service/shared_part_01.py b/backend/app/services/llm_service/shared_part_01.py new file mode 100644 index 00000000..58b5a499 --- /dev/null +++ b/backend/app/services/llm_service/shared_part_01.py @@ -0,0 +1,294 @@ +"""统一 LLM 服务(DeepSeek / GLM / OpenAI / Ollama)。 + +能力: +1. 多引擎配置持久化(Integration.type=llm_engine) +2. 动态选择激活引擎 +3. OpenAI-Compatible chat/completions 调用 +4. 基础 AI 调用日志写入(ai_call_logs) +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +import hashlib +import json +import logging +import os +import re +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Optional +from uuid import uuid4 + +import httpx +from sqlalchemy import and_, select +from sqlalchemy.exc import OperationalError +from sqlalchemy.ext.asyncio import AsyncSession + +from ...config.ai_prompts import build_default_role_prompt_bundle +from ...database import AsyncSessionLocal +from ...models import AICallLog, Integration +from ..model_context_bundle_service import ModelContextBundleService + +from ...utils.timezone import now_cn_aware +try: + import redis.asyncio as redis_async + + REDIS_AVAILABLE = True +except Exception: # pragma: no cover + REDIS_AVAILABLE = False + redis_async = None + +logger = logging.getLogger(__name__) + +SUPPORTED_PROVIDERS = {"deepseek", "glm", "openai", "ollama"} +OPENCLAW_DEFAULT_LOW_MODEL = "GLM-4.7" +OPENCLAW_DEFAULT_HIGH_MODEL = "MiniMax-M2.5" +OPENCLAW_DEFAULT_SAFETY_MODEL = "deepseek-chat" +PROVIDER_DEFAULTS: dict[str, dict[str, str]] = { + "deepseek": { + "name": "DeepSeek", + "base_url": "https://api.deepseek.com/v1", + "model": "deepseek-chat", + "api_env": "DEEPSEEK_API_KEY", + }, + "glm": { + "name": "GLM", + "base_url": "https://open.bigmodel.cn/api/paas/v4", + "model": "GLM-4.7", + "api_env": "GLM_API_KEY", + }, + "openai": { + "name": "OpenAI", + "base_url": "https://api.openai.com/v1", + "model": "gpt-4o-mini", + "api_env": "OPENAI_API_KEY", + }, + "ollama": { + "name": "Ollama", + "base_url": "http://127.0.0.1:11434", + "model": "llama3:latest", + "api_env": "", + }, +} +PROVIDER_ALIASES = { + "zhipu": "glm", + "chatglm": "glm", + "deep-seek": "deepseek", + "local": "ollama", +} +PROMPT_PROFILE_CACHE_TTL_SECONDS = 60.0 +BOOL_TRUE_VALUES = {"1", "true", "yes", "y", "on", "是"} +OPENCLAW_ROUTING_CACHE_TTL_SECONDS = 30.0 +OPENCLAW_DEFAULT_RULES_RELATIVE_PATH = "../.openclaw/pmflow-routing-rules.json" +OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS = 120 +OPENCLAW_AGENTS_TIMEOUT_SECONDS_MAX = 120 +DEFAULT_TIER_MODEL_OVERRIDES = { + "low": OPENCLAW_DEFAULT_LOW_MODEL, + "high": OPENCLAW_DEFAULT_HIGH_MODEL, + "safety": OPENCLAW_DEFAULT_SAFETY_MODEL, +} +DEFAULT_HIGH_TIER_HINTS = ( + "classify_intent_complex", + "project_from_text", + "feishu_project_prefill", + "feishu_task_orchestration", + "feishu_project_signal_classification", + "adaptive_rag_answer_deep", + "rag_db_project_semantic_retrieval", + "analyze_food_calories", + "sstc_ai_breakdown", + "generate_task_breakdown", + "recommend_assignee", + "alignment", + "charter", + "cognitive_conflict", + "blocker", + "model_requirement", + "signal_planner_analyze", +) +DEFAULT_LOW_TIER_HINTS = ( + "feishu_level_classification", + "feishu_signal_extraction", + "feishu_humanized_reply", + "signal_source_onboarding_plan", + "signal_source_ai_worker_execution", + "extract_context_insights", + "project_draft_", + "adaptive_rag_answer_normal", + "rag_intent_classification", + "classify_intent", +) +STRUCTURED_OUTPUT_FUNCTION_HINTS = ( + "feishu_level_classification", + "feishu_signal_extraction", + "signal_source_onboarding_plan", + "signal_source_ai_worker_execution", + "rag_intent_classification", + "classify_intent", + "feishu_project_prefill", + "feishu_task_orchestration", + "generate_task_breakdown", + "generate_charter_from_signal", + "decompose_tasks_from_charter", + "recommend_assignees_for_task", + "evaluate_model_requirement", + "calculate_alignment_score", + "detect_cognitive_conflicts", + "rag_db_project_semantic_retrieval", + "workload_optimize_prompt", + "analyze_cost_roi", + "analyze_food_calories", + "sstc_ai_breakdown", + "sstc_charter_localize_zh", + "sstc_charter_prefill", + "signal_planner_analyze", +) +LOW_TO_HIGH_ESCALATION_FALLBACK_MARKERS = ( + "不知道", + "不清楚", + "无法回答", + "无法确定", + "无法判断", + "信息不足", + "缺少信息", + "没有足够信息", + "请提供更多信息", + "抱歉", + "不能提供", + "无法访问", + "as an ai", + "i'm sorry", + "cannot answer", + "don't know", +) +FAST_LOCAL_FALLBACK_FUNCTION_HINTS = ( + "signal_source_onboarding_plan", + "signal_source_ai_worker_execution", +) +FAST_LOCAL_PLANNER_FUNCTION_HINTS = ( + "signal_source_onboarding_plan", +) +FAST_LOCAL_WORKER_FUNCTION_HINTS = ("signal_source_ai_worker_execution",) +FAST_LOCAL_FALLBACK_FAST_TIMEOUT_SECONDS = 20.0 +FAST_LOCAL_FALLBACK_COMPLETE_TIMEOUT_SECONDS = 35.0 +STRUCTURED_CACHE_TTL_SECONDS = 300 +LLM_ENGINE_SETTINGS_DEFAULTS = { + "enable_local_kb_qa": False, + "local_kb_qa_scope": "kb_qa_only", +} +LOCAL_KB_QA_FUNCTION_PREFIXES = ( + "adaptive_rag_answer_", +) +LOCAL_KB_QA_FUNCTION_EXACT = { + "rag_intent_classification", + "rag_db_project_semantic_retrieval", +} +STRICT_MODEL_ERROR_FUNCTION_PREFIXES = ( + "adaptive_rag_answer_", +) +STRICT_MODEL_ERROR_FUNCTION_EXACT = { + "rag_db_project_semantic_retrieval", +} +FAIL_FAST_ERROR_MARKERS = ( + "primary_engine_empty_response", + "high_tier_empty_response", + "llm 返回空内容", + "返回空内容", + "empty_response", + "provider_circuit_open", +) +CIRCUIT_BREAKER_FAILURE_THRESHOLD = 2 +CIRCUIT_BREAKER_COOLDOWN_SECONDS = 300.0 +CIRCUIT_BREAKER_EXEMPT_FUNCTION_PREFIXES = ( + "project_from_text_", + "project_draft_", +) +DDL_API_GUARDRAIL_FUNCTION_EXACT = { + "classify_intent_ai_pmo_kernel", + "classify_intent_complex", +} +DDL_API_GUARDRAIL_SYSTEM_PROMPT = """ +硬性约束(最高优先级): +1. 禁止直接执行或建议执行数据库 DDL SQL(CREATE/ALTER/DROP/TRUNCATE/RENAME/COMMENT/GRANT/REVOKE)。 +2. 所有数据库 DDL 变更必须通过受控接口或迁移流程(例如 Alembic migration)。 +3. 若输入要求直接改库执行 DDL: + - `classify_intent_ai_pmo_kernel` / `classify_intent_complex` 必须返回 `query.read_only`,并在 reasoning 包含 `ddl_api_required`。 +""".strip() +STRUCTURED_OBJECT_SCHEMAS: dict[str, dict[str, Any]] = { + "ai_pmo_signal_title_refine": { + "required_keys": ("title",), + "type_checks": { + "title": "str", + }, + }, + "signal_source_onboarding_plan": { + "required_keys": ("should_onboard", "summary"), + "type_checks": { + "should_onboard": "bool", + "summary": "str", + }, + "optional_type_checks": { + "worker_roles": ("list",), + "execution_instruction": ("str_or_none",), + "source": ("dict_or_none",), + }, + }, + "signal_source_ai_worker_execution": { + "required_keys": ("summary", "deliverables", "risks", "next_actions"), + "type_checks": { + "summary": "str", + "deliverables": "list", + "risks": "list", + "next_actions": "list", + }, + }, + "classify_intent_ai_pmo_kernel": { + "required_keys": ("confidence", "reasoning"), + "type_checks": { + "confidence": "number", + "reasoning": "str", + }, + "optional_type_checks": { + "intent": ("str_or_none",), + "family": ("str_or_none",), + "action": ("str_or_none",), + "project_id": ("number_or_none",), + "requested_count": ("number_or_none",), + "requires_confirmation": ("bool",), + }, + }, + "classify_intent_complex": { + "required_keys": ("confidence", "reasoning"), + "type_checks": { + "confidence": "number", + "reasoning": "str", + }, + "optional_type_checks": { + "intent": ("str_or_none",), + "family": ("str_or_none",), + "action": ("str_or_none",), + "project_id": ("number_or_none",), + "requested_count": ("number_or_none",), + "requires_confirmation": ("bool",), + }, + }, + "signal_planner_analyze": { + "required_keys": ("summary", "priority", "execution_plan", "risks", "confidence"), + "type_checks": { + "summary": "str", + "priority": "str", + "execution_plan": "list", + "risks": "list", + "confidence": "number", + }, + "optional_type_checks": { + "assignee_user_id": ("number_or_none",), + "assignee_reason": ("str_or_none",), + }, + }, +} + + diff --git a/backend/app/services/llm_service/shared_part_02.py b/backend/app/services/llm_service/shared_part_02.py new file mode 100644 index 00000000..6e562ae8 --- /dev/null +++ b/backend/app/services/llm_service/shared_part_02.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from .shared_part_01 import * + +def _normalize_provider(raw: Optional[str]) -> str: + provider = str(raw or "").strip().lower() + provider = PROVIDER_ALIASES.get(provider, provider) + if provider not in SUPPORTED_PROVIDERS: + raise ValueError("provider 仅支持 deepseek/glm/openai/ollama") + return provider + +def _mask_secret(secret: str) -> str: + value = str(secret or "").strip() + if not value: + return "" + if len(value) <= 8: + return "*" * len(value) + return f"{value[:4]}...{value[-4:]}" + +def _safe_json(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + try: + # 返回深拷贝,避免原地修改导致 SQLAlchemy 不感知 JSON 字段变更 + return json.loads(json.dumps(value)) + except Exception: + return dict(value) + if isinstance(value, str): + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + return {} + +def _env_bool(name: str, default: bool = False) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return str(raw).strip().lower() in BOOL_TRUE_VALUES + +def _parse_csv_items(raw: str) -> tuple[str, ...]: + rows = [item.strip().lower() for item in str(raw or "").split(",") if item.strip()] + return tuple(rows) + +def _cn_now_text() -> str: + cn_tz = timezone(timedelta(hours=8)) + return datetime.now(cn_tz).strftime("%Y-%m-%d %H:%M:%S") + +def _cn_now_text_ms() -> str: + cn_tz = timezone(timedelta(hours=8)) + return datetime.now(cn_tz).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + +def _runtime_log(scope: str, message: str) -> None: + print(f"[{_cn_now_text()}][{scope}] {message}") + +def _looks_like_openclaw_timeout_message(text: str) -> bool: + lowered = str(text or "").strip().lower() + if not lowered: + return False + timeout_signals = ( + "request timed out before a response was generated", + "agents.defaults.timeoutseconds", + "timeoutseconds", + "before a response was generated", + ) + return any(signal in lowered for signal in timeout_signals) + +class ChatCompletionResult: + text: str + raw_response: Any = None + +class StructuredExtractionResult: + payload: Optional[dict[str, Any]] + source_field: str + provider: str + model: str + error_code: Optional[str] = None + raw_text: str = "" + validation: str = "invalid" + from_cache: bool = False + diff --git a/backend/app/services/model_capability/__init__.py b/backend/app/services/model_capability/__init__.py new file mode 100644 index 00000000..41597c15 --- /dev/null +++ b/backend/app/services/model_capability/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .service import ModelCapabilityService + +__all__ = ["ModelCapabilityService"] diff --git a/backend/app/services/model_capability/mixin_cost_tracking.py b/backend/app/services/model_capability/mixin_cost_tracking.py new file mode 100644 index 00000000..5057217c --- /dev/null +++ b/backend/app/services/model_capability/mixin_cost_tracking.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +from .shared import * + +class ModelCapabilityCostTrackingMixin: + async def track_cost_and_roi( + self, + truth_node_id: int, + period: str = "weekly", + tenant_id: int = 1, + ) -> Dict[str, Any]: + """算力成本跟踪与 ROI 核算 + + Args: + truth_node_id: 宪章(需求)ID + period: 周期类型(daily, weekly, monthly, project) + tenant_id: 租户 ID + + Returns: + 包含成本明细、ROI 分析等信息的字典 + """ + # 获取相关任务和模型绑定 + tasks = await self._get_truth_node_tasks(truth_node_id, tenant_id) + bindings = await self._get_truth_node_bindings(truth_node_id, tenant_id) + + if not tasks: + return { + "total_tokens": 0, + "total_cost_usd": 0.0, + "task_breakdown": [], + "roi_analysis": {}, + "cost_variance": 0.0, + } + + # 汇总 token 使用量和成本 + total_tokens = 0 + total_cost = 0.0 + task_costs = [] + + for task in tasks: + # 从 AI 调用日志统计 token + task_tokens = await self._get_task_token_usage(task.id) + task_cost = await self._calculate_task_cost(task, bindings) + + total_tokens += task_tokens + total_cost += task_cost + task_costs.append({ + "task_id": task.id, + "task_name": task.title, + "tokens": task_tokens, + "cost_usd": round(task_cost, 4), + }) + + # 获取或创建成本跟踪记录 + cost_tracking = await self._get_or_create_cost_tracking( + truth_node_id=truth_node_id, + period=period, + tenant_id=tenant_id, + bindings=bindings, + ) + + # 更新实际数据 + cost_tracking.actual_tokens = total_tokens + cost_tracking.actual_cost_usd = round(total_cost, 4) + + # AI 分析 ROI + truth_node = await self._get_truth_node(truth_node_id, tenant_id) + roi_analysis = await self._analyze_cost_roi( + total_cost=total_cost, + total_tokens=total_tokens, + task_count=len(tasks), + truth_node_content=str(truth_node.content or truth_node.title or "") if truth_node else "", + tenant_id=tenant_id, + ) + + cost_tracking.roi_score = roi_analysis.get("roi_score") + cost_tracking.roi_analysis = roi_analysis + + # 生成优化建议 + optimization = await self._generate_cost_optimization( + truth_node_id=truth_node_id, + tenant_id=tenant_id, + total_cost=total_cost, + ) + cost_tracking.optimization_suggestions = optimization + + await self.db.commit() + await self.db.refresh(cost_tracking) + + # 计算成本偏差 + cost_variance = total_cost - (cost_tracking.estimated_cost_usd or 0) + + return { + "total_tokens": total_tokens, + "total_cost_usd": round(total_cost, 4), + "task_breakdown": task_costs, + "roi_analysis": roi_analysis, + "cost_variance": round(cost_variance, 4), + "optimization_suggestions": optimization, + } + + async def _get_truth_node_tasks( + self, truth_node_id: int, tenant_id: int + ) -> List[Task]: + """获取宪章下的所有任务""" + result = await self.db.execute( + select(Task).where( + and_( + Task.truth_node_id == truth_node_id, + Task.tenant_id == tenant_id, + ) + ) + ) + return list(result.scalars().all()) + + async def _get_truth_node_bindings( + self, truth_node_id: int, tenant_id: int + ) -> List[ModelRequirementBinding]: + """获取宪章的模型绑定""" + result = await self.db.execute( + select(ModelRequirementBinding).where( + and_( + ModelRequirementBinding.truth_node_id == truth_node_id, + ModelRequirementBinding.tenant_id == tenant_id, + ) + ) + ) + return list(result.scalars().all()) + + async def _get_task_token_usage(self, task_id: int) -> int: + """从 AI 调用日志统计任务的 token 使用量""" + # 注意:需要根据实际日志记录方式调整 + # 这里假设日志中有 task_id 或相关标识 + result = await self.db.execute( + select(func.sum(AICallLog.total_tokens)).where( + AICallLog.function_name.contains(f"task_{task_id}") + ) + ) + total = result.scalar() or 0 + return int(total) + + async def _calculate_task_cost( + self, task: Task, bindings: List[ModelRequirementBinding] + ) -> float: + """计算任务成本""" + # 如果有绑定记录,使用绑定的预估成本 + if bindings: + return sum(float(b.estimated_cost or 0) for b in bindings) / max(len(bindings), 1) + return 0.0 + + async def _get_or_create_cost_tracking( + self, + truth_node_id: int, + period: str, + tenant_id: int, + bindings: List[ModelRequirementBinding], + ) -> CostTracking: + """获取或创建成本跟踪记录""" + # 查找现有记录 + result = await self.db.execute( + select(CostTracking).where( + and_( + CostTracking.truth_node_id == truth_node_id, + CostTracking.period == period, + CostTracking.tenant_id == tenant_id, + ) + ).order_by(desc(CostTracking.created_at)) + ) + tracking = result.scalar_one_or_none() + + if not tracking: + # 创建新记录 + tracking = CostTracking( + tenant_id=tenant_id, + truth_node_id=truth_node_id, + model_version_id=bindings[0].model_version_id if bindings else None, + period=period, + estimated_tokens=0, + actual_tokens=0, + estimated_cost_usd=sum(float(b.estimated_cost or 0) for b in bindings), + actual_cost_usd=0.0, + roi_score=None, + roi_analysis={}, + optimization_suggestions={}, + ) + self.db.add(tracking) + await self.db.commit() + await self.db.refresh(tracking) + + return tracking + + async def _analyze_cost_roi( + self, + total_cost: float, + total_tokens: int, + task_count: int, + truth_node_content: str, + tenant_id: int, + ) -> Dict[str, Any]: + """AI 分析成本效益""" + from ..config.ai_prompts import PROMPT_ANALYZE_COST_ROI + + prompt = PROMPT_ANALYZE_COST_ROI.format( + total_cost=f"${total_cost:.2f}", + total_tokens=f"{total_tokens:,}", + task_count=task_count, + truth_node_content=truth_node_content[:2000], # 限制长度 + ) + + try: + text = await self.ai_service._invoke_with_retry( + prompt, + db=self.db, + tenant_id=tenant_id, + function_name="analyze_cost_roi", + ) + return self.ai_service._parse_json(text) + except Exception: + # 失败时返回默认分析 + return { + "roi_score": 5.0, + "cost_efficiency": "fair", + "cost_per_task": round(total_cost / max(task_count, 1), 2), + "optimization_suggestions": [], + "benchmark_comparison": "无法分析", + } + + async def _generate_cost_optimization( + self, truth_node_id: int, tenant_id: int, total_cost: float + ) -> Dict[str, Any]: + """生成成本优化建议""" + # 简单的优化建议逻辑 + suggestions = [] + + # 检查是否可以使用更便宜的模型 + bindings = await self._get_truth_node_bindings(truth_node_id, tenant_id) + if bindings: + for binding in bindings: + model = await self._get_model_version(binding.model_version_id) + if model and model.cost_per_1k_tokens and model.cost_per_1k_tokens > 0.001: + suggestions.append({ + "type": "model_downgrade", + "priority": "medium", + "description": f"考虑切换到更便宜的模型替代 {model.name}", + "estimated_savings_percent": 30, + }) + + # 检查是否可以启用缓存 + suggestions.append({ + "type": "cache_optimization", + "priority": "high", + "description": "启用 Redis 缓存可减少 20-40% 重复调用", + "estimated_savings_percent": 30, + }) + + return { + "suggestions": suggestions, + "total_potential_savings_percent": 30 if suggestions else 0, + } + diff --git a/backend/app/services/model_capability/mixin_gap_queries.py b/backend/app/services/model_capability/mixin_gap_queries.py new file mode 100644 index 00000000..bfd30d81 --- /dev/null +++ b/backend/app/services/model_capability/mixin_gap_queries.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from .shared import * + +class ModelCapabilityGapQueriesMixin: + async def list_capability_gaps( + self, + tenant_id: int, + status: Optional[str] = None, + severity: Optional[str] = None, + limit: int = 100, + ) -> Dict[str, Any]: + """列出能力差距记录""" + query = select(ModelCapabilityGap).where( + ModelCapabilityGap.tenant_id == tenant_id + ) + + if status: + query = query.where(ModelCapabilityGap.status == status) + if severity: + query = query.where(ModelCapabilityGap.severity == severity) + + query = query.order_by(desc(ModelCapabilityGap.created_at)).limit(limit) + + result = await self.db.execute(query) + gaps = result.scalars().all() + + return { + "gaps": [ + { + "id": g.id, + "gap_type": g.gap_type, + "severity": g.severity, + "description": g.description, + "ai_analysis": g.ai_analysis, + "status": g.status, + "created_at": g.created_at.isoformat() if g.created_at else None, + "resolved_at": g.resolved_at.isoformat() if g.resolved_at else None, + } + for g in gaps + ], + "total": len(gaps), + } + + async def acknowledge_gap( + self, gap_id: int, tenant_id: int + ) -> Dict[str, Any]: + """确认能力差距""" + result = await self.db.execute( + select(ModelCapabilityGap).where( + and_( + ModelCapabilityGap.id == gap_id, + ModelCapabilityGap.tenant_id == tenant_id, + ) + ) + ) + gap = result.scalar_one_or_none() + + if not gap: + raise ValueError(f"能力差距 {gap_id} 不存在") + + gap.status = "acknowledged" + await self.db.commit() + + return {"id": gap.id, "status": gap.status} + + async def resolve_gap( + self, gap_id: int, tenant_id: int, resolution_notes: Optional[str] = None + ) -> Dict[str, Any]: + """解决能力差距""" + result = await self.db.execute( + select(ModelCapabilityGap).where( + and_( + ModelCapabilityGap.id == gap_id, + ModelCapabilityGap.tenant_id == tenant_id, + ) + ) + ) + gap = result.scalar_one_or_none() + + if not gap: + raise ValueError(f"能力差距 {gap_id} 不存在") + + gap.status = "resolved" + gap.resolved_at = now_cn_naive() + gap.resolution_notes = resolution_notes + await self.db.commit() + + return {"id": gap.id, "status": gap.status} + + async def get_cost_tracking( + self, truth_node_id: int, period: str = "weekly", tenant_id: int = 1 + ) -> Dict[str, Any]: + """获取成本跟踪记录""" + result = await self.db.execute( + select(CostTracking).where( + and_( + CostTracking.truth_node_id == truth_node_id, + CostTracking.period == period, + CostTracking.tenant_id == tenant_id, + ) + ).order_by(desc(CostTracking.created_at)) + ) + tracking = result.scalar_one_or_none() + + if not tracking: + return { + "truth_node_id": truth_node_id, + "period": period, + "estimated_tokens": 0, + "actual_tokens": 0, + "estimated_cost_usd": 0.0, + "actual_cost_usd": 0.0, + "roi_score": None, + "roi_analysis": {}, + "optimization_suggestions": {}, + } + + return { + "id": tracking.id, + "truth_node_id": tracking.truth_node_id, + "period": tracking.period, + "estimated_tokens": tracking.estimated_tokens, + "actual_tokens": tracking.actual_tokens, + "estimated_cost_usd": float(tracking.estimated_cost_usd), + "actual_cost_usd": float(tracking.actual_cost_usd), + "roi_score": tracking.roi_score, + "roi_analysis": tracking.roi_analysis, + "optimization_suggestions": tracking.optimization_suggestions, + } + diff --git a/backend/app/services/model_capability/mixin_validation.py b/backend/app/services/model_capability/mixin_validation.py new file mode 100644 index 00000000..ecd18a84 --- /dev/null +++ b/backend/app/services/model_capability/mixin_validation.py @@ -0,0 +1,279 @@ +from __future__ import annotations + +from .shared import * + +class ModelCapabilityValidationMixin: + def __init__(self, db: AsyncSession, ai_service: AIService): + self.db = db + self.ai_service = ai_service + + async def validate_model_requirement_match( + self, + truth_node_id: int, + model_version_id: int, + tenant_id: int, + project_id: Optional[int] = None, + ) -> Dict[str, Any]: + """智能匹配校验:评估需求与模型的匹配度 + + Args: + truth_node_id: 宪章(需求)ID + model_version_id: 模型版本 ID + tenant_id: 租户 ID + + Returns: + 包含匹配度、成本预估、风险提示、能力差距等信息的字典 + """ + # 获取宪章和模型信息 + truth_node = await self._get_truth_node(truth_node_id, tenant_id) + model_version = await self._get_model_version(model_version_id) + + if not truth_node: + raise ValueError(f"宪章 {truth_node_id} 不存在") + if not model_version: + raise ValueError(f"模型版本 {model_version_id} 不存在") + if project_id is not None: + await self.validate_truth_node_in_project( + truth_node_id=truth_node_id, + tenant_id=tenant_id, + project_id=project_id, + ) + + # 格式化模型信息用于 AI 分析 + model_info = self._format_model_info(model_version) + charter_content = str(truth_node.content or truth_node.title or "") + + # AI 评估匹配度 + evaluation = await self.ai_service.evaluate_model_requirement( + charter_content=charter_content, + model_version_id=model_version_id, + model_info=json.dumps(model_info, ensure_ascii=False), + tenant_id=tenant_id, + db=self.db, + ) + + # 创建或更新绑定记录 + binding = await self._upsert_binding( + truth_node_id, model_version_id, evaluation, tenant_id + ) + + # 检测能力差距 + gaps = await self._detect_capability_gaps( + truth_node_id, model_version_id, evaluation, tenant_id + ) + + return { + "binding_id": binding.id, + "compatibility_score": evaluation.get("compatibility_score", 0), + "cost_estimate_usd": evaluation.get("cost_estimate_usd", 0.0), + "cost_estimate_tokens_per_day": evaluation.get("cost_estimate_tokens_per_day", 0), + "risk_hints": evaluation.get("risk_hints", []), + "capability_gaps": gaps, + "recommendations": evaluation.get("recommendations", []), + } + + async def validate_truth_node_in_project( + self, truth_node_id: int, tenant_id: int, project_id: int + ) -> TruthNode: + """校验宪章是否属于指定项目。""" + truth_node = await self._get_truth_node(truth_node_id, tenant_id) + if not truth_node: + raise ValueError(f"宪章 {truth_node_id} 不存在") + if truth_node.project_id != project_id: + raise ValueError( + f"宪章 {truth_node_id} 不属于项目 {project_id},拒绝跨项目操作" + ) + return truth_node + + async def _get_truth_node( + self, truth_node_id: int, tenant_id: int + ) -> Optional[TruthNode]: + """获取宪章""" + result = await self.db.execute( + select(TruthNode).where( + and_( + TruthNode.id == truth_node_id, + TruthNode.tenant_id == tenant_id, + ) + ) + ) + return result.scalar_one_or_none() + + async def _get_model_version( + self, model_version_id: int + ) -> Optional[ModelVersion]: + """获取模型版本""" + result = await self.db.execute( + select(ModelVersion).where(ModelVersion.id == model_version_id) + ) + return result.scalar_one_or_none() + + def _format_model_info(self, model: ModelVersion) -> Dict[str, Any]: + """格式化模型信息用于 AI 分析""" + return { + "name": model.name, + "provider": model.provider, + "version": model.version, + "capability_boundaries": model.capability_boundaries or {}, + "cost_per_1k_tokens": float(model.cost_per_1k_tokens) if model.cost_per_1k_tokens else 0.0, + } + + async def _upsert_binding( + self, + truth_node_id: int, + model_version_id: int, + evaluation: Dict[str, Any], + tenant_id: int, + ) -> ModelRequirementBinding: + """创建或更新模型-需求绑定""" + # 检查是否已存在绑定 + result = await self.db.execute( + select(ModelRequirementBinding).where( + and_( + ModelRequirementBinding.truth_node_id == truth_node_id, + ModelRequirementBinding.model_version_id == model_version_id, + ) + ) + ) + binding = result.scalar_one_or_none() + + compatibility_score = evaluation.get("compatibility_score", 0) + cost_estimate = evaluation.get("cost_estimate_usd", 0.0) + risk_notes = ", ".join(evaluation.get("risk_hints", [])) + + if binding: + # 更新现有绑定 + binding.compatibility_score = int(compatibility_score) + binding.estimated_cost = float(cost_estimate) + binding.risk_notes = risk_notes[:1000] if risk_notes else None # 限制长度 + else: + # 创建新绑定 + binding = ModelRequirementBinding( + tenant_id=tenant_id, + truth_node_id=truth_node_id, + model_version_id=model_version_id, + compatibility_score=int(compatibility_score), + estimated_cost=float(cost_estimate), + risk_notes=risk_notes[:1000] if risk_notes else None, + ) + self.db.add(binding) + + await self.db.commit() + await self.db.refresh(binding) + return binding + + async def _detect_capability_gaps( + self, + truth_node_id: int, + model_version_id: int, + evaluation: Dict[str, Any], + tenant_id: int, + ) -> List[Dict[str, Any]]: + """检测能力差距并创建预警记录""" + gaps = [] + + # 检查兼容性评分 + compatibility = evaluation.get("compatibility_score", 100) + if compatibility < 60: + gap = await self._create_capability_gap( + truth_node_id=truth_node_id, + model_version_id=model_version_id, + gap_type="capability", + severity="critical" if compatibility < 40 else "high", + description=f"模型兼容性评分仅 {compatibility}/100,可能无法满足需求", + ai_analysis={"compatibility_score": compatibility}, + tenant_id=tenant_id, + ) + gaps.append(gap) + + # 检查成本风险 + cost_estimate = evaluation.get("cost_estimate_usd", 0) + if cost_estimate > 100: # 日成本超过 $100 视为高风险 + gap = await self._create_capability_gap( + truth_node_id=truth_node_id, + model_version_id=model_version_id, + gap_type="cost", + severity="high" if cost_estimate > 500 else "medium", + description=f"预估日成本 ${cost_estimate:.2f},超出常规范围", + ai_analysis={"cost_estimate_usd": cost_estimate}, + tenant_id=tenant_id, + ) + gaps.append(gap) + + # 检查风险提示 + risk_hints = evaluation.get("risk_hints", []) + if risk_hints: + gap = await self._create_capability_gap( + truth_node_id=truth_node_id, + model_version_id=model_version_id, + gap_type="risk", + severity="medium", + description="检测到潜在风险", + ai_analysis={"risk_hints": risk_hints}, + tenant_id=tenant_id, + ) + gaps.append(gap) + + return gaps + + async def _create_capability_gap( + self, + truth_node_id: int, + model_version_id: int, + gap_type: str, + severity: str, + description: str, + ai_analysis: Dict[str, Any], + tenant_id: int, + ) -> Dict[str, Any]: + """创建能力差距记录""" + # 检查是否已存在相同类型的未解决差距 + existing = await self.db.execute( + select(ModelCapabilityGap).where( + and_( + ModelCapabilityGap.truth_node_id == truth_node_id, + ModelCapabilityGap.model_version_id == model_version_id, + ModelCapabilityGap.gap_type == gap_type, + ModelCapabilityGap.status.in_(["open", "acknowledged"]), + ) + ) + ) + existing_gap = existing.scalar_one_or_none() + + if existing_gap: + # 更新现有差距 + existing_gap.description = description + existing_gap.ai_analysis = ai_analysis + existing_gap.severity = severity + await self.db.commit() + await self.db.refresh(existing_gap) + return { + "id": existing_gap.id, + "gap_type": existing_gap.gap_type, + "severity": existing_gap.severity, + "description": existing_gap.description, + "status": existing_gap.status, + } + else: + # 创建新差距 + gap = ModelCapabilityGap( + tenant_id=tenant_id, + model_version_id=model_version_id, + truth_node_id=truth_node_id, + gap_type=gap_type, + severity=severity, + description=description, + ai_analysis=ai_analysis, + status="open", + ) + self.db.add(gap) + await self.db.commit() + await self.db.refresh(gap) + return { + "id": gap.id, + "gap_type": gap.gap_type, + "severity": gap.severity, + "description": gap.description, + "status": gap.status, + } + diff --git a/backend/app/services/model_capability/service.py b/backend/app/services/model_capability/service.py new file mode 100644 index 00000000..15b37b0e --- /dev/null +++ b/backend/app/services/model_capability/service.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from .mixin_cost_tracking import ModelCapabilityCostTrackingMixin +from .mixin_gap_queries import ModelCapabilityGapQueriesMixin +from .mixin_validation import ModelCapabilityValidationMixin + + +class ModelCapabilityService( + ModelCapabilityValidationMixin, + ModelCapabilityCostTrackingMixin, + ModelCapabilityGapQueriesMixin, +): + """模型能力矩阵核心服务。""" + + +__all__ = ["ModelCapabilityService"] diff --git a/backend/app/services/model_capability/shared.py b/backend/app/services/model_capability/shared.py new file mode 100644 index 00000000..f1dc091c --- /dev/null +++ b/backend/app/services/model_capability/shared.py @@ -0,0 +1,29 @@ +"""模型能力矩阵核心服务 + +AI 开发团队专属垂直能力矩阵的核心业务逻辑: +- 模型-需求智能匹配校验 +- 算力成本跟踪与 ROI 核算 +- 能力差距检测与预警 +- 成本优化建议 +""" +import json +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, Union + +from sqlalchemy import select, and_, func, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from ...models import ( + ModelVersion, + ModelRequirementBinding, + TruthNode, + Task, + AICallLog, + ModelCapabilityGap, + CostTracking, + User, +) +from ..ai_service import AIService + + +from ...utils.timezone import now_cn_naive diff --git a/backend/app/services/model_capability_service.py b/backend/app/services/model_capability_service.py index df795d34..511e1407 100644 --- a/backend/app/services/model_capability_service.py +++ b/backend/app/services/model_capability_service.py @@ -1,702 +1,5 @@ -"""模型能力矩阵核心服务 +from __future__ import annotations -AI 开发团队专属垂直能力矩阵的核心业务逻辑: -- 模型-需求智能匹配校验 -- 算力成本跟踪与 ROI 核算 -- 能力差距检测与预警 -- 成本优化建议 -""" -import json -from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional, Union +from .model_capability import ModelCapabilityService -from sqlalchemy import select, and_, func, desc -from sqlalchemy.ext.asyncio import AsyncSession - -from ..models import ( - ModelVersion, - ModelRequirementBinding, - TruthNode, - Task, - AICallLog, - ModelCapabilityGap, - CostTracking, - User, -) -from ..services.ai_service import AIService - - -from ..utils.timezone import now_cn_naive -class ModelCapabilityService: - """模型能力矩阵核心服务""" - - def __init__(self, db: AsyncSession, ai_service: AIService): - self.db = db - self.ai_service = ai_service - - # ======================================================================== - # 模型-需求智能匹配校验 - # ======================================================================== - - async def validate_model_requirement_match( - self, - truth_node_id: int, - model_version_id: int, - tenant_id: int, - project_id: Optional[int] = None, - ) -> Dict[str, Any]: - """智能匹配校验:评估需求与模型的匹配度 - - Args: - truth_node_id: 宪章(需求)ID - model_version_id: 模型版本 ID - tenant_id: 租户 ID - - Returns: - 包含匹配度、成本预估、风险提示、能力差距等信息的字典 - """ - # 获取宪章和模型信息 - truth_node = await self._get_truth_node(truth_node_id, tenant_id) - model_version = await self._get_model_version(model_version_id) - - if not truth_node: - raise ValueError(f"宪章 {truth_node_id} 不存在") - if not model_version: - raise ValueError(f"模型版本 {model_version_id} 不存在") - if project_id is not None: - await self.validate_truth_node_in_project( - truth_node_id=truth_node_id, - tenant_id=tenant_id, - project_id=project_id, - ) - - # 格式化模型信息用于 AI 分析 - model_info = self._format_model_info(model_version) - charter_content = str(truth_node.content or truth_node.title or "") - - # AI 评估匹配度 - evaluation = await self.ai_service.evaluate_model_requirement( - charter_content=charter_content, - model_version_id=model_version_id, - model_info=json.dumps(model_info, ensure_ascii=False), - tenant_id=tenant_id, - db=self.db, - ) - - # 创建或更新绑定记录 - binding = await self._upsert_binding( - truth_node_id, model_version_id, evaluation, tenant_id - ) - - # 检测能力差距 - gaps = await self._detect_capability_gaps( - truth_node_id, model_version_id, evaluation, tenant_id - ) - - return { - "binding_id": binding.id, - "compatibility_score": evaluation.get("compatibility_score", 0), - "cost_estimate_usd": evaluation.get("cost_estimate_usd", 0.0), - "cost_estimate_tokens_per_day": evaluation.get("cost_estimate_tokens_per_day", 0), - "risk_hints": evaluation.get("risk_hints", []), - "capability_gaps": gaps, - "recommendations": evaluation.get("recommendations", []), - } - - async def validate_truth_node_in_project( - self, truth_node_id: int, tenant_id: int, project_id: int - ) -> TruthNode: - """校验宪章是否属于指定项目。""" - truth_node = await self._get_truth_node(truth_node_id, tenant_id) - if not truth_node: - raise ValueError(f"宪章 {truth_node_id} 不存在") - if truth_node.project_id != project_id: - raise ValueError( - f"宪章 {truth_node_id} 不属于项目 {project_id},拒绝跨项目操作" - ) - return truth_node - - async def _get_truth_node( - self, truth_node_id: int, tenant_id: int - ) -> Optional[TruthNode]: - """获取宪章""" - result = await self.db.execute( - select(TruthNode).where( - and_( - TruthNode.id == truth_node_id, - TruthNode.tenant_id == tenant_id, - ) - ) - ) - return result.scalar_one_or_none() - - async def _get_model_version( - self, model_version_id: int - ) -> Optional[ModelVersion]: - """获取模型版本""" - result = await self.db.execute( - select(ModelVersion).where(ModelVersion.id == model_version_id) - ) - return result.scalar_one_or_none() - - def _format_model_info(self, model: ModelVersion) -> Dict[str, Any]: - """格式化模型信息用于 AI 分析""" - return { - "name": model.name, - "provider": model.provider, - "version": model.version, - "capability_boundaries": model.capability_boundaries or {}, - "cost_per_1k_tokens": float(model.cost_per_1k_tokens) if model.cost_per_1k_tokens else 0.0, - } - - async def _upsert_binding( - self, - truth_node_id: int, - model_version_id: int, - evaluation: Dict[str, Any], - tenant_id: int, - ) -> ModelRequirementBinding: - """创建或更新模型-需求绑定""" - # 检查是否已存在绑定 - result = await self.db.execute( - select(ModelRequirementBinding).where( - and_( - ModelRequirementBinding.truth_node_id == truth_node_id, - ModelRequirementBinding.model_version_id == model_version_id, - ) - ) - ) - binding = result.scalar_one_or_none() - - compatibility_score = evaluation.get("compatibility_score", 0) - cost_estimate = evaluation.get("cost_estimate_usd", 0.0) - risk_notes = ", ".join(evaluation.get("risk_hints", [])) - - if binding: - # 更新现有绑定 - binding.compatibility_score = int(compatibility_score) - binding.estimated_cost = float(cost_estimate) - binding.risk_notes = risk_notes[:1000] if risk_notes else None # 限制长度 - else: - # 创建新绑定 - binding = ModelRequirementBinding( - tenant_id=tenant_id, - truth_node_id=truth_node_id, - model_version_id=model_version_id, - compatibility_score=int(compatibility_score), - estimated_cost=float(cost_estimate), - risk_notes=risk_notes[:1000] if risk_notes else None, - ) - self.db.add(binding) - - await self.db.commit() - await self.db.refresh(binding) - return binding - - async def _detect_capability_gaps( - self, - truth_node_id: int, - model_version_id: int, - evaluation: Dict[str, Any], - tenant_id: int, - ) -> List[Dict[str, Any]]: - """检测能力差距并创建预警记录""" - gaps = [] - - # 检查兼容性评分 - compatibility = evaluation.get("compatibility_score", 100) - if compatibility < 60: - gap = await self._create_capability_gap( - truth_node_id=truth_node_id, - model_version_id=model_version_id, - gap_type="capability", - severity="critical" if compatibility < 40 else "high", - description=f"模型兼容性评分仅 {compatibility}/100,可能无法满足需求", - ai_analysis={"compatibility_score": compatibility}, - tenant_id=tenant_id, - ) - gaps.append(gap) - - # 检查成本风险 - cost_estimate = evaluation.get("cost_estimate_usd", 0) - if cost_estimate > 100: # 日成本超过 $100 视为高风险 - gap = await self._create_capability_gap( - truth_node_id=truth_node_id, - model_version_id=model_version_id, - gap_type="cost", - severity="high" if cost_estimate > 500 else "medium", - description=f"预估日成本 ${cost_estimate:.2f},超出常规范围", - ai_analysis={"cost_estimate_usd": cost_estimate}, - tenant_id=tenant_id, - ) - gaps.append(gap) - - # 检查风险提示 - risk_hints = evaluation.get("risk_hints", []) - if risk_hints: - gap = await self._create_capability_gap( - truth_node_id=truth_node_id, - model_version_id=model_version_id, - gap_type="risk", - severity="medium", - description="检测到潜在风险", - ai_analysis={"risk_hints": risk_hints}, - tenant_id=tenant_id, - ) - gaps.append(gap) - - return gaps - - async def _create_capability_gap( - self, - truth_node_id: int, - model_version_id: int, - gap_type: str, - severity: str, - description: str, - ai_analysis: Dict[str, Any], - tenant_id: int, - ) -> Dict[str, Any]: - """创建能力差距记录""" - # 检查是否已存在相同类型的未解决差距 - existing = await self.db.execute( - select(ModelCapabilityGap).where( - and_( - ModelCapabilityGap.truth_node_id == truth_node_id, - ModelCapabilityGap.model_version_id == model_version_id, - ModelCapabilityGap.gap_type == gap_type, - ModelCapabilityGap.status.in_(["open", "acknowledged"]), - ) - ) - ) - existing_gap = existing.scalar_one_or_none() - - if existing_gap: - # 更新现有差距 - existing_gap.description = description - existing_gap.ai_analysis = ai_analysis - existing_gap.severity = severity - await self.db.commit() - await self.db.refresh(existing_gap) - return { - "id": existing_gap.id, - "gap_type": existing_gap.gap_type, - "severity": existing_gap.severity, - "description": existing_gap.description, - "status": existing_gap.status, - } - else: - # 创建新差距 - gap = ModelCapabilityGap( - tenant_id=tenant_id, - model_version_id=model_version_id, - truth_node_id=truth_node_id, - gap_type=gap_type, - severity=severity, - description=description, - ai_analysis=ai_analysis, - status="open", - ) - self.db.add(gap) - await self.db.commit() - await self.db.refresh(gap) - return { - "id": gap.id, - "gap_type": gap.gap_type, - "severity": gap.severity, - "description": gap.description, - "status": gap.status, - } - - # ======================================================================== - # 算力成本跟踪与 ROI 核算 - # ======================================================================== - - async def track_cost_and_roi( - self, - truth_node_id: int, - period: str = "weekly", - tenant_id: int = 1, - ) -> Dict[str, Any]: - """算力成本跟踪与 ROI 核算 - - Args: - truth_node_id: 宪章(需求)ID - period: 周期类型(daily, weekly, monthly, project) - tenant_id: 租户 ID - - Returns: - 包含成本明细、ROI 分析等信息的字典 - """ - # 获取相关任务和模型绑定 - tasks = await self._get_truth_node_tasks(truth_node_id, tenant_id) - bindings = await self._get_truth_node_bindings(truth_node_id, tenant_id) - - if not tasks: - return { - "total_tokens": 0, - "total_cost_usd": 0.0, - "task_breakdown": [], - "roi_analysis": {}, - "cost_variance": 0.0, - } - - # 汇总 token 使用量和成本 - total_tokens = 0 - total_cost = 0.0 - task_costs = [] - - for task in tasks: - # 从 AI 调用日志统计 token - task_tokens = await self._get_task_token_usage(task.id) - task_cost = await self._calculate_task_cost(task, bindings) - - total_tokens += task_tokens - total_cost += task_cost - task_costs.append({ - "task_id": task.id, - "task_name": task.title, - "tokens": task_tokens, - "cost_usd": round(task_cost, 4), - }) - - # 获取或创建成本跟踪记录 - cost_tracking = await self._get_or_create_cost_tracking( - truth_node_id=truth_node_id, - period=period, - tenant_id=tenant_id, - bindings=bindings, - ) - - # 更新实际数据 - cost_tracking.actual_tokens = total_tokens - cost_tracking.actual_cost_usd = round(total_cost, 4) - - # AI 分析 ROI - truth_node = await self._get_truth_node(truth_node_id, tenant_id) - roi_analysis = await self._analyze_cost_roi( - total_cost=total_cost, - total_tokens=total_tokens, - task_count=len(tasks), - truth_node_content=str(truth_node.content or truth_node.title or "") if truth_node else "", - tenant_id=tenant_id, - ) - - cost_tracking.roi_score = roi_analysis.get("roi_score") - cost_tracking.roi_analysis = roi_analysis - - # 生成优化建议 - optimization = await self._generate_cost_optimization( - truth_node_id=truth_node_id, - tenant_id=tenant_id, - total_cost=total_cost, - ) - cost_tracking.optimization_suggestions = optimization - - await self.db.commit() - await self.db.refresh(cost_tracking) - - # 计算成本偏差 - cost_variance = total_cost - (cost_tracking.estimated_cost_usd or 0) - - return { - "total_tokens": total_tokens, - "total_cost_usd": round(total_cost, 4), - "task_breakdown": task_costs, - "roi_analysis": roi_analysis, - "cost_variance": round(cost_variance, 4), - "optimization_suggestions": optimization, - } - - async def _get_truth_node_tasks( - self, truth_node_id: int, tenant_id: int - ) -> List[Task]: - """获取宪章下的所有任务""" - result = await self.db.execute( - select(Task).where( - and_( - Task.truth_node_id == truth_node_id, - Task.tenant_id == tenant_id, - ) - ) - ) - return list(result.scalars().all()) - - async def _get_truth_node_bindings( - self, truth_node_id: int, tenant_id: int - ) -> List[ModelRequirementBinding]: - """获取宪章的模型绑定""" - result = await self.db.execute( - select(ModelRequirementBinding).where( - and_( - ModelRequirementBinding.truth_node_id == truth_node_id, - ModelRequirementBinding.tenant_id == tenant_id, - ) - ) - ) - return list(result.scalars().all()) - - async def _get_task_token_usage(self, task_id: int) -> int: - """从 AI 调用日志统计任务的 token 使用量""" - # 注意:需要根据实际日志记录方式调整 - # 这里假设日志中有 task_id 或相关标识 - result = await self.db.execute( - select(func.sum(AICallLog.total_tokens)).where( - AICallLog.function_name.contains(f"task_{task_id}") - ) - ) - total = result.scalar() or 0 - return int(total) - - async def _calculate_task_cost( - self, task: Task, bindings: List[ModelRequirementBinding] - ) -> float: - """计算任务成本""" - # 如果有绑定记录,使用绑定的预估成本 - if bindings: - return sum(float(b.estimated_cost or 0) for b in bindings) / max(len(bindings), 1) - return 0.0 - - async def _get_or_create_cost_tracking( - self, - truth_node_id: int, - period: str, - tenant_id: int, - bindings: List[ModelRequirementBinding], - ) -> CostTracking: - """获取或创建成本跟踪记录""" - # 查找现有记录 - result = await self.db.execute( - select(CostTracking).where( - and_( - CostTracking.truth_node_id == truth_node_id, - CostTracking.period == period, - CostTracking.tenant_id == tenant_id, - ) - ).order_by(desc(CostTracking.created_at)) - ) - tracking = result.scalar_one_or_none() - - if not tracking: - # 创建新记录 - tracking = CostTracking( - tenant_id=tenant_id, - truth_node_id=truth_node_id, - model_version_id=bindings[0].model_version_id if bindings else None, - period=period, - estimated_tokens=0, - actual_tokens=0, - estimated_cost_usd=sum(float(b.estimated_cost or 0) for b in bindings), - actual_cost_usd=0.0, - roi_score=None, - roi_analysis={}, - optimization_suggestions={}, - ) - self.db.add(tracking) - await self.db.commit() - await self.db.refresh(tracking) - - return tracking - - async def _analyze_cost_roi( - self, - total_cost: float, - total_tokens: int, - task_count: int, - truth_node_content: str, - tenant_id: int, - ) -> Dict[str, Any]: - """AI 分析成本效益""" - from ..config.ai_prompts import PROMPT_ANALYZE_COST_ROI - - prompt = PROMPT_ANALYZE_COST_ROI.format( - total_cost=f"${total_cost:.2f}", - total_tokens=f"{total_tokens:,}", - task_count=task_count, - truth_node_content=truth_node_content[:2000], # 限制长度 - ) - - try: - text = await self.ai_service._invoke_with_retry( - prompt, - db=self.db, - tenant_id=tenant_id, - function_name="analyze_cost_roi", - ) - return self.ai_service._parse_json(text) - except Exception: - # 失败时返回默认分析 - return { - "roi_score": 5.0, - "cost_efficiency": "fair", - "cost_per_task": round(total_cost / max(task_count, 1), 2), - "optimization_suggestions": [], - "benchmark_comparison": "无法分析", - } - - async def _generate_cost_optimization( - self, truth_node_id: int, tenant_id: int, total_cost: float - ) -> Dict[str, Any]: - """生成成本优化建议""" - # 简单的优化建议逻辑 - suggestions = [] - - # 检查是否可以使用更便宜的模型 - bindings = await self._get_truth_node_bindings(truth_node_id, tenant_id) - if bindings: - for binding in bindings: - model = await self._get_model_version(binding.model_version_id) - if model and model.cost_per_1k_tokens and model.cost_per_1k_tokens > 0.001: - suggestions.append({ - "type": "model_downgrade", - "priority": "medium", - "description": f"考虑切换到更便宜的模型替代 {model.name}", - "estimated_savings_percent": 30, - }) - - # 检查是否可以启用缓存 - suggestions.append({ - "type": "cache_optimization", - "priority": "high", - "description": "启用 Redis 缓存可减少 20-40% 重复调用", - "estimated_savings_percent": 30, - }) - - return { - "suggestions": suggestions, - "total_potential_savings_percent": 30 if suggestions else 0, - } - - # ======================================================================== - # 能力差距管理 - # ======================================================================== - - async def list_capability_gaps( - self, - tenant_id: int, - status: Optional[str] = None, - severity: Optional[str] = None, - limit: int = 100, - ) -> Dict[str, Any]: - """列出能力差距记录""" - query = select(ModelCapabilityGap).where( - ModelCapabilityGap.tenant_id == tenant_id - ) - - if status: - query = query.where(ModelCapabilityGap.status == status) - if severity: - query = query.where(ModelCapabilityGap.severity == severity) - - query = query.order_by(desc(ModelCapabilityGap.created_at)).limit(limit) - - result = await self.db.execute(query) - gaps = result.scalars().all() - - return { - "gaps": [ - { - "id": g.id, - "gap_type": g.gap_type, - "severity": g.severity, - "description": g.description, - "ai_analysis": g.ai_analysis, - "status": g.status, - "created_at": g.created_at.isoformat() if g.created_at else None, - "resolved_at": g.resolved_at.isoformat() if g.resolved_at else None, - } - for g in gaps - ], - "total": len(gaps), - } - - async def acknowledge_gap( - self, gap_id: int, tenant_id: int - ) -> Dict[str, Any]: - """确认能力差距""" - result = await self.db.execute( - select(ModelCapabilityGap).where( - and_( - ModelCapabilityGap.id == gap_id, - ModelCapabilityGap.tenant_id == tenant_id, - ) - ) - ) - gap = result.scalar_one_or_none() - - if not gap: - raise ValueError(f"能力差距 {gap_id} 不存在") - - gap.status = "acknowledged" - await self.db.commit() - - return {"id": gap.id, "status": gap.status} - - async def resolve_gap( - self, gap_id: int, tenant_id: int, resolution_notes: Optional[str] = None - ) -> Dict[str, Any]: - """解决能力差距""" - result = await self.db.execute( - select(ModelCapabilityGap).where( - and_( - ModelCapabilityGap.id == gap_id, - ModelCapabilityGap.tenant_id == tenant_id, - ) - ) - ) - gap = result.scalar_one_or_none() - - if not gap: - raise ValueError(f"能力差距 {gap_id} 不存在") - - gap.status = "resolved" - gap.resolved_at = now_cn_naive() - gap.resolution_notes = resolution_notes - await self.db.commit() - - return {"id": gap.id, "status": gap.status} - - # ======================================================================== - # 成本跟踪查询 - # ======================================================================== - - async def get_cost_tracking( - self, truth_node_id: int, period: str = "weekly", tenant_id: int = 1 - ) -> Dict[str, Any]: - """获取成本跟踪记录""" - result = await self.db.execute( - select(CostTracking).where( - and_( - CostTracking.truth_node_id == truth_node_id, - CostTracking.period == period, - CostTracking.tenant_id == tenant_id, - ) - ).order_by(desc(CostTracking.created_at)) - ) - tracking = result.scalar_one_or_none() - - if not tracking: - return { - "truth_node_id": truth_node_id, - "period": period, - "estimated_tokens": 0, - "actual_tokens": 0, - "estimated_cost_usd": 0.0, - "actual_cost_usd": 0.0, - "roi_score": None, - "roi_analysis": {}, - "optimization_suggestions": {}, - } - - return { - "id": tracking.id, - "truth_node_id": tracking.truth_node_id, - "period": tracking.period, - "estimated_tokens": tracking.estimated_tokens, - "actual_tokens": tracking.actual_tokens, - "estimated_cost_usd": float(tracking.estimated_cost_usd), - "actual_cost_usd": float(tracking.actual_cost_usd), - "roi_score": tracking.roi_score, - "roi_analysis": tracking.roi_analysis, - "optimization_suggestions": tracking.optimization_suggestions, - } +__all__ = ["ModelCapabilityService"] diff --git a/backend/app/services/model_context_bundle/__init__.py b/backend/app/services/model_context_bundle/__init__.py new file mode 100644 index 00000000..7707d6ca --- /dev/null +++ b/backend/app/services/model_context_bundle/__init__.py @@ -0,0 +1,11 @@ +"""Model context bundle exports.""" + +from .service import ModelContextBundleService +from .shared import CONTEXT_BUNDLE_KEYS, CONTEXT_BUNDLE_MARKER, TARGET_FUNCTION_CONTEXT + +__all__ = [ + "CONTEXT_BUNDLE_MARKER", + "CONTEXT_BUNDLE_KEYS", + "TARGET_FUNCTION_CONTEXT", + "ModelContextBundleService", +] diff --git a/backend/app/services/model_context_bundle/mixin_build.py b/backend/app/services/model_context_bundle/mixin_build.py new file mode 100644 index 00000000..d3e70204 --- /dev/null +++ b/backend/app/services/model_context_bundle/mixin_build.py @@ -0,0 +1,182 @@ +"""Bundle build and capability summary methods.""" + +from __future__ import annotations + +from .shared import ( + Any, + AsyncSession, + CONTEXT_BUNDLE_KEYS, + CONTEXT_BUNDLE_MARKER, + ModelRoutingService, + TARGET_FUNCTION_CONTEXT, + TaskBriefService, + datetime, + timezone, +) +from ..project_knowledge_graph import ProjectKnowledgeGraphService + + +class ModelContextBundleBuildMixin: + """Build context bundles and capability summaries.""" + + @classmethod + async def build_context_bundle( + cls, + db: AsyncSession, + *, + tenant_id: int, + function_name: str, + ) -> dict[str, Any] | None: + fn = cls._normalize_function_name(function_name) + target = TARGET_FUNCTION_CONTEXT.get(fn) + if target is None: + return None + + kg_service = ProjectKnowledgeGraphService(db, tenant_id=int(tenant_id)) + overview = await kg_service.get_overview() + templates = await TaskBriefService.list_templates(db, tenant_id=int(tenant_id)) + template_map = { + str(item.get("id") or ""): item + for item in templates + if isinstance(item, dict) and str(item.get("id") or "").strip() + } + configured_template_ids = [ + str(x or "").strip() + for x in target.get("template_ids", []) + if str(x or "").strip() + ] + primary_template_id = configured_template_ids[0] if configured_template_ids else "" + additional_ids = configured_template_ids[1:] if len(configured_template_ids) > 1 else [] + missing_template_ids = [tid for tid in configured_template_ids if tid not in template_map] + contract_sync = await TaskBriefService.get_contract_sync_status(db, tenant_id=int(tenant_id)) + + bundle = { + "marker": CONTEXT_BUNDLE_MARKER, + "function_name": fn, + "tier": str(target.get("tier") or ""), + "knowledge_graph": cls._kg_snapshot(overview), + "task_template": { + "primary": cls._template_snapshot( + template_map.get(primary_template_id), + include_content=True, + ), + "additional": [ + snap + for snap in ( + cls._template_snapshot(template_map.get(template_id), include_content=False) + for template_id in additional_ids + ) + if snap is not None + ], + "template_ids": configured_template_ids, + "missing_template_ids": missing_template_ids, + }, + "contract_sync": cls._contract_snapshot(contract_sync), + } + return bundle + + @classmethod + async def inject_prompt_with_bundle( + cls, + db: AsyncSession, + *, + tenant_id: int, + function_name: str, + prompt: str, + ) -> str: + raw_prompt = str(prompt or "") + if not cls.should_inject_context(function_name): + return raw_prompt + marker = f"[{CONTEXT_BUNDLE_MARKER}]" + if marker in raw_prompt: + return raw_prompt + bundle = await cls.build_context_bundle( + db, + tenant_id=int(tenant_id), + function_name=function_name, + ) + if bundle is None: + return raw_prompt + bundle_text = cls.render_bundle_marker_text(bundle) + return f"{bundle_text}\n\n{raw_prompt}".strip() + + @classmethod + async def build_capability_summary(cls, db: AsyncSession, *, tenant_id: int) -> dict[str, Any]: + kg_service = ProjectKnowledgeGraphService(db, tenant_id=int(tenant_id)) + overview = await kg_service.get_overview() + templates = await TaskBriefService.list_templates(db, tenant_id=int(tenant_id)) + template_map = { + str(item.get("id") or ""): item + for item in templates + if isinstance(item, dict) and str(item.get("id") or "").strip() + } + required_template_ids = sorted( + { + template_id + for spec in TARGET_FUNCTION_CONTEXT.values() + for template_id in spec.get("template_ids", []) + if str(template_id or "").strip() + } + ) + present_template_ids = sorted(template_map.keys()) + missing_template_ids = [ + template_id + for template_id in required_template_ids + if template_id not in template_map + ] + contract_sync_raw = await TaskBriefService.get_contract_sync_status(db, tenant_id=int(tenant_id)) + contract_sync = cls._contract_snapshot(contract_sync_raw) + + function_matrix: list[dict[str, Any]] = [] + for function_name in cls.target_functions(): + spec = TARGET_FUNCTION_CONTEXT[function_name] + routing = await ModelRoutingService.get_effective_routing( + db, + tenant_id=int(tenant_id), + function_name=function_name, + ) + function_matrix.append( + { + "function_name": function_name, + "tier": str(spec.get("tier") or ""), + "context_sections": list(CONTEXT_BUNDLE_KEYS), + "template_ids": [ + str(tid) + for tid in spec.get("template_ids", []) + if str(tid or "").strip() + ], + "model_routing": { + "provider": str(routing.get("provider") or ""), + "model": str(routing.get("model") or ""), + "tier": str(routing.get("tier") or ""), + "source": str(routing.get("source") or ""), + "api_env": str(routing.get("api_env") or ""), + }, + } + ) + + required_templates = [ + cls._template_snapshot(template_map.get(template_id), include_content=False) + or {"id": template_id} + for template_id in required_template_ids + ] + + return { + "marker": CONTEXT_BUNDLE_MARKER, + "knowledge_graph": cls._kg_snapshot(overview), + "task_templates": { + "total": len(templates), + "required_template_ids": required_template_ids, + "present_template_ids": present_template_ids, + "missing_template_ids": missing_template_ids, + "required_templates": required_templates, + }, + "contract_sync": contract_sync, + "function_model_template_matrix": function_matrix, + "coverage": { + "template_ready": len(missing_template_ids) == 0, + "contract_ready": bool(contract_sync.get("all_synced")), + "target_function_count": len(function_matrix), + }, + "generated_at": datetime.now(timezone.utc).isoformat(), + } diff --git a/backend/app/services/model_context_bundle/mixin_core.py b/backend/app/services/model_context_bundle/mixin_core.py new file mode 100644 index 00000000..34c21cdf --- /dev/null +++ b/backend/app/services/model_context_bundle/mixin_core.py @@ -0,0 +1,108 @@ +"""Core helper methods for model context bundle service.""" + +from __future__ import annotations + +from .shared import Any, CONTEXT_BUNDLE_KEYS, CONTEXT_BUNDLE_MARKER, TARGET_FUNCTION_CONTEXT, json + + +class ModelContextBundleCoreMixin: + """Owns normalization and snapshot helper methods.""" + + @staticmethod + def _normalize_function_name(function_name: str) -> str: + return str(function_name or "").strip().lower() + + @classmethod + def target_functions(cls) -> list[str]: + return list(TARGET_FUNCTION_CONTEXT.keys()) + + @classmethod + def should_inject_context(cls, function_name: str) -> bool: + fn = cls._normalize_function_name(function_name) + return fn in TARGET_FUNCTION_CONTEXT + + @staticmethod + def _template_snapshot( + template: dict[str, Any] | None, + *, + include_content: bool = True, + ) -> dict[str, Any] | None: + if not isinstance(template, dict): + return None + content = str(template.get("content") or "") + payload: dict[str, Any] = { + "id": str(template.get("id") or ""), + "name": str(template.get("name") or ""), + "description": str(template.get("description") or ""), + "updated_at": str(template.get("updated_at") or ""), + } + if include_content: + payload["content"] = content[:2400] + return payload + + @staticmethod + def _kg_snapshot(overview: dict[str, Any]) -> dict[str, Any]: + latest = overview.get("latest_sync_run") if isinstance(overview.get("latest_sync_run"), dict) else {} + return { + "entity_total": int(overview.get("entity_total") or 0), + "relation_total": int(overview.get("relation_total") or 0), + "evidence_total": int(overview.get("evidence_total") or 0), + "pending_events": int(overview.get("pending_events") or 0), + "latest_sync_run": { + "id": latest.get("id"), + "run_type": str(latest.get("run_type") or ""), + "status": str(latest.get("status") or ""), + "started_at": latest.get("started_at"), + "ended_at": latest.get("ended_at"), + } + if latest + else None, + } + + @staticmethod + def _contract_snapshot(contract_sync: dict[str, Any]) -> dict[str, Any]: + files = contract_sync.get("files") if isinstance(contract_sync.get("files"), list) else [] + normalized_files: list[dict[str, Any]] = [] + missing_files: list[str] = [] + for item in files: + if not isinstance(item, dict): + continue + exists = bool(item.get("exists")) + file_path = str(item.get("file") or "") + if file_path and not exists: + missing_files.append(file_path) + normalized_files.append( + { + "file": file_path, + "exists": exists, + "updated_at": item.get("updated_at"), + } + ) + return { + "all_synced": bool(contract_sync.get("all_synced")), + "missing_files": missing_files, + "files": normalized_files, + } + + @classmethod + def render_bundle_marker_text(cls, bundle: dict[str, Any]) -> str: + payload = dict(bundle) if isinstance(bundle, dict) else {} + marker = f"[{CONTEXT_BUNDLE_MARKER}]" + return f"{marker}\n{json.dumps(payload, ensure_ascii=False, sort_keys=True)}" + + @classmethod + def _canary_prompt_for_function(cls, *, function_name: str, probe_id: str) -> str: + fn = cls._normalize_function_name(function_name) + if fn in {"classify_intent_ai_pmo_kernel", "classify_intent_complex"}: + return ( + f"这是 PMFlow 上下文注入探针,probe_id={probe_id}。" + "请只返回 JSON:" + '{"intent":"query.read_only","confidence":0.91,"reasoning":"probe_context_ok"}' + ) + if fn == "signal_planner_analyze": + return ( + f"这是 PMFlow 上下文注入探针,probe_id={probe_id}。" + "请只返回 JSON:" + '{"summary":"probe","priority":"medium","execution_plan":[],"risks":[],"confidence":0.77}' + ) + return f"probe_id={probe_id}" diff --git a/backend/app/services/model_context_bundle/mixin_verify.py b/backend/app/services/model_context_bundle/mixin_verify.py new file mode 100644 index 00000000..2edfdfce --- /dev/null +++ b/backend/app/services/model_context_bundle/mixin_verify.py @@ -0,0 +1,170 @@ +"""Verification helpers for context bundle consumption.""" + +from __future__ import annotations + +from .shared import Any, AICallLog, AsyncSession, CONTEXT_BUNDLE_MARKER, ModelRoutingService, Optional, TARGET_FUNCTION_CONTEXT, and_, select, uuid4 + + +class ModelContextBundleVerifyMixin: + """Run canary calls and verify prompt context evidence.""" + + @classmethod + async def _trigger_canary_call( + cls, + db: AsyncSession, + *, + tenant_id: int, + function_name: str, + probe_id: str, + ) -> dict[str, Any]: + prompt = cls._canary_prompt_for_function(function_name=function_name, probe_id=probe_id) + try: + from ..llm import llm_service + + result = await llm_service.generate_structured_object( + prompt, + db=db, + tenant_id=int(tenant_id), + function_name=function_name, + use_prompt_profile=False, + temperature=0.0, + max_tokens=220, + response_mode="fast", + ) + return { + "called": True, + "error": None, + "error_code": str(result.error_code or ""), + "status": str(result.error_code or "success"), + "provider": str(result.provider or ""), + "model": str(result.model or ""), + } + except Exception as exc: + return { + "called": False, + "error": str(exc)[:500], + "error_code": "canary_call_failed", + "status": "failed", + "provider": "", + "model": "", + } + + @classmethod + async def _find_prompt_evidence( + cls, + db: AsyncSession, + *, + tenant_id: int, + function_name: str, + probe_id: Optional[str], + ) -> Optional[AICallLog]: + stmt = select(AICallLog).where( + and_( + AICallLog.tenant_id == int(tenant_id), + AICallLog.function_name == str(function_name), + AICallLog.prompt.like(f"%[{CONTEXT_BUNDLE_MARKER}]%"), + ) + ) + if probe_id: + stmt = stmt.where(AICallLog.prompt.like(f"%{probe_id}%")) + stmt = stmt.order_by(AICallLog.id.desc()).limit(1) + return (await db.execute(stmt)).scalar_one_or_none() + + @classmethod + async def verify_context_consumption( + cls, + db: AsyncSession, + *, + tenant_id: int, + run_canary: bool = True, + ) -> dict[str, Any]: + probe_id = uuid4().hex[:12] if run_canary else "" + function_results: list[dict[str, Any]] = [] + + for function_name in cls.target_functions(): + target = TARGET_FUNCTION_CONTEXT.get(function_name) or {} + canary = { + "called": False, + "status": "skipped", + "error": None, + "error_code": "", + "provider": "", + "model": "", + } + if run_canary: + canary = await cls._trigger_canary_call( + db, + tenant_id=int(tenant_id), + function_name=function_name, + probe_id=probe_id, + ) + + evidence = await cls._find_prompt_evidence( + db, + tenant_id=int(tenant_id), + function_name=function_name, + probe_id=probe_id if run_canary else None, + ) + prompt_text = str(evidence.prompt or "") if evidence is not None else "" + checks = { + "marker": f"[{CONTEXT_BUNDLE_MARKER}]" in prompt_text, + "knowledge_graph": '"knowledge_graph"' in prompt_text, + "task_template": '"task_template"' in prompt_text, + "contract_sync": '"contract_sync"' in prompt_text, + } + route = await ModelRoutingService.get_effective_routing( + db, + tenant_id=int(tenant_id), + function_name=function_name, + ) + function_results.append( + { + "function_name": function_name, + "tier": str(target.get("tier") or ""), + "template_ids": [ + str(x) + for x in target.get("template_ids", []) + if str(x or "").strip() + ], + "routing_snapshot": { + "provider": str(route.get("provider") or ""), + "model": str(route.get("model") or ""), + "tier": str(route.get("tier") or ""), + "source": str(route.get("source") or ""), + }, + "canary": canary, + "checks": checks, + "pass": all(checks.values()), + "log_evidence": ( + { + "id": int(evidence.id), + "status": str(evidence.status or ""), + "created_at": evidence.created_at.isoformat() if evidence.created_at else None, + "prompt_excerpt": prompt_text[:800], + } + if evidence is not None + else None + ), + } + ) + + tier_result: dict[str, dict[str, Any]] = { + "low": {"pass": False, "functions": []}, + "high": {"pass": False, "functions": []}, + } + for tier_name in ("low", "high"): + tier_functions = [row for row in function_results if str(row.get("tier") or "") == tier_name] + tier_result[tier_name] = { + "pass": bool(tier_functions) and all(bool(row.get("pass")) for row in tier_functions), + "functions": tier_functions, + } + + overall_pass = bool(tier_result["low"]["pass"]) and bool(tier_result["high"]["pass"]) + return { + "marker": CONTEXT_BUNDLE_MARKER, + "run_canary": bool(run_canary), + "probe_id": probe_id or None, + "overall_pass": overall_pass, + "tiers": tier_result, + "functions": function_results, + } diff --git a/backend/app/services/model_context_bundle/service.py b/backend/app/services/model_context_bundle/service.py new file mode 100644 index 00000000..61ea4e87 --- /dev/null +++ b/backend/app/services/model_context_bundle/service.py @@ -0,0 +1,17 @@ +"""Model context bundle service composition.""" + +from __future__ import annotations + +from .mixin_build import ModelContextBundleBuildMixin +from .mixin_core import ModelContextBundleCoreMixin +from .mixin_verify import ModelContextBundleVerifyMixin + + +class ModelContextBundleService( + ModelContextBundleCoreMixin, + ModelContextBundleBuildMixin, + ModelContextBundleVerifyMixin, +): + """Build and verify PMFlow model context bundles.""" + + pass diff --git a/backend/app/services/model_context_bundle/shared.py b/backend/app/services/model_context_bundle/shared.py new file mode 100644 index 00000000..da5748af --- /dev/null +++ b/backend/app/services/model_context_bundle/shared.py @@ -0,0 +1,58 @@ +"""Shared imports/constants for model context bundle service.""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Any, Optional +from uuid import uuid4 + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...models import AICallLog +from ..model_routing_service import ModelRoutingService +from ..project_knowledge_graph import ProjectKnowledgeGraphService +from ..task_brief_service import ( + HIGH_CONTRACT_TEMPLATE_ID, + HIGH_TEMPLATE_ID, + LOW_TEMPLATE_ID, + TaskBriefService, +) + +CONTEXT_BUNDLE_MARKER = "PMFLOW_CONTEXT_BUNDLE_V1" +CONTEXT_BUNDLE_KEYS = ("knowledge_graph", "task_template", "contract_sync") + +TARGET_FUNCTION_CONTEXT: dict[str, dict[str, Any]] = { + "classify_intent_ai_pmo_kernel": { + "tier": "low", + "template_ids": [LOW_TEMPLATE_ID], + }, + "signal_planner_analyze": { + "tier": "low", + "template_ids": [LOW_TEMPLATE_ID], + }, + "classify_intent_complex": { + "tier": "high", + "template_ids": [HIGH_TEMPLATE_ID, HIGH_CONTRACT_TEMPLATE_ID], + }, +} + +__all__ = [ + "AICallLog", + "Any", + "AsyncSession", + "CONTEXT_BUNDLE_KEYS", + "CONTEXT_BUNDLE_MARKER", + "ModelRoutingService", + "Optional", + "ProjectKnowledgeGraphService", + "TARGET_FUNCTION_CONTEXT", + "TaskBriefService", + "and_", + "datetime", + "json", + "select", + "timezone", + "uuid4", +] diff --git a/backend/app/services/model_context_bundle_service.py b/backend/app/services/model_context_bundle_service.py index 7403b61f..f71bbe8e 100644 --- a/backend/app/services/model_context_bundle_service.py +++ b/backend/app/services/model_context_bundle_service.py @@ -1,435 +1,15 @@ -"""Model context bundle service. +"""Compatibility exports for model context bundle service.""" -统一提供三类上下文: -1. 知识图谱摘要(KG overview) -2. 任务模板(task briefs) -3. 契约同步状态(contract sync) -""" - -from __future__ import annotations - -import json -from datetime import datetime, timezone -from typing import Any, Optional -from uuid import uuid4 - -from sqlalchemy import and_, select -from sqlalchemy.ext.asyncio import AsyncSession - -from ..models import AICallLog -from .model_routing_service import ModelRoutingService -from .project_knowledge_graph import ProjectKnowledgeGraphService -from .task_brief_service import ( - HIGH_CONTRACT_TEMPLATE_ID, - HIGH_TEMPLATE_ID, - LOW_TEMPLATE_ID, - TaskBriefService, +from .model_context_bundle import ( + CONTEXT_BUNDLE_KEYS, + CONTEXT_BUNDLE_MARKER, + TARGET_FUNCTION_CONTEXT, + ModelContextBundleService, ) -CONTEXT_BUNDLE_MARKER = "PMFLOW_CONTEXT_BUNDLE_V1" -CONTEXT_BUNDLE_KEYS = ("knowledge_graph", "task_template", "contract_sync") - -TARGET_FUNCTION_CONTEXT: dict[str, dict[str, Any]] = { - "classify_intent_ai_pmo_kernel": { - "tier": "low", - "template_ids": [LOW_TEMPLATE_ID], - }, - "signal_planner_analyze": { - "tier": "low", - "template_ids": [LOW_TEMPLATE_ID], - }, - "classify_intent_complex": { - "tier": "high", - "template_ids": [HIGH_TEMPLATE_ID, HIGH_CONTRACT_TEMPLATE_ID], - }, -} - - -class ModelContextBundleService: - @staticmethod - def _normalize_function_name(function_name: str) -> str: - return str(function_name or "").strip().lower() - - @classmethod - def target_functions(cls) -> list[str]: - return list(TARGET_FUNCTION_CONTEXT.keys()) - - @classmethod - def should_inject_context(cls, function_name: str) -> bool: - fn = cls._normalize_function_name(function_name) - return fn in TARGET_FUNCTION_CONTEXT - - @staticmethod - def _template_snapshot(template: Optional[dict[str, Any]], *, include_content: bool = True) -> Optional[dict[str, Any]]: - if not isinstance(template, dict): - return None - content = str(template.get("content") or "") - payload: dict[str, Any] = { - "id": str(template.get("id") or ""), - "name": str(template.get("name") or ""), - "description": str(template.get("description") or ""), - "updated_at": str(template.get("updated_at") or ""), - } - if include_content: - payload["content"] = content[:2400] - return payload - - @staticmethod - def _kg_snapshot(overview: dict[str, Any]) -> dict[str, Any]: - latest = overview.get("latest_sync_run") if isinstance(overview.get("latest_sync_run"), dict) else {} - return { - "entity_total": int(overview.get("entity_total") or 0), - "relation_total": int(overview.get("relation_total") or 0), - "evidence_total": int(overview.get("evidence_total") or 0), - "pending_events": int(overview.get("pending_events") or 0), - "latest_sync_run": { - "id": latest.get("id"), - "run_type": str(latest.get("run_type") or ""), - "status": str(latest.get("status") or ""), - "started_at": latest.get("started_at"), - "ended_at": latest.get("ended_at"), - } - if latest - else None, - } - - @staticmethod - def _contract_snapshot(contract_sync: dict[str, Any]) -> dict[str, Any]: - files = contract_sync.get("files") if isinstance(contract_sync.get("files"), list) else [] - normalized_files: list[dict[str, Any]] = [] - missing_files: list[str] = [] - for item in files: - if not isinstance(item, dict): - continue - exists = bool(item.get("exists")) - file_path = str(item.get("file") or "") - if file_path and not exists: - missing_files.append(file_path) - normalized_files.append( - { - "file": file_path, - "exists": exists, - "updated_at": item.get("updated_at"), - } - ) - return { - "all_synced": bool(contract_sync.get("all_synced")), - "missing_files": missing_files, - "files": normalized_files, - } - - @classmethod - async def build_context_bundle( - cls, - db: AsyncSession, - *, - tenant_id: int, - function_name: str, - ) -> Optional[dict[str, Any]]: - fn = cls._normalize_function_name(function_name) - target = TARGET_FUNCTION_CONTEXT.get(fn) - if target is None: - return None - - kg_service = ProjectKnowledgeGraphService(db, tenant_id=int(tenant_id)) - overview = await kg_service.get_overview() - templates = await TaskBriefService.list_templates(db, tenant_id=int(tenant_id)) - template_map = { - str(item.get("id") or ""): item - for item in templates - if isinstance(item, dict) and str(item.get("id") or "").strip() - } - configured_template_ids = [str(x or "").strip() for x in target.get("template_ids", []) if str(x or "").strip()] - primary_template_id = configured_template_ids[0] if configured_template_ids else "" - additional_ids = configured_template_ids[1:] if len(configured_template_ids) > 1 else [] - missing_template_ids = [tid for tid in configured_template_ids if tid not in template_map] - contract_sync = await TaskBriefService.get_contract_sync_status(db, tenant_id=int(tenant_id)) - - bundle = { - "marker": CONTEXT_BUNDLE_MARKER, - "function_name": fn, - "tier": str(target.get("tier") or ""), - "knowledge_graph": cls._kg_snapshot(overview), - "task_template": { - "primary": cls._template_snapshot(template_map.get(primary_template_id), include_content=True), - "additional": [ - snap - for snap in ( - cls._template_snapshot(template_map.get(template_id), include_content=False) - for template_id in additional_ids - ) - if snap is not None - ], - "template_ids": configured_template_ids, - "missing_template_ids": missing_template_ids, - }, - "contract_sync": cls._contract_snapshot(contract_sync), - } - return bundle - - @classmethod - def render_bundle_marker_text(cls, bundle: dict[str, Any]) -> str: - payload = dict(bundle) if isinstance(bundle, dict) else {} - marker = f"[{CONTEXT_BUNDLE_MARKER}]" - return f"{marker}\n{json.dumps(payload, ensure_ascii=False, sort_keys=True)}" - - @classmethod - async def inject_prompt_with_bundle( - cls, - db: AsyncSession, - *, - tenant_id: int, - function_name: str, - prompt: str, - ) -> str: - raw_prompt = str(prompt or "") - if not cls.should_inject_context(function_name): - return raw_prompt - marker = f"[{CONTEXT_BUNDLE_MARKER}]" - if marker in raw_prompt: - return raw_prompt - bundle = await cls.build_context_bundle( - db, - tenant_id=int(tenant_id), - function_name=function_name, - ) - if bundle is None: - return raw_prompt - bundle_text = cls.render_bundle_marker_text(bundle) - return f"{bundle_text}\n\n{raw_prompt}".strip() - - @classmethod - async def build_capability_summary(cls, db: AsyncSession, *, tenant_id: int) -> dict[str, Any]: - kg_service = ProjectKnowledgeGraphService(db, tenant_id=int(tenant_id)) - overview = await kg_service.get_overview() - templates = await TaskBriefService.list_templates(db, tenant_id=int(tenant_id)) - template_map = { - str(item.get("id") or ""): item - for item in templates - if isinstance(item, dict) and str(item.get("id") or "").strip() - } - required_template_ids = sorted( - { - template_id - for spec in TARGET_FUNCTION_CONTEXT.values() - for template_id in spec.get("template_ids", []) - if str(template_id or "").strip() - } - ) - present_template_ids = sorted(template_map.keys()) - missing_template_ids = [template_id for template_id in required_template_ids if template_id not in template_map] - contract_sync_raw = await TaskBriefService.get_contract_sync_status(db, tenant_id=int(tenant_id)) - contract_sync = cls._contract_snapshot(contract_sync_raw) - - function_matrix: list[dict[str, Any]] = [] - for function_name in cls.target_functions(): - spec = TARGET_FUNCTION_CONTEXT[function_name] - routing = await ModelRoutingService.get_effective_routing( - db, - tenant_id=int(tenant_id), - function_name=function_name, - ) - function_matrix.append( - { - "function_name": function_name, - "tier": str(spec.get("tier") or ""), - "context_sections": list(CONTEXT_BUNDLE_KEYS), - "template_ids": [str(tid) for tid in spec.get("template_ids", []) if str(tid or "").strip()], - "model_routing": { - "provider": str(routing.get("provider") or ""), - "model": str(routing.get("model") or ""), - "tier": str(routing.get("tier") or ""), - "source": str(routing.get("source") or ""), - "api_env": str(routing.get("api_env") or ""), - }, - } - ) - - required_templates = [ - cls._template_snapshot(template_map.get(template_id), include_content=False) or {"id": template_id} - for template_id in required_template_ids - ] - - return { - "marker": CONTEXT_BUNDLE_MARKER, - "knowledge_graph": cls._kg_snapshot(overview), - "task_templates": { - "total": len(templates), - "required_template_ids": required_template_ids, - "present_template_ids": present_template_ids, - "missing_template_ids": missing_template_ids, - "required_templates": required_templates, - }, - "contract_sync": contract_sync, - "function_model_template_matrix": function_matrix, - "coverage": { - "template_ready": len(missing_template_ids) == 0, - "contract_ready": bool(contract_sync.get("all_synced")), - "target_function_count": len(function_matrix), - }, - "generated_at": datetime.now(timezone.utc).isoformat(), - } - - @classmethod - def _canary_prompt_for_function(cls, *, function_name: str, probe_id: str) -> str: - fn = cls._normalize_function_name(function_name) - if fn in {"classify_intent_ai_pmo_kernel", "classify_intent_complex"}: - return ( - f"这是 PMFlow 上下文注入探针,probe_id={probe_id}。" - "请只返回 JSON:" - '{"intent":"query.read_only","confidence":0.91,"reasoning":"probe_context_ok"}' - ) - if fn == "signal_planner_analyze": - return ( - f"这是 PMFlow 上下文注入探针,probe_id={probe_id}。" - "请只返回 JSON:" - '{"summary":"probe","priority":"medium","execution_plan":[],"risks":[],"confidence":0.77}' - ) - return f"probe_id={probe_id}" - - @classmethod - async def _trigger_canary_call( - cls, - db: AsyncSession, - *, - tenant_id: int, - function_name: str, - probe_id: str, - ) -> dict[str, Any]: - prompt = cls._canary_prompt_for_function(function_name=function_name, probe_id=probe_id) - try: - from .llm import llm_service # 延迟导入,避免循环依赖 - - result = await llm_service.generate_structured_object( - prompt, - db=db, - tenant_id=int(tenant_id), - function_name=function_name, - use_prompt_profile=False, - temperature=0.0, - max_tokens=220, - response_mode="fast", - ) - return { - "called": True, - "error": None, - "error_code": str(result.error_code or ""), - "status": str(result.error_code or "success"), - "provider": str(result.provider or ""), - "model": str(result.model or ""), - } - except Exception as exc: - return { - "called": False, - "error": str(exc)[:500], - "error_code": "canary_call_failed", - "status": "failed", - "provider": "", - "model": "", - } - - @classmethod - async def _find_prompt_evidence( - cls, - db: AsyncSession, - *, - tenant_id: int, - function_name: str, - probe_id: Optional[str], - ) -> Optional[AICallLog]: - stmt = select(AICallLog).where( - and_( - AICallLog.tenant_id == int(tenant_id), - AICallLog.function_name == str(function_name), - AICallLog.prompt.like(f"%[{CONTEXT_BUNDLE_MARKER}]%"), - ) - ) - if probe_id: - stmt = stmt.where(AICallLog.prompt.like(f"%{probe_id}%")) - stmt = stmt.order_by(AICallLog.id.desc()).limit(1) - return (await db.execute(stmt)).scalar_one_or_none() - - @classmethod - async def verify_context_consumption( - cls, - db: AsyncSession, - *, - tenant_id: int, - run_canary: bool = True, - ) -> dict[str, Any]: - probe_id = uuid4().hex[:12] if run_canary else "" - function_results: list[dict[str, Any]] = [] - - for function_name in cls.target_functions(): - target = TARGET_FUNCTION_CONTEXT.get(function_name) or {} - canary = {"called": False, "status": "skipped", "error": None, "error_code": "", "provider": "", "model": ""} - if run_canary: - canary = await cls._trigger_canary_call( - db, - tenant_id=int(tenant_id), - function_name=function_name, - probe_id=probe_id, - ) - - evidence = await cls._find_prompt_evidence( - db, - tenant_id=int(tenant_id), - function_name=function_name, - probe_id=probe_id if run_canary else None, - ) - prompt_text = str(evidence.prompt or "") if evidence is not None else "" - checks = { - "marker": f"[{CONTEXT_BUNDLE_MARKER}]" in prompt_text, - "knowledge_graph": '"knowledge_graph"' in prompt_text, - "task_template": '"task_template"' in prompt_text, - "contract_sync": '"contract_sync"' in prompt_text, - } - route = await ModelRoutingService.get_effective_routing( - db, - tenant_id=int(tenant_id), - function_name=function_name, - ) - function_results.append( - { - "function_name": function_name, - "tier": str(target.get("tier") or ""), - "template_ids": [str(x) for x in target.get("template_ids", []) if str(x or "").strip()], - "routing_snapshot": { - "provider": str(route.get("provider") or ""), - "model": str(route.get("model") or ""), - "tier": str(route.get("tier") or ""), - "source": str(route.get("source") or ""), - }, - "canary": canary, - "checks": checks, - "pass": all(checks.values()), - "log_evidence": ( - { - "id": int(evidence.id), - "status": str(evidence.status or ""), - "created_at": evidence.created_at.isoformat() if evidence.created_at else None, - "prompt_excerpt": prompt_text[:800], - } - if evidence is not None - else None - ), - } - ) - - tier_result: dict[str, dict[str, Any]] = {"low": {"pass": False, "functions": []}, "high": {"pass": False, "functions": []}} - for tier_name in ("low", "high"): - tier_functions = [row for row in function_results if str(row.get("tier") or "") == tier_name] - tier_result[tier_name] = { - "pass": bool(tier_functions) and all(bool(row.get("pass")) for row in tier_functions), - "functions": tier_functions, - } - - overall_pass = bool(tier_result["low"]["pass"]) and bool(tier_result["high"]["pass"]) - return { - "marker": CONTEXT_BUNDLE_MARKER, - "run_canary": bool(run_canary), - "probe_id": probe_id or None, - "overall_pass": overall_pass, - "tiers": tier_result, - "functions": function_results, - } +__all__ = [ + "CONTEXT_BUNDLE_MARKER", + "CONTEXT_BUNDLE_KEYS", + "TARGET_FUNCTION_CONTEXT", + "ModelContextBundleService", +] diff --git a/backend/app/services/project_knowledge_graph.py b/backend/app/services/project_knowledge_graph.py index 44c2d32c..f9e8f2df 100644 --- a/backend/app/services/project_knowledge_graph.py +++ b/backend/app/services/project_knowledge_graph.py @@ -1,2076 +1,5 @@ -"""Project Knowledge Graph service. - -第一阶段目标: -1. 构建统一实体/关系/证据写入能力 -2. 支持结构化数据(project/task/signal/user)同步 -3. 支持代码目录扫描快照 -4. 提供图谱概览统计 -""" - from __future__ import annotations -import hashlib -import json -import os -from datetime import datetime, timedelta, timezone -from pathlib import Path -from typing import Any, Optional +from .project_knowledge_graph_service import ProjectKnowledgeGraphService -from sqlalchemy import and_, func, select -from sqlalchemy.ext.asyncio import AsyncSession - -from ..models import ( - IngestEvent, - IngestOffset, - KGEvidence, - KGEntity, - KGRelation, - KGSyncRun, - Project, - FeishuItem, - Signal, - SymbolDefinition, - SymbolReference, - Task, - User, -) -from .context_cache import context_cache_service -from .symbol_index_service import SymbolIndexService - - -from ..utils.timezone import now_cn_naive -def _utc_now_naive() -> datetime: - return now_cn_naive() - - -DDL_BLOCKED_PREFIXES = [ - "CREATE", - "ALTER", - "DROP", - "TRUNCATE", - "RENAME", - "COMMENT", - "GRANT", - "REVOKE", -] -DDL_GUARDRAIL_POLICY_KEY = "policy:ddl_via_interface_only" -DDL_GUARDRAIL_POLICY_NAME = "DDL 只能通过受控接口/迁移流程" - - -class ProjectKnowledgeGraphService: - def __init__(self, db: AsyncSession, *, tenant_id: int) -> None: - self.db = db - self.tenant_id = int(tenant_id) - - async def upsert_entity( - self, - *, - entity_type: str, - entity_key: str, - display_name: str, - payload: Optional[dict[str, Any]] = None, - source_type: str = "system", - source_ref: Optional[str] = None, - ) -> KGEntity: - row = ( - await self.db.execute( - select(KGEntity).where( - and_( - KGEntity.tenant_id == self.tenant_id, - KGEntity.entity_type == str(entity_type).strip()[:40], - KGEntity.entity_key == str(entity_key).strip()[:255], - ) - ) - ) - ).scalar_one_or_none() - - if row is None: - row = KGEntity( - tenant_id=self.tenant_id, - entity_type=str(entity_type).strip()[:40], - entity_key=str(entity_key).strip()[:255], - display_name=str(display_name or entity_key).strip()[:255], - payload=payload or {}, - source_type=str(source_type or "system").strip()[:32], - source_ref=(str(source_ref).strip()[:255] if source_ref else None), - last_seen_at=_utc_now_naive(), - ) - self.db.add(row) - await self.db.flush() - return row - - row.display_name = str(display_name or row.display_name).strip()[:255] - row.payload = payload or {} - row.source_type = str(source_type or row.source_type or "system").strip()[:32] - row.source_ref = str(source_ref).strip()[:255] if source_ref else row.source_ref - row.last_seen_at = _utc_now_naive() - await self.db.flush() - return row - - async def upsert_relation( - self, - *, - relation_type: str, - subject_entity_id: int, - object_entity_id: int, - weight: float = 1.0, - payload: Optional[dict[str, Any]] = None, - source_type: str = "system", - source_ref: Optional[str] = None, - ) -> KGRelation: - row = ( - await self.db.execute( - select(KGRelation).where( - and_( - KGRelation.tenant_id == self.tenant_id, - KGRelation.relation_type == str(relation_type).strip()[:40], - KGRelation.subject_entity_id == int(subject_entity_id), - KGRelation.object_entity_id == int(object_entity_id), - ) - ) - ) - ).scalar_one_or_none() - - if row is None: - row = KGRelation( - tenant_id=self.tenant_id, - relation_type=str(relation_type).strip()[:40], - subject_entity_id=int(subject_entity_id), - object_entity_id=int(object_entity_id), - weight=float(weight), - payload=payload or {}, - source_type=str(source_type or "system").strip()[:32], - source_ref=(str(source_ref).strip()[:255] if source_ref else None), - ) - self.db.add(row) - await self.db.flush() - return row - - row.weight = float(weight) - row.payload = payload or {} - row.source_type = str(source_type or row.source_type or "system").strip()[:32] - row.source_ref = str(source_ref).strip()[:255] if source_ref else row.source_ref - await self.db.flush() - return row - - async def add_evidence( - self, - *, - evidence_type: str, - content_excerpt: str, - entity_id: Optional[int] = None, - relation_id: Optional[int] = None, - source_type: str = "system", - source_ref: Optional[str] = None, - metadata: Optional[dict[str, Any]] = None, - ) -> KGEvidence: - clean = str(content_excerpt or "").strip() - digest = hashlib.md5(clean.encode("utf-8")).hexdigest() - - row = KGEvidence( - tenant_id=self.tenant_id, - entity_id=int(entity_id) if entity_id is not None else None, - relation_id=int(relation_id) if relation_id is not None else None, - evidence_type=str(evidence_type or "snapshot").strip()[:40], - content_hash=digest, - content_excerpt=clean[:4000] if clean else "(empty)", - source_type=str(source_type or "system").strip()[:32], - source_ref=(str(source_ref).strip()[:255] if source_ref else None), - meta_payload=metadata or {}, - occurred_at=_utc_now_naive(), - ) - self.db.add(row) - await self.db.flush() - return row - - async def enqueue_ingest_event( - self, - *, - channel: str, - event_key: str, - payload: Optional[dict[str, Any]] = None, - status: str = "pending", - ) -> IngestEvent: - row = ( - await self.db.execute( - select(IngestEvent).where( - and_( - IngestEvent.tenant_id == self.tenant_id, - IngestEvent.channel == str(channel).strip()[:32], - IngestEvent.event_key == str(event_key).strip()[:128], - ) - ) - ) - ).scalar_one_or_none() - if row is None: - row = IngestEvent( - tenant_id=self.tenant_id, - channel=str(channel).strip()[:32], - event_key=str(event_key).strip()[:128], - status=str(status or "pending").strip()[:20], - payload=payload or {}, - ) - self.db.add(row) - else: - row.status = str(status or row.status or "pending").strip()[:20] - row.payload = payload or row.payload or {} - row.error_message = None - await self.db.flush() - return row - - async def set_ingest_offset( - self, - *, - channel: str, - offset_key: str, - offset_value: str, - payload: Optional[dict[str, Any]] = None, - ) -> IngestOffset: - row = ( - await self.db.execute( - select(IngestOffset).where( - and_( - IngestOffset.tenant_id == self.tenant_id, - IngestOffset.channel == str(channel).strip()[:32], - IngestOffset.offset_key == str(offset_key).strip()[:128], - ) - ) - ) - ).scalar_one_or_none() - - if row is None: - row = IngestOffset( - tenant_id=self.tenant_id, - channel=str(channel).strip()[:32], - offset_key=str(offset_key).strip()[:128], - offset_value=str(offset_value or "").strip()[:255], - payload=payload or {}, - ) - self.db.add(row) - else: - row.offset_value = str(offset_value or row.offset_value).strip()[:255] - row.payload = payload or row.payload or {} - await self.db.flush() - return row - - async def get_ingest_offset(self, *, channel: str, offset_key: str) -> Optional[str]: - row = ( - await self.db.execute( - select(IngestOffset).where( - and_( - IngestOffset.tenant_id == self.tenant_id, - IngestOffset.channel == str(channel).strip()[:32], - IngestOffset.offset_key == str(offset_key).strip()[:128], - ) - ) - ) - ).scalar_one_or_none() - if row is None: - return None - return str(row.offset_value or "").strip() or None - - @staticmethod - def _parse_iso_dt(value: Optional[str]) -> Optional[datetime]: - raw = str(value or "").strip() - if not raw: - return None - try: - dt = datetime.fromisoformat(raw) - return dt.replace(tzinfo=None) if dt.tzinfo else dt - except Exception: - return None - - async def _open_sync_run( - self, - *, - run_type: str, - trigger_source: str, - input_payload: Optional[dict[str, Any]] = None, - ) -> KGSyncRun: - now = _utc_now_naive() - row = KGSyncRun( - tenant_id=self.tenant_id, - run_type=str(run_type or "incremental").strip()[:40], - trigger_source=str(trigger_source or "system").strip()[:40], - status="running", - started_at=now, - input_payload=input_payload or {}, - result_payload={}, - ) - self.db.add(row) - await self.db.flush() - await self.db.commit() - await self.db.refresh(row) - return row - - async def _close_sync_run( - self, - *, - row: Optional[KGSyncRun], - status: str, - result_payload: Optional[dict[str, Any]] = None, - error_message: Optional[str] = None, - started_at_hint: Optional[datetime] = None, - ) -> None: - if row is None: - return - now = _utc_now_naive() - started = started_at_hint or row.started_at or now - row.status = str(status or "unknown").strip()[:20] - row.ended_at = now - row.duration_ms = max(int((now - started).total_seconds() * 1000), 0) - row.result_payload = result_payload or {} - row.error_message = (str(error_message).strip()[:2000] if error_message else None) - await self.db.commit() - - async def list_sync_runs( - self, - *, - limit: int = 50, - run_type: Optional[str] = None, - status: Optional[str] = None, - ) -> dict[str, Any]: - max_rows = max(1, min(int(limit), 200)) - stmt = select(KGSyncRun).where(KGSyncRun.tenant_id == self.tenant_id) - if run_type: - stmt = stmt.where(KGSyncRun.run_type == str(run_type).strip()[:40]) - if status: - stmt = stmt.where(KGSyncRun.status == str(status).strip()[:20]) - rows = ( - await self.db.execute(stmt.order_by(KGSyncRun.started_at.desc(), KGSyncRun.id.desc()).limit(max_rows)) - ).scalars().all() - - items = [] - for row in rows: - items.append( - { - "id": int(row.id), - "run_type": str(row.run_type or ""), - "trigger_source": str(row.trigger_source or ""), - "status": str(row.status or ""), - "started_at": row.started_at.isoformat() if row.started_at else None, - "ended_at": row.ended_at.isoformat() if row.ended_at else None, - "duration_ms": int(row.duration_ms or 0), - "input_payload": row.input_payload or {}, - "result_payload": row.result_payload or {}, - "error_message": str(row.error_message or ""), - } - ) - return { - "tenant_id": self.tenant_id, - "count": len(items), - "items": items, - } - - async def get_sync_slo(self, *, lookback_hours: int = 24) -> dict[str, Any]: - hours = max(1, min(int(lookback_hours), 24 * 30)) - now = _utc_now_naive() - since = now - timedelta(hours=hours) - - runs = ( - await self.db.execute( - select(KGSyncRun).where( - and_( - KGSyncRun.tenant_id == self.tenant_id, - KGSyncRun.started_at >= since, - KGSyncRun.run_type == "incremental", - ) - ) - ) - ).scalars().all() - - total = len(runs) - success = sum(1 for x in runs if str(x.status or "") == "success") - partial = sum(1 for x in runs if str(x.status or "") == "partial") - failed = sum(1 for x in runs if str(x.status or "") == "failed") - avg_duration_ms = ( - int(sum(int(x.duration_ms or 0) for x in runs) / total) - if total > 0 - else 0 - ) - - latest_success = ( - await self.db.execute( - select(KGSyncRun) - .where( - and_( - KGSyncRun.tenant_id == self.tenant_id, - KGSyncRun.run_type == "incremental", - KGSyncRun.status == "success", - ) - ) - .order_by(KGSyncRun.started_at.desc(), KGSyncRun.id.desc()) - .limit(1) - ) - ).scalar_one_or_none() - - dead_letter = ( - await self.db.execute( - select(func.count(IngestEvent.id)).where( - and_( - IngestEvent.tenant_id == self.tenant_id, - IngestEvent.status == "dead_letter", - ) - ) - ) - ).scalar_one() - pending_events = ( - await self.db.execute( - select(func.count(IngestEvent.id)).where( - and_( - IngestEvent.tenant_id == self.tenant_id, - IngestEvent.status.in_(["pending", "retry"]), - ) - ) - ) - ).scalar_one() - - async def _freshness_minutes(channel: str, offset_key: str) -> Optional[int]: - raw = await self.get_ingest_offset(channel=channel, offset_key=offset_key) - dt = self._parse_iso_dt(raw) - if dt is None: - return None - return max(int((now - dt).total_seconds() // 60), 0) - - return { - "tenant_id": self.tenant_id, - "lookback_hours": hours, - "window_started_at": since.isoformat(), - "window_ended_at": now.isoformat(), - "runs_total": total, - "runs_success": success, - "runs_partial": partial, - "runs_failed": failed, - "success_rate": round((success / total), 4) if total > 0 else 0.0, - "avg_duration_ms": avg_duration_ms, - "latest_success_at": latest_success.started_at.isoformat() if latest_success and latest_success.started_at else None, - "dead_letter_events": int(dead_letter or 0), - "pending_events": int(pending_events or 0), - "freshness_minutes": { - "db_structured": await _freshness_minutes("db_delta", "last_structured_sync_at"), - "feishu_delta": await _freshness_minutes("feishu_delta", "last_feishu_item_updated_at"), - "code_scan": await _freshness_minutes("code_scan", "last_code_scan_at"), - "symbol_scan": await _freshness_minutes("symbol_scan", "last_symbol_scan_at"), - "feature_catalog": await _freshness_minutes("feature_catalog", "last_feature_catalog_sync_at"), - }, - } - - async def sync_structured_delta(self, *, limit: int = 1000) -> dict[str, Any]: - max_rows = max(1, min(int(limit), 5000)) - last_raw = await self.get_ingest_offset(channel="db_delta", offset_key="last_structured_sync_at") - last_dt = self._parse_iso_dt(last_raw) or (datetime(1970, 1, 1)) - counters = { - "projects": 0, - "tasks": 0, - "signals": 0, - "users": 0, - "relations": 0, - "evidences": 0, - "from_offset": last_dt.isoformat(), - } - newest_seen = last_dt - - project_rows = ( - await self.db.execute( - select(Project) - .where( - and_( - Project.tenant_id == self.tenant_id, - Project.updated_at >= last_dt, - ) - ) - .order_by(Project.updated_at.asc(), Project.id.asc()) - .limit(max_rows) - ) - ).scalars().all() - project_map: dict[int, int] = {} - for p in project_rows: - entity = await self.upsert_entity( - entity_type="project", - entity_key=f"project:{int(p.id)}", - display_name=str(p.title or p.name or f"Project#{int(p.id)}"), - payload={ - "project_id": int(p.id), - "status": str(p.status or "unknown"), - "owner_id": int(p.owner_id) if p.owner_id is not None else None, - }, - source_type="db", - source_ref=f"projects:{int(p.id)}", - ) - project_map[int(p.id)] = int(entity.id) - counters["projects"] += 1 - if p.updated_at and p.updated_at > newest_seen: - newest_seen = p.updated_at - - user_rows = ( - await self.db.execute( - select(User) - .where( - and_( - User.tenant_id == self.tenant_id, - User.updated_at >= last_dt, - ) - ) - .order_by(User.updated_at.asc(), User.id.asc()) - .limit(max_rows) - ) - ).scalars().all() - user_map: dict[int, int] = {} - for u in user_rows: - entity = await self.upsert_entity( - entity_type="user", - entity_key=f"user:{int(u.id)}", - display_name=str(u.name or f"User#{int(u.id)}"), - payload={ - "user_id": int(u.id), - "role": str(u.role or "unknown"), - "is_active": bool(u.is_active), - }, - source_type="db", - source_ref=f"users:{int(u.id)}", - ) - user_map[int(u.id)] = int(entity.id) - counters["users"] += 1 - if u.updated_at and u.updated_at > newest_seen: - newest_seen = u.updated_at - - task_rows = ( - await self.db.execute( - select(Task) - .where( - and_( - Task.tenant_id == self.tenant_id, - Task.updated_at >= last_dt, - ) - ) - .order_by(Task.updated_at.asc(), Task.id.asc()) - .limit(max_rows) - ) - ).scalars().all() - for t in task_rows: - task_entity = await self.upsert_entity( - entity_type="task", - entity_key=f"task:{int(t.id)}", - display_name=str(t.title or f"Task#{int(t.id)}"), - payload={ - "task_id": int(t.id), - "project_id": int(t.project_id) if t.project_id is not None else None, - "assignee_id": int(t.assignee_id) if t.assignee_id is not None else None, - "status": str(t.status or "unknown"), - }, - source_type="db", - source_ref=f"tasks:{int(t.id)}", - ) - counters["tasks"] += 1 - if t.updated_at and t.updated_at > newest_seen: - newest_seen = t.updated_at - - if t.project_id is not None: - target_pid = int(t.project_id) - if target_pid not in project_map: - p = ( - await self.db.execute( - select(Project).where( - and_( - Project.tenant_id == self.tenant_id, - Project.id == target_pid, - ) - ) - ) - ).scalar_one_or_none() - if p is not None: - p_entity = await self.upsert_entity( - entity_type="project", - entity_key=f"project:{int(p.id)}", - display_name=str(p.title or p.name or f"Project#{int(p.id)}"), - payload={ - "project_id": int(p.id), - "status": str(p.status or "unknown"), - "owner_id": int(p.owner_id) if p.owner_id is not None else None, - }, - source_type="db", - source_ref=f"projects:{int(p.id)}", - ) - project_map[int(p.id)] = int(p_entity.id) - if target_pid in project_map: - rel = await self.upsert_relation( - relation_type="belongs_to", - subject_entity_id=int(task_entity.id), - object_entity_id=int(project_map[target_pid]), - source_type="db", - source_ref=f"tasks:{int(t.id)}", - ) - counters["relations"] += 1 - await self.add_evidence( - evidence_type="db_delta", - content_excerpt=f"task#{int(t.id)} belongs_to project#{target_pid}", - relation_id=int(rel.id), - source_type="db", - source_ref=f"tasks:{int(t.id)}", - metadata={"table": "tasks"}, - ) - counters["evidences"] += 1 - - if t.assignee_id is not None: - uid = int(t.assignee_id) - if uid not in user_map: - u = ( - await self.db.execute( - select(User).where(and_(User.tenant_id == self.tenant_id, User.id == uid)) - ) - ).scalar_one_or_none() - if u is not None: - u_entity = await self.upsert_entity( - entity_type="user", - entity_key=f"user:{uid}", - display_name=str(u.name or f"User#{uid}"), - payload={ - "user_id": uid, - "role": str(u.role or "unknown"), - "is_active": bool(u.is_active), - }, - source_type="db", - source_ref=f"users:{uid}", - ) - user_map[uid] = int(u_entity.id) - if uid in user_map: - rel = await self.upsert_relation( - relation_type="assigned_to", - subject_entity_id=int(task_entity.id), - object_entity_id=int(user_map[uid]), - source_type="db", - source_ref=f"tasks:{int(t.id)}", - ) - counters["relations"] += 1 - await self.add_evidence( - evidence_type="db_delta", - content_excerpt=f"task#{int(t.id)} assigned_to user#{uid}", - relation_id=int(rel.id), - source_type="db", - source_ref=f"tasks:{int(t.id)}", - metadata={"table": "tasks"}, - ) - counters["evidences"] += 1 - - signal_rows = ( - await self.db.execute( - select(Signal) - .where( - and_( - Signal.tenant_id == self.tenant_id, - Signal.updated_at >= last_dt, - ) - ) - .order_by(Signal.updated_at.asc(), Signal.id.asc()) - .limit(max_rows) - ) - ).scalars().all() - for s in signal_rows: - signal_entity = await self.upsert_entity( - entity_type="signal", - entity_key=f"signal:{int(s.id)}", - display_name=str(s.title or s.content or f"Signal#{int(s.id)}")[:255], - payload={ - "signal_id": int(s.id), - "priority": str(s.priority or ""), - "status": str(s.status or ""), - "matched_project_id": ( - int(s.matched_project_id) if getattr(s, "matched_project_id", None) is not None else None - ), - }, - source_type="db", - source_ref=f"signals:{int(s.id)}", - ) - counters["signals"] += 1 - if s.updated_at and s.updated_at > newest_seen: - newest_seen = s.updated_at - - pid = int(s.matched_project_id) if getattr(s, "matched_project_id", None) is not None else None - if pid is not None: - if pid not in project_map: - p = ( - await self.db.execute( - select(Project).where(and_(Project.tenant_id == self.tenant_id, Project.id == pid)) - ) - ).scalar_one_or_none() - if p is not None: - p_entity = await self.upsert_entity( - entity_type="project", - entity_key=f"project:{pid}", - display_name=str(p.title or p.name or f"Project#{pid}"), - payload={ - "project_id": pid, - "status": str(p.status or "unknown"), - "owner_id": int(p.owner_id) if p.owner_id is not None else None, - }, - source_type="db", - source_ref=f"projects:{pid}", - ) - project_map[pid] = int(p_entity.id) - if pid in project_map: - rel = await self.upsert_relation( - relation_type="matches_project", - subject_entity_id=int(signal_entity.id), - object_entity_id=int(project_map[pid]), - source_type="db", - source_ref=f"signals:{int(s.id)}", - ) - counters["relations"] += 1 - await self.add_evidence( - evidence_type="db_delta", - content_excerpt=f"signal#{int(s.id)} matches project#{pid}", - relation_id=int(rel.id), - source_type="db", - source_ref=f"signals:{int(s.id)}", - metadata={"table": "signals"}, - ) - counters["evidences"] += 1 - - await self.enqueue_ingest_event( - channel="db_delta", - event_key=f"db_delta:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", - payload=counters, - status="done", - ) - await self.set_ingest_offset( - channel="db_delta", - offset_key="last_structured_sync_at", - offset_value=newest_seen.isoformat(), - payload={"counters": counters}, - ) - await self.db.commit() - counters["to_offset"] = newest_seen.isoformat() - return counters - - async def enqueue_feishu_delta_events(self, *, limit: int = 500) -> dict[str, Any]: - max_rows = max(1, min(int(limit), 2000)) - last_raw = await self.get_ingest_offset(channel="feishu_delta", offset_key="last_feishu_item_updated_at") - last_dt = self._parse_iso_dt(last_raw) or (datetime(1970, 1, 1)) - - rows = ( - await self.db.execute( - select(FeishuItem) - .where( - and_( - FeishuItem.tenant_id == self.tenant_id, - FeishuItem.updated_at >= last_dt, - ) - ) - .order_by(FeishuItem.updated_at.asc(), FeishuItem.id.asc()) - .limit(max_rows) - ) - ).scalars().all() - enqueued = 0 - newest_seen = last_dt - for item in rows: - event_key = str(item.feishu_message_id or f"feishu_item:{int(item.id)}") - await self.enqueue_ingest_event( - channel="feishu_delta", - event_key=event_key[:128], - payload={ - "feishu_item_id": int(item.id), - "matched_project_id": int(item.matched_project_id) if item.matched_project_id is not None else None, - "level": str(item.level or ""), - "route_status": str(item.route_status or ""), - }, - status="pending", - ) - enqueued += 1 - if item.updated_at and item.updated_at > newest_seen: - newest_seen = item.updated_at - - await self.set_ingest_offset( - channel="feishu_delta", - offset_key="last_feishu_item_updated_at", - offset_value=newest_seen.isoformat(), - payload={"batch_size": len(rows)}, - ) - await self.db.commit() - return { - "enqueued": enqueued, - "from_offset": last_dt.isoformat(), - "to_offset": newest_seen.isoformat(), - "candidate_count": len(rows), - } - - async def process_pending_events(self, *, max_batch: int = 200, max_retry: int = 3) -> dict[str, Any]: - batch = max(1, min(int(max_batch), 1000)) - retries = max(0, min(int(max_retry), 10)) - - rows = ( - await self.db.execute( - select(IngestEvent) - .where( - and_( - IngestEvent.tenant_id == self.tenant_id, - IngestEvent.status.in_(["pending", "retry"]), - ) - ) - .order_by(IngestEvent.created_at.asc(), IngestEvent.id.asc()) - .limit(batch) - ) - ).scalars().all() - - stats = {"processed": 0, "done": 0, "retry": 0, "dead_letter": 0} - for ev in rows: - stats["processed"] += 1 - try: - if str(ev.channel) == "feishu_delta": - await self._process_feishu_event(ev) - ev.status = "done" - ev.error_message = None - ev.processed_at = _utc_now_naive() - stats["done"] += 1 - except Exception as exc: - ev.retry_count = int(ev.retry_count or 0) + 1 - ev.error_message = str(exc)[:1000] - if ev.retry_count > retries: - ev.status = "dead_letter" - ev.processed_at = _utc_now_naive() - stats["dead_letter"] += 1 - else: - ev.status = "retry" - stats["retry"] += 1 - - await self.db.commit() - return stats - - async def _process_feishu_event(self, event: IngestEvent) -> None: - payload = event.payload if isinstance(event.payload, dict) else {} - item_id = payload.get("feishu_item_id") - if item_id is None: - return - item = ( - await self.db.execute( - select(FeishuItem).where( - and_( - FeishuItem.tenant_id == self.tenant_id, - FeishuItem.id == int(item_id), - ) - ) - ) - ).scalar_one_or_none() - if item is None: - return - - signal_entity = await self.upsert_entity( - entity_type="signal", - entity_key=f"feishu_item:{int(item.id)}", - display_name=str(item.raw_message or f"FeishuItem#{int(item.id)}")[:255], - payload={ - "feishu_item_id": int(item.id), - "level": str(item.level or ""), - "route_status": str(item.route_status or ""), - "matched_project_id": int(item.matched_project_id) if item.matched_project_id is not None else None, - }, - source_type="feishu", - source_ref=str(item.feishu_message_id or f"item:{int(item.id)}")[:255], - ) - await self.add_evidence( - evidence_type="feishu_delta", - content_excerpt=str(item.raw_message or "")[:1000] or f"feishu_item#{int(item.id)}", - entity_id=int(signal_entity.id), - source_type="feishu", - source_ref=str(item.feishu_message_id or f"item:{int(item.id)}")[:255], - metadata={"feishu_item_id": int(item.id)}, - ) - - if item.matched_project_id is not None: - project_entity = await self.upsert_entity( - entity_type="project", - entity_key=f"project:{int(item.matched_project_id)}", - display_name=f"Project#{int(item.matched_project_id)}", - payload={"project_id": int(item.matched_project_id)}, - source_type="db", - source_ref=f"projects:{int(item.matched_project_id)}", - ) - rel = await self.upsert_relation( - relation_type="matches_project", - subject_entity_id=int(signal_entity.id), - object_entity_id=int(project_entity.id), - source_type="feishu", - source_ref=str(item.feishu_message_id or f"item:{int(item.id)}")[:255], - ) - await self.add_evidence( - evidence_type="feishu_delta", - content_excerpt=f"feishu_item#{int(item.id)} matches project#{int(item.matched_project_id)}", - relation_id=int(rel.id), - source_type="feishu", - source_ref=str(item.feishu_message_id or f"item:{int(item.id)}")[:255], - metadata={"feishu_item_id": int(item.id)}, - ) - - async def sync_codebase_if_due( - self, - *, - base_dir: Optional[str] = None, - interval_hours: int = 6, - ) -> dict[str, Any]: - hours = max(1, int(interval_hours)) - offset_raw = await self.get_ingest_offset(channel="code_scan", offset_key="last_code_scan_at") - last_scan_at = self._parse_iso_dt(offset_raw) - now = _utc_now_naive() - if last_scan_at is not None and now - last_scan_at < timedelta(hours=hours): - return { - "executed": False, - "reason": "not_due", - "last_scan_at": last_scan_at.isoformat(), - "next_due_at": (last_scan_at + timedelta(hours=hours)).isoformat(), - } - payload = await self.sync_codebase_snapshot(base_dir=base_dir) - return { - "executed": True, - "reason": "due", - "payload": payload, - } - - async def sync_symbol_index_if_due( - self, - *, - base_dir: Optional[str] = None, - interval_hours: int = 6, - max_files: int = 500, - ) -> dict[str, Any]: - hours = max(1, int(interval_hours)) - offset_raw = await self.get_ingest_offset(channel="symbol_scan", offset_key="last_symbol_scan_at") - last_scan_at = self._parse_iso_dt(offset_raw) - now = _utc_now_naive() - if last_scan_at is not None and now - last_scan_at < timedelta(hours=hours): - return { - "executed": False, - "reason": "not_due", - "last_scan_at": last_scan_at.isoformat(), - "next_due_at": (last_scan_at + timedelta(hours=hours)).isoformat(), - } - - symbol_service = SymbolIndexService(self.db, tenant_id=self.tenant_id) - payload = await symbol_service.sync_codebase_symbols( - base_dir=base_dir, - max_files=max_files, - force_reindex=False, - ) - symbol_graph = await self._sync_symbol_entities(limit=800) - payload["kg_symbol_sync"] = symbol_graph - await self.enqueue_ingest_event( - channel="symbol_scan", - event_key=f"symbol_scan:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", - payload=payload, - status="done", - ) - await self.set_ingest_offset( - channel="symbol_scan", - offset_key="last_symbol_scan_at", - offset_value=_utc_now_naive().isoformat(), - payload=payload, - ) - await self.db.commit() - return { - "executed": True, - "reason": "due", - "payload": payload, - } - - async def _sync_symbol_entities(self, *, limit: int = 800) -> dict[str, Any]: - max_rows = max(10, min(int(limit), 3000)) - defs = ( - await self.db.execute( - select(SymbolDefinition) - .where(SymbolDefinition.tenant_id == self.tenant_id) - .order_by(SymbolDefinition.updated_at.desc(), SymbolDefinition.id.desc()) - .limit(max_rows) - ) - ).scalars().all() - if not defs: - return {"symbols": 0, "modules": 0, "relations": 0, "evidences": 0} - - counters = {"symbols": 0, "modules": 0, "relations": 0, "evidences": 0} - module_entity_map: dict[str, int] = {} - symbol_entity_map: dict[int, int] = {} - name_entity_map: dict[str, int] = {} - qn_entity_map: dict[str, int] = {} - - for row in defs: - qn = str(row.qualified_name or row.symbol_name or f"symbol#{int(row.id)}") - symbol_entity = await self.upsert_entity( - entity_type="symbol", - entity_key=f"symbol:{int(row.id)}", - display_name=qn[:255], - payload={ - "symbol_id": int(row.id), - "symbol_name": str(row.symbol_name or ""), - "qualified_name": str(row.qualified_name or ""), - "symbol_type": str(row.symbol_type or ""), - "language": str(row.language or ""), - "file_path": str(row.file_path or ""), - "start_line": int(row.start_line) if row.start_line is not None else None, - "end_line": int(row.end_line) if row.end_line is not None else None, - }, - source_type="symbol_index", - source_ref=str(row.file_path or "")[:255], - ) - counters["symbols"] += 1 - symbol_entity_map[int(row.id)] = int(symbol_entity.id) - if str(row.symbol_name or "").strip(): - name_entity_map[str(row.symbol_name).strip()] = int(symbol_entity.id) - if str(row.qualified_name or "").strip(): - qn_entity_map[str(row.qualified_name).strip()] = int(symbol_entity.id) - - file_key = str(row.file_path or "").strip() - if not file_key: - continue - module_entity_id = module_entity_map.get(file_key) - if module_entity_id is None: - module_entity = await self.upsert_entity( - entity_type="module", - entity_key=f"module:{file_key}", - display_name=file_key[:255], - payload={"file_path": file_key}, - source_type="symbol_index", - source_ref=file_key[:255], - ) - module_entity_id = int(module_entity.id) - module_entity_map[file_key] = module_entity_id - counters["modules"] += 1 - rel = await self.upsert_relation( - relation_type="defined_in", - subject_entity_id=int(symbol_entity.id), - object_entity_id=int(module_entity_id), - source_type="symbol_index", - source_ref=file_key[:255], - payload={ - "symbol_id": int(row.id), - "symbol_type": str(row.symbol_type or ""), - "language": str(row.language or ""), - }, - ) - counters["relations"] += 1 - await self.add_evidence( - evidence_type="symbol_index", - content_excerpt=( - f"{str(row.symbol_name or '')} defined_in {file_key}:" - f"{int(row.start_line) if row.start_line is not None else 0}" - ), - relation_id=int(rel.id), - source_type="symbol_index", - source_ref=file_key[:255], - metadata={"symbol_id": int(row.id), "file_path": file_key}, - ) - counters["evidences"] += 1 - - refs = ( - await self.db.execute( - select(SymbolReference) - .where(SymbolReference.tenant_id == self.tenant_id) - .order_by(SymbolReference.updated_at.desc(), SymbolReference.id.desc()) - .limit(max_rows * 3) - ) - ).scalars().all() - for row in refs: - source_entity_id = None - if row.source_symbol_id is not None: - source_entity_id = symbol_entity_map.get(int(row.source_symbol_id)) - if source_entity_id is None: - continue - target_entity_id = None - target_qn = str(row.target_qualified_name or "").strip() - target_name = str(row.target_symbol_name or "").strip() - if target_qn: - target_entity_id = qn_entity_map.get(target_qn) - if target_entity_id is None and target_name: - target_entity_id = name_entity_map.get(target_name) - if target_entity_id is None: - continue - rel_type = str(row.relation_type or "references").strip() - rel = await self.upsert_relation( - relation_type=rel_type[:40], - subject_entity_id=int(source_entity_id), - object_entity_id=int(target_entity_id), - source_type="symbol_index", - source_ref=str(row.file_path or "")[:255], - payload={ - "symbol_reference_id": int(row.id), - "target_symbol_name": target_name, - "target_qualified_name": target_qn, - }, - ) - counters["relations"] += 1 - await self.add_evidence( - evidence_type="symbol_reference", - content_excerpt=f"{int(source_entity_id)} {rel_type} {target_name or target_qn}", - relation_id=int(rel.id), - source_type="symbol_index", - source_ref=str(row.file_path or "")[:255], - metadata={ - "symbol_reference_id": int(row.id), - "target_symbol_name": target_name, - "target_qualified_name": target_qn, - }, - ) - counters["evidences"] += 1 - return counters - - @staticmethod - def _routing_rule_candidate_paths() -> list[Path]: - candidates: list[Path] = [] - configured = str(os.getenv("OPENCLAW_ROUTING_RULES_PATH", "") or "").strip() - if configured: - candidates.append(Path(configured).expanduser()) - - backend_root = Path(__file__).resolve().parents[2] - project_root = Path(__file__).resolve().parents[3] - candidates.extend( - [ - project_root / ".openclaw" / "pmflow-routing-rules.json", - backend_root / ".openclaw" / "pmflow-routing-rules.json", - ] - ) - - deduped: list[Path] = [] - seen: set[str] = set() - for path in candidates: - key = str(path) - if key in seen: - continue - seen.add(key) - deduped.append(path) - return deduped - - @classmethod - def _resolve_guardrail_target_models(cls) -> dict[str, dict[str, str]]: - specs: dict[str, dict[str, str]] = { - "low": { - "tier": "low", - "provider": str(os.getenv("LLM_ROUTING_LOW_PROVIDER", "glm") or "glm").strip(), - "model": str(os.getenv("LLM_ROUTING_LOW_MODEL", "GLM-4.7") or "GLM-4.7").strip(), - "api_env": str(os.getenv("LLM_ROUTING_LOW_API_ENV", "GLM_API_KEY") or "GLM_API_KEY").strip(), - "source": "env_default", - "source_path": "", - "rule_name": "", - }, - "high": { - "tier": "high", - "provider": str(os.getenv("LLM_ROUTING_HIGH_PROVIDER", "openai") or "openai").strip(), - "model": str(os.getenv("LLM_ROUTING_HIGH_MODEL", "MiniMax-M2.5") or "MiniMax-M2.5").strip(), - "api_env": str(os.getenv("LLM_ROUTING_HIGH_API_ENV", "OPENAI_API_KEY") or "OPENAI_API_KEY").strip(), - "source": "env_default", - "source_path": "", - "rule_name": "", - }, - } - - for path in cls._routing_rule_candidate_paths(): - try: - content = path.read_text(encoding="utf-8") - payload = json.loads(content) - except Exception: - continue - rows = payload.get("rules") if isinstance(payload, dict) and isinstance(payload.get("rules"), list) else [] - if not rows: - continue - enabled_rows = [row for row in rows if isinstance(row, dict) and bool(row.get("enabled", True))] - enabled_rows.sort(key=lambda item: int(item.get("priority") or 0), reverse=True) - resolved_any = False - for tier in ("low", "high"): - for row in enabled_rows: - if str(row.get("tier") or "").strip().lower() != tier: - continue - model = str(row.get("model") or "").strip() - if not model: - continue - specs[tier] = { - "tier": tier, - "provider": str(row.get("provider") or specs[tier]["provider"]).strip(), - "model": model, - "api_env": str(row.get("api_env") or specs[tier]["api_env"]).strip(), - "source": "routing_rule", - "source_path": str(path), - "rule_name": str(row.get("name") or "").strip(), - } - resolved_any = True - break - if resolved_any and all(str(specs[tier].get("source") or "") == "routing_rule" for tier in ("low", "high")): - break - return specs - - async def _sync_model_guardrail_entities(self) -> dict[str, Any]: - counters = {"models": 0, "policies": 0, "interfaces": 0, "relations": 0, "evidences": 0} - model_specs = self._resolve_guardrail_target_models() - - policy_entity = await self.upsert_entity( - entity_type="governance_policy", - entity_key=DDL_GUARDRAIL_POLICY_KEY, - display_name=DDL_GUARDRAIL_POLICY_NAME, - payload={ - "policy_code": "ddl_via_interface_only", - "enforcement_level": "hard_block", - "blocked_sql_prefixes": DDL_BLOCKED_PREFIXES, - "required_execution_path": "api_or_migration_interface", - "enforced_by": [ - "backend/app/database.py", - "backend/app/services/llm.py", - "backend/app/config/ai_prompts.py", - ], - }, - source_type="governance", - source_ref="backend/app/database.py", - ) - counters["policies"] += 1 - await self.add_evidence( - evidence_type="policy_guardrail", - content_excerpt="Runtime DDL is blocked; model planning must route DDL via interface/migration only.", - entity_id=int(policy_entity.id), - source_type="governance", - source_ref="backend/app/database.py", - metadata={"blocked_sql_prefixes": DDL_BLOCKED_PREFIXES}, - ) - counters["evidences"] += 1 - - interface_specs = [ - { - "entity_key": "interface:api:/ai-steward/command/dispatch", - "display_name": "POST /ai-steward/command/dispatch", - "payload": {"path": "/ai-steward/command/dispatch", "kind": "api"}, - "source_ref": "backend/app/api/ai_steward.py", - }, - { - "entity_key": "interface:migration:alembic", - "display_name": "Alembic Migration Interface", - "payload": {"path": "alembic", "kind": "migration"}, - "source_ref": "backend/alembic/versions", - }, - ] - interface_ids: list[int] = [] - for item in interface_specs: - interface_entity = await self.upsert_entity( - entity_type="interface", - entity_key=str(item["entity_key"]), - display_name=str(item["display_name"]), - payload=dict(item["payload"]), - source_type="governance", - source_ref=str(item["source_ref"]), - ) - counters["interfaces"] += 1 - interface_ids.append(int(interface_entity.id)) - rel = await self.upsert_relation( - relation_type="policy_enforced_by_interface", - subject_entity_id=int(policy_entity.id), - object_entity_id=int(interface_entity.id), - source_type="governance", - source_ref=str(item["source_ref"]), - ) - counters["relations"] += 1 - await self.add_evidence( - evidence_type="policy_guardrail", - content_excerpt=f"{DDL_GUARDRAIL_POLICY_NAME} -> {item['display_name']}", - relation_id=int(rel.id), - source_type="governance", - source_ref=str(item["source_ref"]), - metadata=dict(item["payload"]), - ) - counters["evidences"] += 1 - - for tier in ("low", "high"): - spec = model_specs.get(tier) if isinstance(model_specs.get(tier), dict) else {} - model_name = str(spec.get("model") or "").strip() or ("GLM-4.7" if tier == "low" else "MiniMax-M2.5") - provider = str(spec.get("provider") or "").strip() - api_env = str(spec.get("api_env") or "").strip() - source = str(spec.get("source") or "env_default").strip() - source_ref = str(spec.get("source_path") or spec.get("rule_name") or "llm_routing_env").strip()[:255] - entity = await self.upsert_entity( - entity_type="ai_model", - entity_key=f"ai_model:{tier}:{model_name.lower()[:160]}", - display_name=model_name[:255], - payload={ - "tier": tier, - "provider": provider, - "model": model_name, - "api_env": api_env, - "source": source, - "routing_rule": str(spec.get("rule_name") or "").strip(), - }, - source_type="model_routing", - source_ref=source_ref or None, - ) - counters["models"] += 1 - rel = await self.upsert_relation( - relation_type="model_must_follow_policy", - subject_entity_id=int(entity.id), - object_entity_id=int(policy_entity.id), - source_type="governance", - source_ref=source_ref or None, - payload={"tier": tier, "policy_code": "ddl_via_interface_only"}, - ) - counters["relations"] += 1 - await self.add_evidence( - evidence_type="policy_guardrail", - content_excerpt=f"{tier} model {model_name} must follow {DDL_GUARDRAIL_POLICY_KEY}", - relation_id=int(rel.id), - source_type="governance", - source_ref=source_ref or None, - metadata={"tier": tier, "provider": provider, "model": model_name}, - ) - counters["evidences"] += 1 - - for interface_id in interface_ids: - rel = await self.upsert_relation( - relation_type="model_executes_via_interface", - subject_entity_id=int(entity.id), - object_entity_id=int(interface_id), - source_type="governance", - source_ref=source_ref or None, - payload={"tier": tier}, - ) - counters["relations"] += 1 - await self.add_evidence( - evidence_type="policy_guardrail", - content_excerpt=f"{tier} model {model_name} executes via controlled interface", - relation_id=int(rel.id), - source_type="governance", - source_ref=source_ref or None, - metadata={"tier": tier}, - ) - counters["evidences"] += 1 - - return counters - - async def sync_model_guardrails_if_due( - self, - *, - interval_hours: int = 6, - ) -> dict[str, Any]: - hours = max(1, int(interval_hours)) - offset_raw = await self.get_ingest_offset(channel="model_guardrail", offset_key="last_model_guardrail_sync_at") - last_sync_at = self._parse_iso_dt(offset_raw) - now = _utc_now_naive() - if last_sync_at is not None and now - last_sync_at < timedelta(hours=hours): - return { - "executed": False, - "reason": "not_due", - "last_sync_at": last_sync_at.isoformat(), - "next_due_at": (last_sync_at + timedelta(hours=hours)).isoformat(), - } - - payload = await self._sync_model_guardrail_entities() - await self.enqueue_ingest_event( - channel="model_guardrail", - event_key=f"model_guardrail:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", - payload=payload, - status="done", - ) - await self.set_ingest_offset( - channel="model_guardrail", - offset_key="last_model_guardrail_sync_at", - offset_value=_utc_now_naive().isoformat(), - payload=payload, - ) - await self.db.commit() - return { - "executed": True, - "reason": "due", - "payload": payload, - } - - async def sync_model_guardrails_now(self) -> dict[str, Any]: - payload = await self._sync_model_guardrail_entities() - await self.enqueue_ingest_event( - channel="model_guardrail", - event_key=f"model_guardrail:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", - payload=payload, - status="done", - ) - await self.set_ingest_offset( - channel="model_guardrail", - offset_key="last_model_guardrail_sync_at", - offset_value=_utc_now_naive().isoformat(), - payload=payload, - ) - await self.db.commit() - return payload - - async def sync_feature_catalog_if_due( - self, - *, - interval_hours: int = 6, - ) -> dict[str, Any]: - hours = max(1, int(interval_hours)) - offset_raw = await self.get_ingest_offset(channel="feature_catalog", offset_key="last_feature_catalog_sync_at") - last_sync_at = self._parse_iso_dt(offset_raw) - now = _utc_now_naive() - if last_sync_at is not None and now - last_sync_at < timedelta(hours=hours): - return { - "executed": False, - "reason": "not_due", - "last_sync_at": last_sync_at.isoformat(), - "next_due_at": (last_sync_at + timedelta(hours=hours)).isoformat(), - } - - payload = await self._sync_feature_catalog_entities(limit=300) - await self.enqueue_ingest_event( - channel="feature_catalog", - event_key=f"feature_catalog:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", - payload=payload, - status="done", - ) - await self.set_ingest_offset( - channel="feature_catalog", - offset_key="last_feature_catalog_sync_at", - offset_value=_utc_now_naive().isoformat(), - payload=payload, - ) - await self.db.commit() - return { - "executed": True, - "reason": "due", - "payload": payload, - } - - async def sync_feature_catalog_now(self) -> dict[str, Any]: - payload = await self._sync_feature_catalog_entities(limit=300) - await self.enqueue_ingest_event( - channel="feature_catalog", - event_key=f"feature_catalog:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", - payload=payload, - status="done", - ) - await self.set_ingest_offset( - channel="feature_catalog", - offset_key="last_feature_catalog_sync_at", - offset_value=_utc_now_naive().isoformat(), - payload=payload, - ) - await self.db.commit() - return payload - - async def _sync_feature_catalog_entities(self, *, limit: int = 300) -> dict[str, Any]: - snapshot = await context_cache_service.get_system_snapshot( - db=self.db, - tenant_id=self.tenant_id, - ) - platform = snapshot.get("platform_profile") if isinstance(snapshot.get("platform_profile"), dict) else {} - catalog = platform.get("feature_catalog") if isinstance(platform.get("feature_catalog"), list) else [] - rows = [row for row in catalog if isinstance(row, dict)][: max(1, min(int(limit), 1000))] - if not rows: - return {"features": 0, "routes": 0, "pages": 0, "apis": 0, "relations": 0, "evidences": 0} - - counters = {"features": 0, "routes": 0, "pages": 0, "apis": 0, "relations": 0, "evidences": 0} - route_entity_map: dict[str, int] = {} - page_entity_map: dict[str, int] = {} - api_entity_map: dict[str, int] = {} - - for row in rows: - name = str(row.get("name") or "").strip() or "unknown_feature" - route = str(row.get("route") or "").strip() - summary = str(row.get("summary") or "").strip() - page_file = str(row.get("page_file") or "").strip() - page_component = str(row.get("page_component") or "").strip() - apis = row.get("apis") if isinstance(row.get("apis"), list) else [] - aliases = row.get("aliases") if isinstance(row.get("aliases"), list) else [] - - feature_slug = (route or name).strip().lower()[:200] - feature_entity = await self.upsert_entity( - entity_type="feature", - entity_key=f"feature:{feature_slug}", - display_name=name[:255], - payload={ - "name": name, - "route": route, - "summary": summary, - "page_file": page_file, - "page_component": page_component, - "apis": [str(x) for x in apis[:20] if str(x).strip()], - "aliases": [str(x) for x in aliases[:20] if str(x).strip()], - }, - source_type="feature_catalog", - source_ref=(route or page_file or name)[:255], - ) - counters["features"] += 1 - await self.add_evidence( - evidence_type="feature_catalog", - content_excerpt=f"{name} | route={route or 'n/a'} | page={page_file or 'n/a'}", - entity_id=int(feature_entity.id), - source_type="feature_catalog", - source_ref=(route or page_file or name)[:255], - metadata={"feature_name": name, "route": route, "page_file": page_file}, - ) - counters["evidences"] += 1 - - route_entity_id: Optional[int] = None - if route: - if route not in route_entity_map: - route_entity = await self.upsert_entity( - entity_type="route", - entity_key=f"route:{route[:200]}", - display_name=route[:255], - payload={"route": route}, - source_type="feature_catalog", - source_ref=route[:255], - ) - route_entity_map[route] = int(route_entity.id) - counters["routes"] += 1 - route_entity_id = route_entity_map[route] - rel = await self.upsert_relation( - relation_type="feature_has_route", - subject_entity_id=int(feature_entity.id), - object_entity_id=int(route_entity_id), - source_type="feature_catalog", - source_ref=route[:255], - ) - counters["relations"] += 1 - await self.add_evidence( - evidence_type="feature_catalog", - content_excerpt=f"{name} has route {route}", - relation_id=int(rel.id), - source_type="feature_catalog", - source_ref=route[:255], - metadata={"feature_name": name, "route": route}, - ) - counters["evidences"] += 1 - - page_entity_id: Optional[int] = None - if page_file: - if page_file not in page_entity_map: - page_entity = await self.upsert_entity( - entity_type="page", - entity_key=f"page:{page_file[:220]}", - display_name=(page_component or page_file)[:255], - payload={"page_file": page_file, "page_component": page_component}, - source_type="feature_catalog", - source_ref=page_file[:255], - ) - page_entity_map[page_file] = int(page_entity.id) - counters["pages"] += 1 - page_entity_id = page_entity_map[page_file] - rel = await self.upsert_relation( - relation_type="feature_rendered_by", - subject_entity_id=int(feature_entity.id), - object_entity_id=int(page_entity_id), - source_type="feature_catalog", - source_ref=page_file[:255], - ) - counters["relations"] += 1 - await self.add_evidence( - evidence_type="feature_catalog", - content_excerpt=f"{name} rendered_by {page_file}", - relation_id=int(rel.id), - source_type="feature_catalog", - source_ref=page_file[:255], - metadata={"feature_name": name, "page_file": page_file}, - ) - counters["evidences"] += 1 - - if route_entity_id is not None and page_entity_id is not None: - rel = await self.upsert_relation( - relation_type="route_to_page", - subject_entity_id=int(route_entity_id), - object_entity_id=int(page_entity_id), - source_type="feature_catalog", - source_ref=(route or page_file)[:255], - ) - counters["relations"] += 1 - await self.add_evidence( - evidence_type="feature_catalog", - content_excerpt=f"{route} maps_to {page_file}", - relation_id=int(rel.id), - source_type="feature_catalog", - source_ref=(route or page_file)[:255], - metadata={"route": route, "page_file": page_file}, - ) - counters["evidences"] += 1 - - for raw_api in apis[:20]: - api_path = str(raw_api or "").strip() - if not api_path: - continue - if api_path not in api_entity_map: - api_entity = await self.upsert_entity( - entity_type="api_endpoint", - entity_key=f"api:{api_path[:220]}", - display_name=api_path[:255], - payload={"path": api_path}, - source_type="feature_catalog", - source_ref=api_path[:255], - ) - api_entity_map[api_path] = int(api_entity.id) - counters["apis"] += 1 - rel = await self.upsert_relation( - relation_type="feature_calls_api", - subject_entity_id=int(feature_entity.id), - object_entity_id=int(api_entity_map[api_path]), - source_type="feature_catalog", - source_ref=api_path[:255], - ) - counters["relations"] += 1 - await self.add_evidence( - evidence_type="feature_catalog", - content_excerpt=f"{name} calls {api_path}", - relation_id=int(rel.id), - source_type="feature_catalog", - source_ref=api_path[:255], - metadata={"feature_name": name, "api_path": api_path}, - ) - counters["evidences"] += 1 - - return counters - - async def run_incremental_cycle( - self, - *, - delta_limit: int = 1000, - event_batch: int = 200, - code_scan_hours: int = 6, - feature_scan_hours: int = 6, - code_scan_base_dir: Optional[str] = None, - run_source: str = "system", - ) -> dict[str, Any]: - started = _utc_now_naive() - sync_run = await self._open_sync_run( - run_type="incremental", - trigger_source=run_source, - input_payload={ - "delta_limit": int(delta_limit), - "event_batch": int(event_batch), - "code_scan_hours": int(code_scan_hours), - "feature_scan_hours": int(feature_scan_hours), - "code_scan_base_dir": str(code_scan_base_dir or ""), - }, - ) - run_id = int(sync_run.id) - run_started_at = sync_run.started_at or started - stage_results: dict[str, Any] = {} - stage_errors: dict[str, str] = {} - success_count = 0 - - try: - try: - stage_results["structured_delta"] = await self.sync_structured_delta(limit=delta_limit) - success_count += 1 - except Exception as exc: - await self.db.rollback() - stage_errors["structured_delta"] = str(exc)[:500] - stage_results["structured_delta"] = {"error": stage_errors["structured_delta"]} - - try: - stage_results["feishu_enqueue"] = await self.enqueue_feishu_delta_events(limit=min(delta_limit, 1000)) - success_count += 1 - except Exception as exc: - await self.db.rollback() - stage_errors["feishu_enqueue"] = str(exc)[:500] - stage_results["feishu_enqueue"] = {"error": stage_errors["feishu_enqueue"]} - - try: - stage_results["event_processing"] = await self.process_pending_events(max_batch=event_batch) - success_count += 1 - except Exception as exc: - await self.db.rollback() - stage_errors["event_processing"] = str(exc)[:500] - stage_results["event_processing"] = {"error": stage_errors["event_processing"]} - - try: - stage_results["code_scan"] = await self.sync_codebase_if_due( - base_dir=code_scan_base_dir, - interval_hours=code_scan_hours, - ) - success_count += 1 - except Exception as exc: - await self.db.rollback() - stage_errors["code_scan"] = str(exc)[:500] - stage_results["code_scan"] = {"error": stage_errors["code_scan"]} - - try: - stage_results["symbol_index"] = await self.sync_symbol_index_if_due( - base_dir=code_scan_base_dir, - interval_hours=code_scan_hours, - ) - success_count += 1 - except Exception as exc: - await self.db.rollback() - stage_errors["symbol_index"] = str(exc)[:500] - stage_results["symbol_index"] = {"error": stage_errors["symbol_index"]} - - try: - stage_results["feature_catalog"] = await self.sync_feature_catalog_if_due( - interval_hours=feature_scan_hours, - ) - success_count += 1 - except Exception as exc: - await self.db.rollback() - stage_errors["feature_catalog"] = str(exc)[:500] - stage_results["feature_catalog"] = {"error": stage_errors["feature_catalog"]} - - try: - stage_results["model_guardrails"] = await self.sync_model_guardrails_if_due( - interval_hours=feature_scan_hours, - ) - success_count += 1 - except Exception as exc: - await self.db.rollback() - stage_errors["model_guardrails"] = str(exc)[:500] - stage_results["model_guardrails"] = {"error": stage_errors["model_guardrails"]} - - ended = _utc_now_naive() - if success_count == 7: - final_status = "success" - elif success_count == 0: - final_status = "failed" - else: - final_status = "partial" - - payload = { - "tenant_id": self.tenant_id, - "started_at": started.isoformat(), - "ended_at": ended.isoformat(), - "run_id": run_id, - "run_status": final_status, - "success_stage_count": success_count, - "failed_stage_count": len(stage_errors), - "stage_errors": stage_errors, - **stage_results, - } - await self._close_sync_run( - row=sync_run, - status=final_status, - result_payload=payload, - error_message=("; ".join(f"{k}:{v}" for k, v in stage_errors.items()) if stage_errors else None), - started_at_hint=run_started_at, - ) - return payload - except Exception as exc: - await self.db.rollback() - ended = _utc_now_naive() - payload = { - "tenant_id": self.tenant_id, - "started_at": started.isoformat(), - "ended_at": ended.isoformat(), - "run_id": run_id, - "run_status": "failed", - "success_stage_count": success_count, - "failed_stage_count": max(1, len(stage_errors)), - "stage_errors": stage_errors, - **stage_results, - } - await self._close_sync_run( - row=sync_run, - status="failed", - result_payload=payload, - error_message=str(exc)[:1000], - started_at_hint=run_started_at, - ) - return payload - - async def sync_structured_entities(self, *, limit: int = 1000) -> dict[str, Any]: - max_rows = max(1, min(int(limit), 5000)) - counters = { - "projects": 0, - "tasks": 0, - "signals": 0, - "users": 0, - "relations": 0, - "evidences": 0, - } - - project_map: dict[int, int] = {} - projects = ( - await self.db.execute( - select(Project) - .where(Project.tenant_id == self.tenant_id) - .order_by(Project.updated_at.desc(), Project.id.desc()) - .limit(max_rows) - ) - ).scalars().all() - for p in projects: - entity = await self.upsert_entity( - entity_type="project", - entity_key=f"project:{int(p.id)}", - display_name=str(p.title or p.name or f"Project#{int(p.id)}"), - payload={ - "project_id": int(p.id), - "status": str(p.status or "unknown"), - "owner_id": int(p.owner_id) if p.owner_id is not None else None, - }, - source_type="db", - source_ref=f"projects:{int(p.id)}", - ) - project_map[int(p.id)] = int(entity.id) - counters["projects"] += 1 - - user_map: dict[int, int] = {} - users = ( - await self.db.execute( - select(User) - .where(User.tenant_id == self.tenant_id) - .order_by(User.updated_at.desc(), User.id.desc()) - .limit(max_rows) - ) - ).scalars().all() - for u in users: - entity = await self.upsert_entity( - entity_type="user", - entity_key=f"user:{int(u.id)}", - display_name=str(u.name or f"User#{int(u.id)}"), - payload={ - "user_id": int(u.id), - "role": str(u.role or "unknown"), - "is_active": bool(u.is_active), - }, - source_type="db", - source_ref=f"users:{int(u.id)}", - ) - user_map[int(u.id)] = int(entity.id) - counters["users"] += 1 - - tasks = ( - await self.db.execute( - select(Task) - .where(Task.tenant_id == self.tenant_id) - .order_by(Task.updated_at.desc(), Task.id.desc()) - .limit(max_rows) - ) - ).scalars().all() - for t in tasks: - task_entity = await self.upsert_entity( - entity_type="task", - entity_key=f"task:{int(t.id)}", - display_name=str(t.title or f"Task#{int(t.id)}"), - payload={ - "task_id": int(t.id), - "project_id": int(t.project_id) if t.project_id is not None else None, - "assignee_id": int(t.assignee_id) if t.assignee_id is not None else None, - "status": str(t.status or "unknown"), - }, - source_type="db", - source_ref=f"tasks:{int(t.id)}", - ) - counters["tasks"] += 1 - - if t.project_id is not None and int(t.project_id) in project_map: - rel = await self.upsert_relation( - relation_type="belongs_to", - subject_entity_id=int(task_entity.id), - object_entity_id=int(project_map[int(t.project_id)]), - source_type="db", - source_ref=f"tasks:{int(t.id)}", - ) - counters["relations"] += 1 - await self.add_evidence( - evidence_type="db_row", - content_excerpt=f"task#{int(t.id)} belongs_to project#{int(t.project_id)}", - relation_id=int(rel.id), - source_type="db", - source_ref=f"tasks:{int(t.id)}", - metadata={"table": "tasks"}, - ) - counters["evidences"] += 1 - - if t.assignee_id is not None and int(t.assignee_id) in user_map: - rel = await self.upsert_relation( - relation_type="assigned_to", - subject_entity_id=int(task_entity.id), - object_entity_id=int(user_map[int(t.assignee_id)]), - source_type="db", - source_ref=f"tasks:{int(t.id)}", - ) - counters["relations"] += 1 - await self.add_evidence( - evidence_type="db_row", - content_excerpt=f"task#{int(t.id)} assigned_to user#{int(t.assignee_id)}", - relation_id=int(rel.id), - source_type="db", - source_ref=f"tasks:{int(t.id)}", - metadata={"table": "tasks"}, - ) - counters["evidences"] += 1 - - signals = ( - await self.db.execute( - select(Signal) - .where(Signal.tenant_id == self.tenant_id) - .order_by(Signal.updated_at.desc(), Signal.id.desc()) - .limit(max_rows) - ) - ).scalars().all() - for s in signals: - signal_entity = await self.upsert_entity( - entity_type="signal", - entity_key=f"signal:{int(s.id)}", - display_name=str(s.title or s.content or f"Signal#{int(s.id)}")[:255], - payload={ - "signal_id": int(s.id), - "priority": str(s.priority or ""), - "status": str(s.status or ""), - "matched_project_id": ( - int(s.matched_project_id) if getattr(s, "matched_project_id", None) is not None else None - ), - }, - source_type="db", - source_ref=f"signals:{int(s.id)}", - ) - counters["signals"] += 1 - - target_pid = int(s.matched_project_id) if getattr(s, "matched_project_id", None) is not None else None - if target_pid is not None and target_pid in project_map: - rel = await self.upsert_relation( - relation_type="matches_project", - subject_entity_id=int(signal_entity.id), - object_entity_id=int(project_map[target_pid]), - source_type="db", - source_ref=f"signals:{int(s.id)}", - ) - counters["relations"] += 1 - await self.add_evidence( - evidence_type="db_row", - content_excerpt=f"signal#{int(s.id)} matches project#{target_pid}", - relation_id=int(rel.id), - source_type="db", - source_ref=f"signals:{int(s.id)}", - metadata={"table": "signals"}, - ) - counters["evidences"] += 1 - - counters["model_guardrails"] = await self._sync_model_guardrail_entities() - - await self.enqueue_ingest_event( - channel="db_delta", - event_key=f"structured_sync:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", - payload={"limit": max_rows, "counters": counters}, - status="done", - ) - await self.set_ingest_offset( - channel="db_delta", - offset_key="last_structured_sync_at", - offset_value=_utc_now_naive().isoformat(), - payload={"counters": counters}, - ) - - await self.db.commit() - return counters - - async def sync_codebase_snapshot(self, *, base_dir: Optional[str] = None) -> dict[str, Any]: - root = Path(base_dir or "/app/backend").resolve() - if not root.exists() or not root.is_dir(): - raise ValueError(f"codebase path not found: {root}") - - py_files = [p for p in root.rglob("*.py") if p.is_file()] - route_markers = ("@router.get(", "@router.post(", "@router.put(", "@router.patch(", "@router.delete(") - route_count = 0 - service_file_count = 0 - model_file_count = 0 - - for py_file in py_files: - rel = str(py_file.relative_to(root)) - if rel.startswith("app/services/"): - service_file_count += 1 - if rel.startswith("app/models/"): - model_file_count += 1 - try: - text = py_file.read_text(encoding="utf-8", errors="ignore") - except Exception: - continue - for marker in route_markers: - route_count += text.count(marker) - - payload = { - "root": str(root), - "python_files": len(py_files), - "service_files": service_file_count, - "model_files": model_file_count, - "api_route_markers": route_count, - "scanned_at": _utc_now_naive().isoformat(), - } - entity = await self.upsert_entity( - entity_type="service", - entity_key="service:backend_codebase", - display_name="Backend Codebase Snapshot", - payload=payload, - source_type="code_scan", - source_ref="filesystem", - ) - await self.add_evidence( - evidence_type="code_scan", - content_excerpt=f"python_files={len(py_files)}, routes={route_count}", - entity_id=int(entity.id), - source_type="code_scan", - source_ref=str(root), - metadata=payload, - ) - await self.enqueue_ingest_event( - channel="code_scan", - event_key=f"code_scan:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", - payload=payload, - status="done", - ) - await self.set_ingest_offset( - channel="code_scan", - offset_key="last_code_scan_at", - offset_value=_utc_now_naive().isoformat(), - payload=payload, - ) - await self.db.commit() - return payload - - async def get_overview(self) -> dict[str, Any]: - entity_total = ( - await self.db.execute(select(func.count(KGEntity.id)).where(KGEntity.tenant_id == self.tenant_id)) - ).scalar_one() - relation_total = ( - await self.db.execute(select(func.count(KGRelation.id)).where(KGRelation.tenant_id == self.tenant_id)) - ).scalar_one() - evidence_total = ( - await self.db.execute(select(func.count(KGEvidence.id)).where(KGEvidence.tenant_id == self.tenant_id)) - ).scalar_one() - pending_events = ( - await self.db.execute( - select(func.count(IngestEvent.id)).where( - and_( - IngestEvent.tenant_id == self.tenant_id, - IngestEvent.status.in_(["pending", "retry"]), - ) - ) - ) - ).scalar_one() - latest_run = ( - await self.db.execute( - select(KGSyncRun) - .where(KGSyncRun.tenant_id == self.tenant_id) - .order_by(KGSyncRun.started_at.desc(), KGSyncRun.id.desc()) - .limit(1) - ) - ).scalar_one_or_none() - - entity_groups = ( - await self.db.execute( - select(KGEntity.entity_type, func.count(KGEntity.id)) - .where(KGEntity.tenant_id == self.tenant_id) - .group_by(KGEntity.entity_type) - ) - ).all() - relation_groups = ( - await self.db.execute( - select(KGRelation.relation_type, func.count(KGRelation.id)) - .where(KGRelation.tenant_id == self.tenant_id) - .group_by(KGRelation.relation_type) - ) - ).all() - - return { - "tenant_id": self.tenant_id, - "entity_total": int(entity_total or 0), - "relation_total": int(relation_total or 0), - "evidence_total": int(evidence_total or 0), - "pending_events": int(pending_events or 0), - "entities_by_type": {str(k): int(v or 0) for k, v in entity_groups}, - "relations_by_type": {str(k): int(v or 0) for k, v in relation_groups}, - "latest_sync_run": ( - { - "id": int(latest_run.id), - "run_type": str(latest_run.run_type or ""), - "trigger_source": str(latest_run.trigger_source or ""), - "status": str(latest_run.status or ""), - "started_at": latest_run.started_at.isoformat() if latest_run.started_at else None, - "ended_at": latest_run.ended_at.isoformat() if latest_run.ended_at else None, - "duration_ms": int(latest_run.duration_ms or 0), - } - if latest_run is not None - else None - ), - "snapshot_at": _utc_now_naive().isoformat(), - } +__all__ = ["ProjectKnowledgeGraphService"] diff --git a/backend/app/services/project_knowledge_graph_service/__init__.py b/backend/app/services/project_knowledge_graph_service/__init__.py new file mode 100644 index 00000000..eefcbaa9 --- /dev/null +++ b/backend/app/services/project_knowledge_graph_service/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .service import ProjectKnowledgeGraphService + +__all__ = ["ProjectKnowledgeGraphService"] diff --git a/backend/app/services/project_knowledge_graph_service/mixin_codebase_overview.py b/backend/app/services/project_knowledge_graph_service/mixin_codebase_overview.py new file mode 100644 index 00000000..cf24b511 --- /dev/null +++ b/backend/app/services/project_knowledge_graph_service/mixin_codebase_overview.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from .shared import * + +class PKGCodebaseOverviewMixin: + async def sync_codebase_snapshot(self, *, base_dir: Optional[str] = None) -> dict[str, Any]: + root = Path(base_dir or "/app/backend").resolve() + if not root.exists() or not root.is_dir(): + raise ValueError(f"codebase path not found: {root}") + + py_files = [p for p in root.rglob("*.py") if p.is_file()] + route_markers = ("@router.get(", "@router.post(", "@router.put(", "@router.patch(", "@router.delete(") + route_count = 0 + service_file_count = 0 + model_file_count = 0 + + for py_file in py_files: + rel = str(py_file.relative_to(root)) + if rel.startswith("app/services/"): + service_file_count += 1 + if rel.startswith("app/models/"): + model_file_count += 1 + try: + text = py_file.read_text(encoding="utf-8", errors="ignore") + except Exception: + continue + for marker in route_markers: + route_count += text.count(marker) + + payload = { + "root": str(root), + "python_files": len(py_files), + "service_files": service_file_count, + "model_files": model_file_count, + "api_route_markers": route_count, + "scanned_at": _utc_now_naive().isoformat(), + } + entity = await self.upsert_entity( + entity_type="service", + entity_key="service:backend_codebase", + display_name="Backend Codebase Snapshot", + payload=payload, + source_type="code_scan", + source_ref="filesystem", + ) + await self.add_evidence( + evidence_type="code_scan", + content_excerpt=f"python_files={len(py_files)}, routes={route_count}", + entity_id=int(entity.id), + source_type="code_scan", + source_ref=str(root), + metadata=payload, + ) + await self.enqueue_ingest_event( + channel="code_scan", + event_key=f"code_scan:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", + payload=payload, + status="done", + ) + await self.set_ingest_offset( + channel="code_scan", + offset_key="last_code_scan_at", + offset_value=_utc_now_naive().isoformat(), + payload=payload, + ) + await self.db.commit() + return payload + + async def get_overview(self) -> dict[str, Any]: + entity_total = ( + await self.db.execute(select(func.count(KGEntity.id)).where(KGEntity.tenant_id == self.tenant_id)) + ).scalar_one() + relation_total = ( + await self.db.execute(select(func.count(KGRelation.id)).where(KGRelation.tenant_id == self.tenant_id)) + ).scalar_one() + evidence_total = ( + await self.db.execute(select(func.count(KGEvidence.id)).where(KGEvidence.tenant_id == self.tenant_id)) + ).scalar_one() + pending_events = ( + await self.db.execute( + select(func.count(IngestEvent.id)).where( + and_( + IngestEvent.tenant_id == self.tenant_id, + IngestEvent.status.in_(["pending", "retry"]), + ) + ) + ) + ).scalar_one() + latest_run = ( + await self.db.execute( + select(KGSyncRun) + .where(KGSyncRun.tenant_id == self.tenant_id) + .order_by(KGSyncRun.started_at.desc(), KGSyncRun.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + + entity_groups = ( + await self.db.execute( + select(KGEntity.entity_type, func.count(KGEntity.id)) + .where(KGEntity.tenant_id == self.tenant_id) + .group_by(KGEntity.entity_type) + ) + ).all() + relation_groups = ( + await self.db.execute( + select(KGRelation.relation_type, func.count(KGRelation.id)) + .where(KGRelation.tenant_id == self.tenant_id) + .group_by(KGRelation.relation_type) + ) + ).all() + + return { + "tenant_id": self.tenant_id, + "entity_total": int(entity_total or 0), + "relation_total": int(relation_total or 0), + "evidence_total": int(evidence_total or 0), + "pending_events": int(pending_events or 0), + "entities_by_type": {str(k): int(v or 0) for k, v in entity_groups}, + "relations_by_type": {str(k): int(v or 0) for k, v in relation_groups}, + "latest_sync_run": ( + { + "id": int(latest_run.id), + "run_type": str(latest_run.run_type or ""), + "trigger_source": str(latest_run.trigger_source or ""), + "status": str(latest_run.status or ""), + "started_at": latest_run.started_at.isoformat() if latest_run.started_at else None, + "ended_at": latest_run.ended_at.isoformat() if latest_run.ended_at else None, + "duration_ms": int(latest_run.duration_ms or 0), + } + if latest_run is not None + else None + ), + "snapshot_at": _utc_now_naive().isoformat(), + } + diff --git a/backend/app/services/project_knowledge_graph_service/mixin_crud_ingest.py b/backend/app/services/project_knowledge_graph_service/mixin_crud_ingest.py new file mode 100644 index 00000000..3b1d94f1 --- /dev/null +++ b/backend/app/services/project_knowledge_graph_service/mixin_crud_ingest.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +from .shared import * + +class PKGCrudIngestMixin: + def __init__(self, db: AsyncSession, *, tenant_id: int) -> None: + self.db = db + self.tenant_id = int(tenant_id) + + async def upsert_entity( + self, + *, + entity_type: str, + entity_key: str, + display_name: str, + payload: Optional[dict[str, Any]] = None, + source_type: str = "system", + source_ref: Optional[str] = None, + ) -> KGEntity: + row = ( + await self.db.execute( + select(KGEntity).where( + and_( + KGEntity.tenant_id == self.tenant_id, + KGEntity.entity_type == str(entity_type).strip()[:40], + KGEntity.entity_key == str(entity_key).strip()[:255], + ) + ) + ) + ).scalar_one_or_none() + + if row is None: + row = KGEntity( + tenant_id=self.tenant_id, + entity_type=str(entity_type).strip()[:40], + entity_key=str(entity_key).strip()[:255], + display_name=str(display_name or entity_key).strip()[:255], + payload=payload or {}, + source_type=str(source_type or "system").strip()[:32], + source_ref=(str(source_ref).strip()[:255] if source_ref else None), + last_seen_at=_utc_now_naive(), + ) + self.db.add(row) + await self.db.flush() + return row + + row.display_name = str(display_name or row.display_name).strip()[:255] + row.payload = payload or {} + row.source_type = str(source_type or row.source_type or "system").strip()[:32] + row.source_ref = str(source_ref).strip()[:255] if source_ref else row.source_ref + row.last_seen_at = _utc_now_naive() + await self.db.flush() + return row + + async def upsert_relation( + self, + *, + relation_type: str, + subject_entity_id: int, + object_entity_id: int, + weight: float = 1.0, + payload: Optional[dict[str, Any]] = None, + source_type: str = "system", + source_ref: Optional[str] = None, + ) -> KGRelation: + row = ( + await self.db.execute( + select(KGRelation).where( + and_( + KGRelation.tenant_id == self.tenant_id, + KGRelation.relation_type == str(relation_type).strip()[:40], + KGRelation.subject_entity_id == int(subject_entity_id), + KGRelation.object_entity_id == int(object_entity_id), + ) + ) + ) + ).scalar_one_or_none() + + if row is None: + row = KGRelation( + tenant_id=self.tenant_id, + relation_type=str(relation_type).strip()[:40], + subject_entity_id=int(subject_entity_id), + object_entity_id=int(object_entity_id), + weight=float(weight), + payload=payload or {}, + source_type=str(source_type or "system").strip()[:32], + source_ref=(str(source_ref).strip()[:255] if source_ref else None), + ) + self.db.add(row) + await self.db.flush() + return row + + row.weight = float(weight) + row.payload = payload or {} + row.source_type = str(source_type or row.source_type or "system").strip()[:32] + row.source_ref = str(source_ref).strip()[:255] if source_ref else row.source_ref + await self.db.flush() + return row + + async def add_evidence( + self, + *, + evidence_type: str, + content_excerpt: str, + entity_id: Optional[int] = None, + relation_id: Optional[int] = None, + source_type: str = "system", + source_ref: Optional[str] = None, + metadata: Optional[dict[str, Any]] = None, + ) -> KGEvidence: + clean = str(content_excerpt or "").strip() + digest = hashlib.md5(clean.encode("utf-8")).hexdigest() + + row = KGEvidence( + tenant_id=self.tenant_id, + entity_id=int(entity_id) if entity_id is not None else None, + relation_id=int(relation_id) if relation_id is not None else None, + evidence_type=str(evidence_type or "snapshot").strip()[:40], + content_hash=digest, + content_excerpt=clean[:4000] if clean else "(empty)", + source_type=str(source_type or "system").strip()[:32], + source_ref=(str(source_ref).strip()[:255] if source_ref else None), + meta_payload=metadata or {}, + occurred_at=_utc_now_naive(), + ) + self.db.add(row) + await self.db.flush() + return row + + async def enqueue_ingest_event( + self, + *, + channel: str, + event_key: str, + payload: Optional[dict[str, Any]] = None, + status: str = "pending", + ) -> IngestEvent: + row = ( + await self.db.execute( + select(IngestEvent).where( + and_( + IngestEvent.tenant_id == self.tenant_id, + IngestEvent.channel == str(channel).strip()[:32], + IngestEvent.event_key == str(event_key).strip()[:128], + ) + ) + ) + ).scalar_one_or_none() + if row is None: + row = IngestEvent( + tenant_id=self.tenant_id, + channel=str(channel).strip()[:32], + event_key=str(event_key).strip()[:128], + status=str(status or "pending").strip()[:20], + payload=payload or {}, + ) + self.db.add(row) + else: + row.status = str(status or row.status or "pending").strip()[:20] + row.payload = payload or row.payload or {} + row.error_message = None + await self.db.flush() + return row + + async def set_ingest_offset( + self, + *, + channel: str, + offset_key: str, + offset_value: str, + payload: Optional[dict[str, Any]] = None, + ) -> IngestOffset: + row = ( + await self.db.execute( + select(IngestOffset).where( + and_( + IngestOffset.tenant_id == self.tenant_id, + IngestOffset.channel == str(channel).strip()[:32], + IngestOffset.offset_key == str(offset_key).strip()[:128], + ) + ) + ) + ).scalar_one_or_none() + + if row is None: + row = IngestOffset( + tenant_id=self.tenant_id, + channel=str(channel).strip()[:32], + offset_key=str(offset_key).strip()[:128], + offset_value=str(offset_value or "").strip()[:255], + payload=payload or {}, + ) + self.db.add(row) + else: + row.offset_value = str(offset_value or row.offset_value).strip()[:255] + row.payload = payload or row.payload or {} + await self.db.flush() + return row + + async def get_ingest_offset(self, *, channel: str, offset_key: str) -> Optional[str]: + row = ( + await self.db.execute( + select(IngestOffset).where( + and_( + IngestOffset.tenant_id == self.tenant_id, + IngestOffset.channel == str(channel).strip()[:32], + IngestOffset.offset_key == str(offset_key).strip()[:128], + ) + ) + ) + ).scalar_one_or_none() + if row is None: + return None + return str(row.offset_value or "").strip() or None + + @staticmethod + def _parse_iso_dt(value: Optional[str]) -> Optional[datetime]: + raw = str(value or "").strip() + if not raw: + return None + try: + dt = datetime.fromisoformat(raw) + return dt.replace(tzinfo=None) if dt.tzinfo else dt + except Exception: + return None + diff --git a/backend/app/services/project_knowledge_graph_service/mixin_cycle_run.py b/backend/app/services/project_knowledge_graph_service/mixin_cycle_run.py new file mode 100644 index 00000000..5041895b --- /dev/null +++ b/backend/app/services/project_knowledge_graph_service/mixin_cycle_run.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +from .shared import * + +class PKGCycleRunMixin: + async def run_incremental_cycle( + self, + *, + delta_limit: int = 1000, + event_batch: int = 200, + code_scan_hours: int = 6, + feature_scan_hours: int = 6, + code_scan_base_dir: Optional[str] = None, + run_source: str = "system", + ) -> dict[str, Any]: + started = _utc_now_naive() + sync_run = await self._open_sync_run( + run_type="incremental", + trigger_source=run_source, + input_payload={ + "delta_limit": int(delta_limit), + "event_batch": int(event_batch), + "code_scan_hours": int(code_scan_hours), + "feature_scan_hours": int(feature_scan_hours), + "code_scan_base_dir": str(code_scan_base_dir or ""), + }, + ) + run_id = int(sync_run.id) + run_started_at = sync_run.started_at or started + stage_results: dict[str, Any] = {} + stage_errors: dict[str, str] = {} + success_count = 0 + + try: + try: + stage_results["structured_delta"] = await self.sync_structured_delta(limit=delta_limit) + success_count += 1 + except Exception as exc: + await self.db.rollback() + stage_errors["structured_delta"] = str(exc)[:500] + stage_results["structured_delta"] = {"error": stage_errors["structured_delta"]} + + try: + stage_results["feishu_enqueue"] = await self.enqueue_feishu_delta_events(limit=min(delta_limit, 1000)) + success_count += 1 + except Exception as exc: + await self.db.rollback() + stage_errors["feishu_enqueue"] = str(exc)[:500] + stage_results["feishu_enqueue"] = {"error": stage_errors["feishu_enqueue"]} + + try: + stage_results["event_processing"] = await self.process_pending_events(max_batch=event_batch) + success_count += 1 + except Exception as exc: + await self.db.rollback() + stage_errors["event_processing"] = str(exc)[:500] + stage_results["event_processing"] = {"error": stage_errors["event_processing"]} + + try: + stage_results["code_scan"] = await self.sync_codebase_if_due( + base_dir=code_scan_base_dir, + interval_hours=code_scan_hours, + ) + success_count += 1 + except Exception as exc: + await self.db.rollback() + stage_errors["code_scan"] = str(exc)[:500] + stage_results["code_scan"] = {"error": stage_errors["code_scan"]} + + try: + stage_results["symbol_index"] = await self.sync_symbol_index_if_due( + base_dir=code_scan_base_dir, + interval_hours=code_scan_hours, + ) + success_count += 1 + except Exception as exc: + await self.db.rollback() + stage_errors["symbol_index"] = str(exc)[:500] + stage_results["symbol_index"] = {"error": stage_errors["symbol_index"]} + + try: + stage_results["feature_catalog"] = await self.sync_feature_catalog_if_due( + interval_hours=feature_scan_hours, + ) + success_count += 1 + except Exception as exc: + await self.db.rollback() + stage_errors["feature_catalog"] = str(exc)[:500] + stage_results["feature_catalog"] = {"error": stage_errors["feature_catalog"]} + + try: + stage_results["model_guardrails"] = await self.sync_model_guardrails_if_due( + interval_hours=feature_scan_hours, + ) + success_count += 1 + except Exception as exc: + await self.db.rollback() + stage_errors["model_guardrails"] = str(exc)[:500] + stage_results["model_guardrails"] = {"error": stage_errors["model_guardrails"]} + + ended = _utc_now_naive() + if success_count == 7: + final_status = "success" + elif success_count == 0: + final_status = "failed" + else: + final_status = "partial" + + payload = { + "tenant_id": self.tenant_id, + "started_at": started.isoformat(), + "ended_at": ended.isoformat(), + "run_id": run_id, + "run_status": final_status, + "success_stage_count": success_count, + "failed_stage_count": len(stage_errors), + "stage_errors": stage_errors, + **stage_results, + } + await self._close_sync_run( + row=sync_run, + status=final_status, + result_payload=payload, + error_message=("; ".join(f"{k}:{v}" for k, v in stage_errors.items()) if stage_errors else None), + started_at_hint=run_started_at, + ) + return payload + except Exception as exc: + await self.db.rollback() + ended = _utc_now_naive() + payload = { + "tenant_id": self.tenant_id, + "started_at": started.isoformat(), + "ended_at": ended.isoformat(), + "run_id": run_id, + "run_status": "failed", + "success_stage_count": success_count, + "failed_stage_count": max(1, len(stage_errors)), + "stage_errors": stage_errors, + **stage_results, + } + await self._close_sync_run( + row=sync_run, + status="failed", + result_payload=payload, + error_message=str(exc)[:1000], + started_at_hint=run_started_at, + ) + return payload + diff --git a/backend/app/services/project_knowledge_graph_service/mixin_event_queue.py b/backend/app/services/project_knowledge_graph_service/mixin_event_queue.py new file mode 100644 index 00000000..a8c203f2 --- /dev/null +++ b/backend/app/services/project_knowledge_graph_service/mixin_event_queue.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from .shared import * + +class PKGEventQueueMixin: + async def enqueue_feishu_delta_events(self, *, limit: int = 500) -> dict[str, Any]: + max_rows = max(1, min(int(limit), 2000)) + last_raw = await self.get_ingest_offset(channel="feishu_delta", offset_key="last_feishu_item_updated_at") + last_dt = self._parse_iso_dt(last_raw) or (datetime(1970, 1, 1)) + + rows = ( + await self.db.execute( + select(FeishuItem) + .where( + and_( + FeishuItem.tenant_id == self.tenant_id, + FeishuItem.updated_at >= last_dt, + ) + ) + .order_by(FeishuItem.updated_at.asc(), FeishuItem.id.asc()) + .limit(max_rows) + ) + ).scalars().all() + enqueued = 0 + newest_seen = last_dt + for item in rows: + event_key = str(item.feishu_message_id or f"feishu_item:{int(item.id)}") + await self.enqueue_ingest_event( + channel="feishu_delta", + event_key=event_key[:128], + payload={ + "feishu_item_id": int(item.id), + "matched_project_id": int(item.matched_project_id) if item.matched_project_id is not None else None, + "level": str(item.level or ""), + "route_status": str(item.route_status or ""), + }, + status="pending", + ) + enqueued += 1 + if item.updated_at and item.updated_at > newest_seen: + newest_seen = item.updated_at + + await self.set_ingest_offset( + channel="feishu_delta", + offset_key="last_feishu_item_updated_at", + offset_value=newest_seen.isoformat(), + payload={"batch_size": len(rows)}, + ) + await self.db.commit() + return { + "enqueued": enqueued, + "from_offset": last_dt.isoformat(), + "to_offset": newest_seen.isoformat(), + "candidate_count": len(rows), + } + + async def process_pending_events(self, *, max_batch: int = 200, max_retry: int = 3) -> dict[str, Any]: + batch = max(1, min(int(max_batch), 1000)) + retries = max(0, min(int(max_retry), 10)) + + rows = ( + await self.db.execute( + select(IngestEvent) + .where( + and_( + IngestEvent.tenant_id == self.tenant_id, + IngestEvent.status.in_(["pending", "retry"]), + ) + ) + .order_by(IngestEvent.created_at.asc(), IngestEvent.id.asc()) + .limit(batch) + ) + ).scalars().all() + + stats = {"processed": 0, "done": 0, "retry": 0, "dead_letter": 0} + for ev in rows: + stats["processed"] += 1 + try: + if str(ev.channel) == "feishu_delta": + await self._process_feishu_event(ev) + ev.status = "done" + ev.error_message = None + ev.processed_at = _utc_now_naive() + stats["done"] += 1 + except Exception as exc: + ev.retry_count = int(ev.retry_count or 0) + 1 + ev.error_message = str(exc)[:1000] + if ev.retry_count > retries: + ev.status = "dead_letter" + ev.processed_at = _utc_now_naive() + stats["dead_letter"] += 1 + else: + ev.status = "retry" + stats["retry"] += 1 + + await self.db.commit() + return stats + + async def _process_feishu_event(self, event: IngestEvent) -> None: + payload = event.payload if isinstance(event.payload, dict) else {} + item_id = payload.get("feishu_item_id") + if item_id is None: + return + item = ( + await self.db.execute( + select(FeishuItem).where( + and_( + FeishuItem.tenant_id == self.tenant_id, + FeishuItem.id == int(item_id), + ) + ) + ) + ).scalar_one_or_none() + if item is None: + return + + signal_entity = await self.upsert_entity( + entity_type="signal", + entity_key=f"feishu_item:{int(item.id)}", + display_name=str(item.raw_message or f"FeishuItem#{int(item.id)}")[:255], + payload={ + "feishu_item_id": int(item.id), + "level": str(item.level or ""), + "route_status": str(item.route_status or ""), + "matched_project_id": int(item.matched_project_id) if item.matched_project_id is not None else None, + }, + source_type="feishu", + source_ref=str(item.feishu_message_id or f"item:{int(item.id)}")[:255], + ) + await self.add_evidence( + evidence_type="feishu_delta", + content_excerpt=str(item.raw_message or "")[:1000] or f"feishu_item#{int(item.id)}", + entity_id=int(signal_entity.id), + source_type="feishu", + source_ref=str(item.feishu_message_id or f"item:{int(item.id)}")[:255], + metadata={"feishu_item_id": int(item.id)}, + ) + + if item.matched_project_id is not None: + project_entity = await self.upsert_entity( + entity_type="project", + entity_key=f"project:{int(item.matched_project_id)}", + display_name=f"Project#{int(item.matched_project_id)}", + payload={"project_id": int(item.matched_project_id)}, + source_type="db", + source_ref=f"projects:{int(item.matched_project_id)}", + ) + rel = await self.upsert_relation( + relation_type="matches_project", + subject_entity_id=int(signal_entity.id), + object_entity_id=int(project_entity.id), + source_type="feishu", + source_ref=str(item.feishu_message_id or f"item:{int(item.id)}")[:255], + ) + await self.add_evidence( + evidence_type="feishu_delta", + content_excerpt=f"feishu_item#{int(item.id)} matches project#{int(item.matched_project_id)}", + relation_id=int(rel.id), + source_type="feishu", + source_ref=str(item.feishu_message_id or f"item:{int(item.id)}")[:255], + metadata={"feishu_item_id": int(item.id)}, + ) + diff --git a/backend/app/services/project_knowledge_graph_service/mixin_feature_catalog.py b/backend/app/services/project_knowledge_graph_service/mixin_feature_catalog.py new file mode 100644 index 00000000..6142816e --- /dev/null +++ b/backend/app/services/project_knowledge_graph_service/mixin_feature_catalog.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +from .shared import * + +class PKGFeatureCatalogMixin: + async def sync_feature_catalog_if_due( + self, + *, + interval_hours: int = 6, + ) -> dict[str, Any]: + hours = max(1, int(interval_hours)) + offset_raw = await self.get_ingest_offset(channel="feature_catalog", offset_key="last_feature_catalog_sync_at") + last_sync_at = self._parse_iso_dt(offset_raw) + now = _utc_now_naive() + if last_sync_at is not None and now - last_sync_at < timedelta(hours=hours): + return { + "executed": False, + "reason": "not_due", + "last_sync_at": last_sync_at.isoformat(), + "next_due_at": (last_sync_at + timedelta(hours=hours)).isoformat(), + } + + payload = await self._sync_feature_catalog_entities(limit=300) + await self.enqueue_ingest_event( + channel="feature_catalog", + event_key=f"feature_catalog:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", + payload=payload, + status="done", + ) + await self.set_ingest_offset( + channel="feature_catalog", + offset_key="last_feature_catalog_sync_at", + offset_value=_utc_now_naive().isoformat(), + payload=payload, + ) + await self.db.commit() + return { + "executed": True, + "reason": "due", + "payload": payload, + } + + async def sync_feature_catalog_now(self) -> dict[str, Any]: + payload = await self._sync_feature_catalog_entities(limit=300) + await self.enqueue_ingest_event( + channel="feature_catalog", + event_key=f"feature_catalog:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", + payload=payload, + status="done", + ) + await self.set_ingest_offset( + channel="feature_catalog", + offset_key="last_feature_catalog_sync_at", + offset_value=_utc_now_naive().isoformat(), + payload=payload, + ) + await self.db.commit() + return payload + + async def _sync_feature_catalog_entities(self, *, limit: int = 300) -> dict[str, Any]: + snapshot = await context_cache_service.get_system_snapshot( + db=self.db, + tenant_id=self.tenant_id, + ) + platform = snapshot.get("platform_profile") if isinstance(snapshot.get("platform_profile"), dict) else {} + catalog = platform.get("feature_catalog") if isinstance(platform.get("feature_catalog"), list) else [] + rows = [row for row in catalog if isinstance(row, dict)][: max(1, min(int(limit), 1000))] + if not rows: + return {"features": 0, "routes": 0, "pages": 0, "apis": 0, "relations": 0, "evidences": 0} + + counters = {"features": 0, "routes": 0, "pages": 0, "apis": 0, "relations": 0, "evidences": 0} + route_entity_map: dict[str, int] = {} + page_entity_map: dict[str, int] = {} + api_entity_map: dict[str, int] = {} + + for row in rows: + name = str(row.get("name") or "").strip() or "unknown_feature" + route = str(row.get("route") or "").strip() + summary = str(row.get("summary") or "").strip() + page_file = str(row.get("page_file") or "").strip() + page_component = str(row.get("page_component") or "").strip() + apis = row.get("apis") if isinstance(row.get("apis"), list) else [] + aliases = row.get("aliases") if isinstance(row.get("aliases"), list) else [] + + feature_slug = (route or name).strip().lower()[:200] + feature_entity = await self.upsert_entity( + entity_type="feature", + entity_key=f"feature:{feature_slug}", + display_name=name[:255], + payload={ + "name": name, + "route": route, + "summary": summary, + "page_file": page_file, + "page_component": page_component, + "apis": [str(x) for x in apis[:20] if str(x).strip()], + "aliases": [str(x) for x in aliases[:20] if str(x).strip()], + }, + source_type="feature_catalog", + source_ref=(route or page_file or name)[:255], + ) + counters["features"] += 1 + await self.add_evidence( + evidence_type="feature_catalog", + content_excerpt=f"{name} | route={route or 'n/a'} | page={page_file or 'n/a'}", + entity_id=int(feature_entity.id), + source_type="feature_catalog", + source_ref=(route or page_file or name)[:255], + metadata={"feature_name": name, "route": route, "page_file": page_file}, + ) + counters["evidences"] += 1 + + route_entity_id: Optional[int] = None + if route: + if route not in route_entity_map: + route_entity = await self.upsert_entity( + entity_type="route", + entity_key=f"route:{route[:200]}", + display_name=route[:255], + payload={"route": route}, + source_type="feature_catalog", + source_ref=route[:255], + ) + route_entity_map[route] = int(route_entity.id) + counters["routes"] += 1 + route_entity_id = route_entity_map[route] + rel = await self.upsert_relation( + relation_type="feature_has_route", + subject_entity_id=int(feature_entity.id), + object_entity_id=int(route_entity_id), + source_type="feature_catalog", + source_ref=route[:255], + ) + counters["relations"] += 1 + await self.add_evidence( + evidence_type="feature_catalog", + content_excerpt=f"{name} has route {route}", + relation_id=int(rel.id), + source_type="feature_catalog", + source_ref=route[:255], + metadata={"feature_name": name, "route": route}, + ) + counters["evidences"] += 1 + + page_entity_id: Optional[int] = None + if page_file: + if page_file not in page_entity_map: + page_entity = await self.upsert_entity( + entity_type="page", + entity_key=f"page:{page_file[:220]}", + display_name=(page_component or page_file)[:255], + payload={"page_file": page_file, "page_component": page_component}, + source_type="feature_catalog", + source_ref=page_file[:255], + ) + page_entity_map[page_file] = int(page_entity.id) + counters["pages"] += 1 + page_entity_id = page_entity_map[page_file] + rel = await self.upsert_relation( + relation_type="feature_rendered_by", + subject_entity_id=int(feature_entity.id), + object_entity_id=int(page_entity_id), + source_type="feature_catalog", + source_ref=page_file[:255], + ) + counters["relations"] += 1 + await self.add_evidence( + evidence_type="feature_catalog", + content_excerpt=f"{name} rendered_by {page_file}", + relation_id=int(rel.id), + source_type="feature_catalog", + source_ref=page_file[:255], + metadata={"feature_name": name, "page_file": page_file}, + ) + counters["evidences"] += 1 + + if route_entity_id is not None and page_entity_id is not None: + rel = await self.upsert_relation( + relation_type="route_to_page", + subject_entity_id=int(route_entity_id), + object_entity_id=int(page_entity_id), + source_type="feature_catalog", + source_ref=(route or page_file)[:255], + ) + counters["relations"] += 1 + await self.add_evidence( + evidence_type="feature_catalog", + content_excerpt=f"{route} maps_to {page_file}", + relation_id=int(rel.id), + source_type="feature_catalog", + source_ref=(route or page_file)[:255], + metadata={"route": route, "page_file": page_file}, + ) + counters["evidences"] += 1 + + for raw_api in apis[:20]: + api_path = str(raw_api or "").strip() + if not api_path: + continue + if api_path not in api_entity_map: + api_entity = await self.upsert_entity( + entity_type="api_endpoint", + entity_key=f"api:{api_path[:220]}", + display_name=api_path[:255], + payload={"path": api_path}, + source_type="feature_catalog", + source_ref=api_path[:255], + ) + api_entity_map[api_path] = int(api_entity.id) + counters["apis"] += 1 + rel = await self.upsert_relation( + relation_type="feature_calls_api", + subject_entity_id=int(feature_entity.id), + object_entity_id=int(api_entity_map[api_path]), + source_type="feature_catalog", + source_ref=api_path[:255], + ) + counters["relations"] += 1 + await self.add_evidence( + evidence_type="feature_catalog", + content_excerpt=f"{name} calls {api_path}", + relation_id=int(rel.id), + source_type="feature_catalog", + source_ref=api_path[:255], + metadata={"feature_name": name, "api_path": api_path}, + ) + counters["evidences"] += 1 + + return counters + diff --git a/backend/app/services/project_knowledge_graph_service/mixin_guardrails.py b/backend/app/services/project_knowledge_graph_service/mixin_guardrails.py new file mode 100644 index 00000000..8484c98b --- /dev/null +++ b/backend/app/services/project_knowledge_graph_service/mixin_guardrails.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +from .shared import * + +class PKGGuardrailsMixin: + @staticmethod + def _routing_rule_candidate_paths() -> list[Path]: + candidates: list[Path] = [] + configured = str(os.getenv("OPENCLAW_ROUTING_RULES_PATH", "") or "").strip() + if configured: + candidates.append(Path(configured).expanduser()) + + backend_root = Path(__file__).resolve().parents[2] + project_root = Path(__file__).resolve().parents[3] + candidates.extend( + [ + project_root / ".openclaw" / "pmflow-routing-rules.json", + backend_root / ".openclaw" / "pmflow-routing-rules.json", + ] + ) + + deduped: list[Path] = [] + seen: set[str] = set() + for path in candidates: + key = str(path) + if key in seen: + continue + seen.add(key) + deduped.append(path) + return deduped + + @classmethod + def _resolve_guardrail_target_models(cls) -> dict[str, dict[str, str]]: + specs: dict[str, dict[str, str]] = { + "low": { + "tier": "low", + "provider": str(os.getenv("LLM_ROUTING_LOW_PROVIDER", "glm") or "glm").strip(), + "model": str(os.getenv("LLM_ROUTING_LOW_MODEL", "GLM-4.7") or "GLM-4.7").strip(), + "api_env": str(os.getenv("LLM_ROUTING_LOW_API_ENV", "GLM_API_KEY") or "GLM_API_KEY").strip(), + "source": "env_default", + "source_path": "", + "rule_name": "", + }, + "high": { + "tier": "high", + "provider": str(os.getenv("LLM_ROUTING_HIGH_PROVIDER", "openai") or "openai").strip(), + "model": str(os.getenv("LLM_ROUTING_HIGH_MODEL", "MiniMax-M2.5") or "MiniMax-M2.5").strip(), + "api_env": str(os.getenv("LLM_ROUTING_HIGH_API_ENV", "OPENAI_API_KEY") or "OPENAI_API_KEY").strip(), + "source": "env_default", + "source_path": "", + "rule_name": "", + }, + } + + for path in cls._routing_rule_candidate_paths(): + try: + content = path.read_text(encoding="utf-8") + payload = json.loads(content) + except Exception: + continue + rows = payload.get("rules") if isinstance(payload, dict) and isinstance(payload.get("rules"), list) else [] + if not rows: + continue + enabled_rows = [row for row in rows if isinstance(row, dict) and bool(row.get("enabled", True))] + enabled_rows.sort(key=lambda item: int(item.get("priority") or 0), reverse=True) + resolved_any = False + for tier in ("low", "high"): + for row in enabled_rows: + if str(row.get("tier") or "").strip().lower() != tier: + continue + model = str(row.get("model") or "").strip() + if not model: + continue + specs[tier] = { + "tier": tier, + "provider": str(row.get("provider") or specs[tier]["provider"]).strip(), + "model": model, + "api_env": str(row.get("api_env") or specs[tier]["api_env"]).strip(), + "source": "routing_rule", + "source_path": str(path), + "rule_name": str(row.get("name") or "").strip(), + } + resolved_any = True + break + if resolved_any and all(str(specs[tier].get("source") or "") == "routing_rule" for tier in ("low", "high")): + break + return specs + + async def _sync_model_guardrail_entities(self) -> dict[str, Any]: + counters = {"models": 0, "policies": 0, "interfaces": 0, "relations": 0, "evidences": 0} + model_specs = self._resolve_guardrail_target_models() + + policy_entity = await self.upsert_entity( + entity_type="governance_policy", + entity_key=DDL_GUARDRAIL_POLICY_KEY, + display_name=DDL_GUARDRAIL_POLICY_NAME, + payload={ + "policy_code": "ddl_via_interface_only", + "enforcement_level": "hard_block", + "blocked_sql_prefixes": DDL_BLOCKED_PREFIXES, + "required_execution_path": "api_or_migration_interface", + "enforced_by": [ + "backend/app/database.py", + "backend/app/services/llm.py", + "backend/app/config/ai_prompts.py", + ], + }, + source_type="governance", + source_ref="backend/app/database.py", + ) + counters["policies"] += 1 + await self.add_evidence( + evidence_type="policy_guardrail", + content_excerpt="Runtime DDL is blocked; model planning must route DDL via interface/migration only.", + entity_id=int(policy_entity.id), + source_type="governance", + source_ref="backend/app/database.py", + metadata={"blocked_sql_prefixes": DDL_BLOCKED_PREFIXES}, + ) + counters["evidences"] += 1 + + interface_specs = [ + { + "entity_key": "interface:api:/ai-steward/command/dispatch", + "display_name": "POST /ai-steward/command/dispatch", + "payload": {"path": "/ai-steward/command/dispatch", "kind": "api"}, + "source_ref": "backend/app/api/ai_steward.py", + }, + { + "entity_key": "interface:migration:alembic", + "display_name": "Alembic Migration Interface", + "payload": {"path": "alembic", "kind": "migration"}, + "source_ref": "backend/alembic/versions", + }, + ] + interface_ids: list[int] = [] + for item in interface_specs: + interface_entity = await self.upsert_entity( + entity_type="interface", + entity_key=str(item["entity_key"]), + display_name=str(item["display_name"]), + payload=dict(item["payload"]), + source_type="governance", + source_ref=str(item["source_ref"]), + ) + counters["interfaces"] += 1 + interface_ids.append(int(interface_entity.id)) + rel = await self.upsert_relation( + relation_type="policy_enforced_by_interface", + subject_entity_id=int(policy_entity.id), + object_entity_id=int(interface_entity.id), + source_type="governance", + source_ref=str(item["source_ref"]), + ) + counters["relations"] += 1 + await self.add_evidence( + evidence_type="policy_guardrail", + content_excerpt=f"{DDL_GUARDRAIL_POLICY_NAME} -> {item['display_name']}", + relation_id=int(rel.id), + source_type="governance", + source_ref=str(item["source_ref"]), + metadata=dict(item["payload"]), + ) + counters["evidences"] += 1 + + for tier in ("low", "high"): + spec = model_specs.get(tier) if isinstance(model_specs.get(tier), dict) else {} + model_name = str(spec.get("model") or "").strip() or ("GLM-4.7" if tier == "low" else "MiniMax-M2.5") + provider = str(spec.get("provider") or "").strip() + api_env = str(spec.get("api_env") or "").strip() + source = str(spec.get("source") or "env_default").strip() + source_ref = str(spec.get("source_path") or spec.get("rule_name") or "llm_routing_env").strip()[:255] + entity = await self.upsert_entity( + entity_type="ai_model", + entity_key=f"ai_model:{tier}:{model_name.lower()[:160]}", + display_name=model_name[:255], + payload={ + "tier": tier, + "provider": provider, + "model": model_name, + "api_env": api_env, + "source": source, + "routing_rule": str(spec.get("rule_name") or "").strip(), + }, + source_type="model_routing", + source_ref=source_ref or None, + ) + counters["models"] += 1 + rel = await self.upsert_relation( + relation_type="model_must_follow_policy", + subject_entity_id=int(entity.id), + object_entity_id=int(policy_entity.id), + source_type="governance", + source_ref=source_ref or None, + payload={"tier": tier, "policy_code": "ddl_via_interface_only"}, + ) + counters["relations"] += 1 + await self.add_evidence( + evidence_type="policy_guardrail", + content_excerpt=f"{tier} model {model_name} must follow {DDL_GUARDRAIL_POLICY_KEY}", + relation_id=int(rel.id), + source_type="governance", + source_ref=source_ref or None, + metadata={"tier": tier, "provider": provider, "model": model_name}, + ) + counters["evidences"] += 1 + + for interface_id in interface_ids: + rel = await self.upsert_relation( + relation_type="model_executes_via_interface", + subject_entity_id=int(entity.id), + object_entity_id=int(interface_id), + source_type="governance", + source_ref=source_ref or None, + payload={"tier": tier}, + ) + counters["relations"] += 1 + await self.add_evidence( + evidence_type="policy_guardrail", + content_excerpt=f"{tier} model {model_name} executes via controlled interface", + relation_id=int(rel.id), + source_type="governance", + source_ref=source_ref or None, + metadata={"tier": tier}, + ) + counters["evidences"] += 1 + + return counters + + async def sync_model_guardrails_if_due( + self, + *, + interval_hours: int = 6, + ) -> dict[str, Any]: + hours = max(1, int(interval_hours)) + offset_raw = await self.get_ingest_offset(channel="model_guardrail", offset_key="last_model_guardrail_sync_at") + last_sync_at = self._parse_iso_dt(offset_raw) + now = _utc_now_naive() + if last_sync_at is not None and now - last_sync_at < timedelta(hours=hours): + return { + "executed": False, + "reason": "not_due", + "last_sync_at": last_sync_at.isoformat(), + "next_due_at": (last_sync_at + timedelta(hours=hours)).isoformat(), + } + + payload = await self._sync_model_guardrail_entities() + await self.enqueue_ingest_event( + channel="model_guardrail", + event_key=f"model_guardrail:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", + payload=payload, + status="done", + ) + await self.set_ingest_offset( + channel="model_guardrail", + offset_key="last_model_guardrail_sync_at", + offset_value=_utc_now_naive().isoformat(), + payload=payload, + ) + await self.db.commit() + return { + "executed": True, + "reason": "due", + "payload": payload, + } + + async def sync_model_guardrails_now(self) -> dict[str, Any]: + payload = await self._sync_model_guardrail_entities() + await self.enqueue_ingest_event( + channel="model_guardrail", + event_key=f"model_guardrail:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", + payload=payload, + status="done", + ) + await self.set_ingest_offset( + channel="model_guardrail", + offset_key="last_model_guardrail_sync_at", + offset_value=_utc_now_naive().isoformat(), + payload=payload, + ) + await self.db.commit() + return payload + diff --git a/backend/app/services/project_knowledge_graph_service/mixin_structured_delta.py b/backend/app/services/project_knowledge_graph_service/mixin_structured_delta.py new file mode 100644 index 00000000..08a5bbcb --- /dev/null +++ b/backend/app/services/project_knowledge_graph_service/mixin_structured_delta.py @@ -0,0 +1,292 @@ +from __future__ import annotations + +from .shared import * + +class PKGStructuredDeltaMixin: + async def sync_structured_delta(self, *, limit: int = 1000) -> dict[str, Any]: + max_rows = max(1, min(int(limit), 5000)) + last_raw = await self.get_ingest_offset(channel="db_delta", offset_key="last_structured_sync_at") + last_dt = self._parse_iso_dt(last_raw) or (datetime(1970, 1, 1)) + counters = { + "projects": 0, + "tasks": 0, + "signals": 0, + "users": 0, + "relations": 0, + "evidences": 0, + "from_offset": last_dt.isoformat(), + } + newest_seen = last_dt + + project_rows = ( + await self.db.execute( + select(Project) + .where( + and_( + Project.tenant_id == self.tenant_id, + Project.updated_at >= last_dt, + ) + ) + .order_by(Project.updated_at.asc(), Project.id.asc()) + .limit(max_rows) + ) + ).scalars().all() + project_map: dict[int, int] = {} + for p in project_rows: + entity = await self.upsert_entity( + entity_type="project", + entity_key=f"project:{int(p.id)}", + display_name=str(p.title or p.name or f"Project#{int(p.id)}"), + payload={ + "project_id": int(p.id), + "status": str(p.status or "unknown"), + "owner_id": int(p.owner_id) if p.owner_id is not None else None, + }, + source_type="db", + source_ref=f"projects:{int(p.id)}", + ) + project_map[int(p.id)] = int(entity.id) + counters["projects"] += 1 + if p.updated_at and p.updated_at > newest_seen: + newest_seen = p.updated_at + + user_rows = ( + await self.db.execute( + select(User) + .where( + and_( + User.tenant_id == self.tenant_id, + User.updated_at >= last_dt, + ) + ) + .order_by(User.updated_at.asc(), User.id.asc()) + .limit(max_rows) + ) + ).scalars().all() + user_map: dict[int, int] = {} + for u in user_rows: + entity = await self.upsert_entity( + entity_type="user", + entity_key=f"user:{int(u.id)}", + display_name=str(u.name or f"User#{int(u.id)}"), + payload={ + "user_id": int(u.id), + "role": str(u.role or "unknown"), + "is_active": bool(u.is_active), + }, + source_type="db", + source_ref=f"users:{int(u.id)}", + ) + user_map[int(u.id)] = int(entity.id) + counters["users"] += 1 + if u.updated_at and u.updated_at > newest_seen: + newest_seen = u.updated_at + + task_rows = ( + await self.db.execute( + select(Task) + .where( + and_( + Task.tenant_id == self.tenant_id, + Task.updated_at >= last_dt, + ) + ) + .order_by(Task.updated_at.asc(), Task.id.asc()) + .limit(max_rows) + ) + ).scalars().all() + for t in task_rows: + task_entity = await self.upsert_entity( + entity_type="task", + entity_key=f"task:{int(t.id)}", + display_name=str(t.title or f"Task#{int(t.id)}"), + payload={ + "task_id": int(t.id), + "project_id": int(t.project_id) if t.project_id is not None else None, + "assignee_id": int(t.assignee_id) if t.assignee_id is not None else None, + "status": str(t.status or "unknown"), + }, + source_type="db", + source_ref=f"tasks:{int(t.id)}", + ) + counters["tasks"] += 1 + if t.updated_at and t.updated_at > newest_seen: + newest_seen = t.updated_at + + if t.project_id is not None: + target_pid = int(t.project_id) + if target_pid not in project_map: + p = ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == target_pid, + ) + ) + ) + ).scalar_one_or_none() + if p is not None: + p_entity = await self.upsert_entity( + entity_type="project", + entity_key=f"project:{int(p.id)}", + display_name=str(p.title or p.name or f"Project#{int(p.id)}"), + payload={ + "project_id": int(p.id), + "status": str(p.status or "unknown"), + "owner_id": int(p.owner_id) if p.owner_id is not None else None, + }, + source_type="db", + source_ref=f"projects:{int(p.id)}", + ) + project_map[int(p.id)] = int(p_entity.id) + if target_pid in project_map: + rel = await self.upsert_relation( + relation_type="belongs_to", + subject_entity_id=int(task_entity.id), + object_entity_id=int(project_map[target_pid]), + source_type="db", + source_ref=f"tasks:{int(t.id)}", + ) + counters["relations"] += 1 + await self.add_evidence( + evidence_type="db_delta", + content_excerpt=f"task#{int(t.id)} belongs_to project#{target_pid}", + relation_id=int(rel.id), + source_type="db", + source_ref=f"tasks:{int(t.id)}", + metadata={"table": "tasks"}, + ) + counters["evidences"] += 1 + + if t.assignee_id is not None: + uid = int(t.assignee_id) + if uid not in user_map: + u = ( + await self.db.execute( + select(User).where(and_(User.tenant_id == self.tenant_id, User.id == uid)) + ) + ).scalar_one_or_none() + if u is not None: + u_entity = await self.upsert_entity( + entity_type="user", + entity_key=f"user:{uid}", + display_name=str(u.name or f"User#{uid}"), + payload={ + "user_id": uid, + "role": str(u.role or "unknown"), + "is_active": bool(u.is_active), + }, + source_type="db", + source_ref=f"users:{uid}", + ) + user_map[uid] = int(u_entity.id) + if uid in user_map: + rel = await self.upsert_relation( + relation_type="assigned_to", + subject_entity_id=int(task_entity.id), + object_entity_id=int(user_map[uid]), + source_type="db", + source_ref=f"tasks:{int(t.id)}", + ) + counters["relations"] += 1 + await self.add_evidence( + evidence_type="db_delta", + content_excerpt=f"task#{int(t.id)} assigned_to user#{uid}", + relation_id=int(rel.id), + source_type="db", + source_ref=f"tasks:{int(t.id)}", + metadata={"table": "tasks"}, + ) + counters["evidences"] += 1 + + signal_rows = ( + await self.db.execute( + select(Signal) + .where( + and_( + Signal.tenant_id == self.tenant_id, + Signal.updated_at >= last_dt, + ) + ) + .order_by(Signal.updated_at.asc(), Signal.id.asc()) + .limit(max_rows) + ) + ).scalars().all() + for s in signal_rows: + signal_entity = await self.upsert_entity( + entity_type="signal", + entity_key=f"signal:{int(s.id)}", + display_name=str(s.title or s.content or f"Signal#{int(s.id)}")[:255], + payload={ + "signal_id": int(s.id), + "priority": str(s.priority or ""), + "status": str(s.status or ""), + "matched_project_id": ( + int(s.matched_project_id) if getattr(s, "matched_project_id", None) is not None else None + ), + }, + source_type="db", + source_ref=f"signals:{int(s.id)}", + ) + counters["signals"] += 1 + if s.updated_at and s.updated_at > newest_seen: + newest_seen = s.updated_at + + pid = int(s.matched_project_id) if getattr(s, "matched_project_id", None) is not None else None + if pid is not None: + if pid not in project_map: + p = ( + await self.db.execute( + select(Project).where(and_(Project.tenant_id == self.tenant_id, Project.id == pid)) + ) + ).scalar_one_or_none() + if p is not None: + p_entity = await self.upsert_entity( + entity_type="project", + entity_key=f"project:{pid}", + display_name=str(p.title or p.name or f"Project#{pid}"), + payload={ + "project_id": pid, + "status": str(p.status or "unknown"), + "owner_id": int(p.owner_id) if p.owner_id is not None else None, + }, + source_type="db", + source_ref=f"projects:{pid}", + ) + project_map[pid] = int(p_entity.id) + if pid in project_map: + rel = await self.upsert_relation( + relation_type="matches_project", + subject_entity_id=int(signal_entity.id), + object_entity_id=int(project_map[pid]), + source_type="db", + source_ref=f"signals:{int(s.id)}", + ) + counters["relations"] += 1 + await self.add_evidence( + evidence_type="db_delta", + content_excerpt=f"signal#{int(s.id)} matches project#{pid}", + relation_id=int(rel.id), + source_type="db", + source_ref=f"signals:{int(s.id)}", + metadata={"table": "signals"}, + ) + counters["evidences"] += 1 + + await self.enqueue_ingest_event( + channel="db_delta", + event_key=f"db_delta:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", + payload=counters, + status="done", + ) + await self.set_ingest_offset( + channel="db_delta", + offset_key="last_structured_sync_at", + offset_value=newest_seen.isoformat(), + payload={"counters": counters}, + ) + await self.db.commit() + counters["to_offset"] = newest_seen.isoformat() + return counters + diff --git a/backend/app/services/project_knowledge_graph_service/mixin_structured_snapshot.py b/backend/app/services/project_knowledge_graph_service/mixin_structured_snapshot.py new file mode 100644 index 00000000..5205aba5 --- /dev/null +++ b/backend/app/services/project_knowledge_graph_service/mixin_structured_snapshot.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +from .shared import * + +class PKGStructuredSnapshotMixin: + async def sync_structured_entities(self, *, limit: int = 1000) -> dict[str, Any]: + max_rows = max(1, min(int(limit), 5000)) + counters = { + "projects": 0, + "tasks": 0, + "signals": 0, + "users": 0, + "relations": 0, + "evidences": 0, + } + + project_map: dict[int, int] = {} + projects = ( + await self.db.execute( + select(Project) + .where(Project.tenant_id == self.tenant_id) + .order_by(Project.updated_at.desc(), Project.id.desc()) + .limit(max_rows) + ) + ).scalars().all() + for p in projects: + entity = await self.upsert_entity( + entity_type="project", + entity_key=f"project:{int(p.id)}", + display_name=str(p.title or p.name or f"Project#{int(p.id)}"), + payload={ + "project_id": int(p.id), + "status": str(p.status or "unknown"), + "owner_id": int(p.owner_id) if p.owner_id is not None else None, + }, + source_type="db", + source_ref=f"projects:{int(p.id)}", + ) + project_map[int(p.id)] = int(entity.id) + counters["projects"] += 1 + + user_map: dict[int, int] = {} + users = ( + await self.db.execute( + select(User) + .where(User.tenant_id == self.tenant_id) + .order_by(User.updated_at.desc(), User.id.desc()) + .limit(max_rows) + ) + ).scalars().all() + for u in users: + entity = await self.upsert_entity( + entity_type="user", + entity_key=f"user:{int(u.id)}", + display_name=str(u.name or f"User#{int(u.id)}"), + payload={ + "user_id": int(u.id), + "role": str(u.role or "unknown"), + "is_active": bool(u.is_active), + }, + source_type="db", + source_ref=f"users:{int(u.id)}", + ) + user_map[int(u.id)] = int(entity.id) + counters["users"] += 1 + + tasks = ( + await self.db.execute( + select(Task) + .where(Task.tenant_id == self.tenant_id) + .order_by(Task.updated_at.desc(), Task.id.desc()) + .limit(max_rows) + ) + ).scalars().all() + for t in tasks: + task_entity = await self.upsert_entity( + entity_type="task", + entity_key=f"task:{int(t.id)}", + display_name=str(t.title or f"Task#{int(t.id)}"), + payload={ + "task_id": int(t.id), + "project_id": int(t.project_id) if t.project_id is not None else None, + "assignee_id": int(t.assignee_id) if t.assignee_id is not None else None, + "status": str(t.status or "unknown"), + }, + source_type="db", + source_ref=f"tasks:{int(t.id)}", + ) + counters["tasks"] += 1 + + if t.project_id is not None and int(t.project_id) in project_map: + rel = await self.upsert_relation( + relation_type="belongs_to", + subject_entity_id=int(task_entity.id), + object_entity_id=int(project_map[int(t.project_id)]), + source_type="db", + source_ref=f"tasks:{int(t.id)}", + ) + counters["relations"] += 1 + await self.add_evidence( + evidence_type="db_row", + content_excerpt=f"task#{int(t.id)} belongs_to project#{int(t.project_id)}", + relation_id=int(rel.id), + source_type="db", + source_ref=f"tasks:{int(t.id)}", + metadata={"table": "tasks"}, + ) + counters["evidences"] += 1 + + if t.assignee_id is not None and int(t.assignee_id) in user_map: + rel = await self.upsert_relation( + relation_type="assigned_to", + subject_entity_id=int(task_entity.id), + object_entity_id=int(user_map[int(t.assignee_id)]), + source_type="db", + source_ref=f"tasks:{int(t.id)}", + ) + counters["relations"] += 1 + await self.add_evidence( + evidence_type="db_row", + content_excerpt=f"task#{int(t.id)} assigned_to user#{int(t.assignee_id)}", + relation_id=int(rel.id), + source_type="db", + source_ref=f"tasks:{int(t.id)}", + metadata={"table": "tasks"}, + ) + counters["evidences"] += 1 + + signals = ( + await self.db.execute( + select(Signal) + .where(Signal.tenant_id == self.tenant_id) + .order_by(Signal.updated_at.desc(), Signal.id.desc()) + .limit(max_rows) + ) + ).scalars().all() + for s in signals: + signal_entity = await self.upsert_entity( + entity_type="signal", + entity_key=f"signal:{int(s.id)}", + display_name=str(s.title or s.content or f"Signal#{int(s.id)}")[:255], + payload={ + "signal_id": int(s.id), + "priority": str(s.priority or ""), + "status": str(s.status or ""), + "matched_project_id": ( + int(s.matched_project_id) if getattr(s, "matched_project_id", None) is not None else None + ), + }, + source_type="db", + source_ref=f"signals:{int(s.id)}", + ) + counters["signals"] += 1 + + target_pid = int(s.matched_project_id) if getattr(s, "matched_project_id", None) is not None else None + if target_pid is not None and target_pid in project_map: + rel = await self.upsert_relation( + relation_type="matches_project", + subject_entity_id=int(signal_entity.id), + object_entity_id=int(project_map[target_pid]), + source_type="db", + source_ref=f"signals:{int(s.id)}", + ) + counters["relations"] += 1 + await self.add_evidence( + evidence_type="db_row", + content_excerpt=f"signal#{int(s.id)} matches project#{target_pid}", + relation_id=int(rel.id), + source_type="db", + source_ref=f"signals:{int(s.id)}", + metadata={"table": "signals"}, + ) + counters["evidences"] += 1 + + counters["model_guardrails"] = await self._sync_model_guardrail_entities() + + await self.enqueue_ingest_event( + channel="db_delta", + event_key=f"structured_sync:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", + payload={"limit": max_rows, "counters": counters}, + status="done", + ) + await self.set_ingest_offset( + channel="db_delta", + offset_key="last_structured_sync_at", + offset_value=_utc_now_naive().isoformat(), + payload={"counters": counters}, + ) + + await self.db.commit() + return counters + diff --git a/backend/app/services/project_knowledge_graph_service/mixin_symbol_sync.py b/backend/app/services/project_knowledge_graph_service/mixin_symbol_sync.py new file mode 100644 index 00000000..7e405127 --- /dev/null +++ b/backend/app/services/project_knowledge_graph_service/mixin_symbol_sync.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +from .shared import * + +class PKGSymbolSyncMixin: + async def sync_codebase_if_due( + self, + *, + base_dir: Optional[str] = None, + interval_hours: int = 6, + ) -> dict[str, Any]: + hours = max(1, int(interval_hours)) + offset_raw = await self.get_ingest_offset(channel="code_scan", offset_key="last_code_scan_at") + last_scan_at = self._parse_iso_dt(offset_raw) + now = _utc_now_naive() + if last_scan_at is not None and now - last_scan_at < timedelta(hours=hours): + return { + "executed": False, + "reason": "not_due", + "last_scan_at": last_scan_at.isoformat(), + "next_due_at": (last_scan_at + timedelta(hours=hours)).isoformat(), + } + payload = await self.sync_codebase_snapshot(base_dir=base_dir) + return { + "executed": True, + "reason": "due", + "payload": payload, + } + + async def sync_symbol_index_if_due( + self, + *, + base_dir: Optional[str] = None, + interval_hours: int = 6, + max_files: int = 500, + ) -> dict[str, Any]: + hours = max(1, int(interval_hours)) + offset_raw = await self.get_ingest_offset(channel="symbol_scan", offset_key="last_symbol_scan_at") + last_scan_at = self._parse_iso_dt(offset_raw) + now = _utc_now_naive() + if last_scan_at is not None and now - last_scan_at < timedelta(hours=hours): + return { + "executed": False, + "reason": "not_due", + "last_scan_at": last_scan_at.isoformat(), + "next_due_at": (last_scan_at + timedelta(hours=hours)).isoformat(), + } + + symbol_service = SymbolIndexService(self.db, tenant_id=self.tenant_id) + payload = await symbol_service.sync_codebase_symbols( + base_dir=base_dir, + max_files=max_files, + force_reindex=False, + ) + symbol_graph = await self._sync_symbol_entities(limit=800) + payload["kg_symbol_sync"] = symbol_graph + await self.enqueue_ingest_event( + channel="symbol_scan", + event_key=f"symbol_scan:{_utc_now_naive().strftime('%Y%m%d%H%M%S')}", + payload=payload, + status="done", + ) + await self.set_ingest_offset( + channel="symbol_scan", + offset_key="last_symbol_scan_at", + offset_value=_utc_now_naive().isoformat(), + payload=payload, + ) + await self.db.commit() + return { + "executed": True, + "reason": "due", + "payload": payload, + } + + async def _sync_symbol_entities(self, *, limit: int = 800) -> dict[str, Any]: + max_rows = max(10, min(int(limit), 3000)) + defs = ( + await self.db.execute( + select(SymbolDefinition) + .where(SymbolDefinition.tenant_id == self.tenant_id) + .order_by(SymbolDefinition.updated_at.desc(), SymbolDefinition.id.desc()) + .limit(max_rows) + ) + ).scalars().all() + if not defs: + return {"symbols": 0, "modules": 0, "relations": 0, "evidences": 0} + + counters = {"symbols": 0, "modules": 0, "relations": 0, "evidences": 0} + module_entity_map: dict[str, int] = {} + symbol_entity_map: dict[int, int] = {} + name_entity_map: dict[str, int] = {} + qn_entity_map: dict[str, int] = {} + + for row in defs: + qn = str(row.qualified_name or row.symbol_name or f"symbol#{int(row.id)}") + symbol_entity = await self.upsert_entity( + entity_type="symbol", + entity_key=f"symbol:{int(row.id)}", + display_name=qn[:255], + payload={ + "symbol_id": int(row.id), + "symbol_name": str(row.symbol_name or ""), + "qualified_name": str(row.qualified_name or ""), + "symbol_type": str(row.symbol_type or ""), + "language": str(row.language or ""), + "file_path": str(row.file_path or ""), + "start_line": int(row.start_line) if row.start_line is not None else None, + "end_line": int(row.end_line) if row.end_line is not None else None, + }, + source_type="symbol_index", + source_ref=str(row.file_path or "")[:255], + ) + counters["symbols"] += 1 + symbol_entity_map[int(row.id)] = int(symbol_entity.id) + if str(row.symbol_name or "").strip(): + name_entity_map[str(row.symbol_name).strip()] = int(symbol_entity.id) + if str(row.qualified_name or "").strip(): + qn_entity_map[str(row.qualified_name).strip()] = int(symbol_entity.id) + + file_key = str(row.file_path or "").strip() + if not file_key: + continue + module_entity_id = module_entity_map.get(file_key) + if module_entity_id is None: + module_entity = await self.upsert_entity( + entity_type="module", + entity_key=f"module:{file_key}", + display_name=file_key[:255], + payload={"file_path": file_key}, + source_type="symbol_index", + source_ref=file_key[:255], + ) + module_entity_id = int(module_entity.id) + module_entity_map[file_key] = module_entity_id + counters["modules"] += 1 + rel = await self.upsert_relation( + relation_type="defined_in", + subject_entity_id=int(symbol_entity.id), + object_entity_id=int(module_entity_id), + source_type="symbol_index", + source_ref=file_key[:255], + payload={ + "symbol_id": int(row.id), + "symbol_type": str(row.symbol_type or ""), + "language": str(row.language or ""), + }, + ) + counters["relations"] += 1 + await self.add_evidence( + evidence_type="symbol_index", + content_excerpt=( + f"{str(row.symbol_name or '')} defined_in {file_key}:" + f"{int(row.start_line) if row.start_line is not None else 0}" + ), + relation_id=int(rel.id), + source_type="symbol_index", + source_ref=file_key[:255], + metadata={"symbol_id": int(row.id), "file_path": file_key}, + ) + counters["evidences"] += 1 + + refs = ( + await self.db.execute( + select(SymbolReference) + .where(SymbolReference.tenant_id == self.tenant_id) + .order_by(SymbolReference.updated_at.desc(), SymbolReference.id.desc()) + .limit(max_rows * 3) + ) + ).scalars().all() + for row in refs: + source_entity_id = None + if row.source_symbol_id is not None: + source_entity_id = symbol_entity_map.get(int(row.source_symbol_id)) + if source_entity_id is None: + continue + target_entity_id = None + target_qn = str(row.target_qualified_name or "").strip() + target_name = str(row.target_symbol_name or "").strip() + if target_qn: + target_entity_id = qn_entity_map.get(target_qn) + if target_entity_id is None and target_name: + target_entity_id = name_entity_map.get(target_name) + if target_entity_id is None: + continue + rel_type = str(row.relation_type or "references").strip() + rel = await self.upsert_relation( + relation_type=rel_type[:40], + subject_entity_id=int(source_entity_id), + object_entity_id=int(target_entity_id), + source_type="symbol_index", + source_ref=str(row.file_path or "")[:255], + payload={ + "symbol_reference_id": int(row.id), + "target_symbol_name": target_name, + "target_qualified_name": target_qn, + }, + ) + counters["relations"] += 1 + await self.add_evidence( + evidence_type="symbol_reference", + content_excerpt=f"{int(source_entity_id)} {rel_type} {target_name or target_qn}", + relation_id=int(rel.id), + source_type="symbol_index", + source_ref=str(row.file_path or "")[:255], + metadata={ + "symbol_reference_id": int(row.id), + "target_symbol_name": target_name, + "target_qualified_name": target_qn, + }, + ) + counters["evidences"] += 1 + return counters + diff --git a/backend/app/services/project_knowledge_graph_service/mixin_sync_runs.py b/backend/app/services/project_knowledge_graph_service/mixin_sync_runs.py new file mode 100644 index 00000000..e1980d99 --- /dev/null +++ b/backend/app/services/project_knowledge_graph_service/mixin_sync_runs.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +from .shared import * + +class PKGSyncRunsMixin: + async def _open_sync_run( + self, + *, + run_type: str, + trigger_source: str, + input_payload: Optional[dict[str, Any]] = None, + ) -> KGSyncRun: + now = _utc_now_naive() + row = KGSyncRun( + tenant_id=self.tenant_id, + run_type=str(run_type or "incremental").strip()[:40], + trigger_source=str(trigger_source or "system").strip()[:40], + status="running", + started_at=now, + input_payload=input_payload or {}, + result_payload={}, + ) + self.db.add(row) + await self.db.flush() + await self.db.commit() + await self.db.refresh(row) + return row + + async def _close_sync_run( + self, + *, + row: Optional[KGSyncRun], + status: str, + result_payload: Optional[dict[str, Any]] = None, + error_message: Optional[str] = None, + started_at_hint: Optional[datetime] = None, + ) -> None: + if row is None: + return + now = _utc_now_naive() + started = started_at_hint or row.started_at or now + row.status = str(status or "unknown").strip()[:20] + row.ended_at = now + row.duration_ms = max(int((now - started).total_seconds() * 1000), 0) + row.result_payload = result_payload or {} + row.error_message = (str(error_message).strip()[:2000] if error_message else None) + await self.db.commit() + + async def list_sync_runs( + self, + *, + limit: int = 50, + run_type: Optional[str] = None, + status: Optional[str] = None, + ) -> dict[str, Any]: + max_rows = max(1, min(int(limit), 200)) + stmt = select(KGSyncRun).where(KGSyncRun.tenant_id == self.tenant_id) + if run_type: + stmt = stmt.where(KGSyncRun.run_type == str(run_type).strip()[:40]) + if status: + stmt = stmt.where(KGSyncRun.status == str(status).strip()[:20]) + rows = ( + await self.db.execute(stmt.order_by(KGSyncRun.started_at.desc(), KGSyncRun.id.desc()).limit(max_rows)) + ).scalars().all() + + items = [] + for row in rows: + items.append( + { + "id": int(row.id), + "run_type": str(row.run_type or ""), + "trigger_source": str(row.trigger_source or ""), + "status": str(row.status or ""), + "started_at": row.started_at.isoformat() if row.started_at else None, + "ended_at": row.ended_at.isoformat() if row.ended_at else None, + "duration_ms": int(row.duration_ms or 0), + "input_payload": row.input_payload or {}, + "result_payload": row.result_payload or {}, + "error_message": str(row.error_message or ""), + } + ) + return { + "tenant_id": self.tenant_id, + "count": len(items), + "items": items, + } + + async def get_sync_slo(self, *, lookback_hours: int = 24) -> dict[str, Any]: + hours = max(1, min(int(lookback_hours), 24 * 30)) + now = _utc_now_naive() + since = now - timedelta(hours=hours) + + runs = ( + await self.db.execute( + select(KGSyncRun).where( + and_( + KGSyncRun.tenant_id == self.tenant_id, + KGSyncRun.started_at >= since, + KGSyncRun.run_type == "incremental", + ) + ) + ) + ).scalars().all() + + total = len(runs) + success = sum(1 for x in runs if str(x.status or "") == "success") + partial = sum(1 for x in runs if str(x.status or "") == "partial") + failed = sum(1 for x in runs if str(x.status or "") == "failed") + avg_duration_ms = ( + int(sum(int(x.duration_ms or 0) for x in runs) / total) + if total > 0 + else 0 + ) + + latest_success = ( + await self.db.execute( + select(KGSyncRun) + .where( + and_( + KGSyncRun.tenant_id == self.tenant_id, + KGSyncRun.run_type == "incremental", + KGSyncRun.status == "success", + ) + ) + .order_by(KGSyncRun.started_at.desc(), KGSyncRun.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + + dead_letter = ( + await self.db.execute( + select(func.count(IngestEvent.id)).where( + and_( + IngestEvent.tenant_id == self.tenant_id, + IngestEvent.status == "dead_letter", + ) + ) + ) + ).scalar_one() + pending_events = ( + await self.db.execute( + select(func.count(IngestEvent.id)).where( + and_( + IngestEvent.tenant_id == self.tenant_id, + IngestEvent.status.in_(["pending", "retry"]), + ) + ) + ) + ).scalar_one() + + async def _freshness_minutes(channel: str, offset_key: str) -> Optional[int]: + raw = await self.get_ingest_offset(channel=channel, offset_key=offset_key) + dt = self._parse_iso_dt(raw) + if dt is None: + return None + return max(int((now - dt).total_seconds() // 60), 0) + + return { + "tenant_id": self.tenant_id, + "lookback_hours": hours, + "window_started_at": since.isoformat(), + "window_ended_at": now.isoformat(), + "runs_total": total, + "runs_success": success, + "runs_partial": partial, + "runs_failed": failed, + "success_rate": round((success / total), 4) if total > 0 else 0.0, + "avg_duration_ms": avg_duration_ms, + "latest_success_at": latest_success.started_at.isoformat() if latest_success and latest_success.started_at else None, + "dead_letter_events": int(dead_letter or 0), + "pending_events": int(pending_events or 0), + "freshness_minutes": { + "db_structured": await _freshness_minutes("db_delta", "last_structured_sync_at"), + "feishu_delta": await _freshness_minutes("feishu_delta", "last_feishu_item_updated_at"), + "code_scan": await _freshness_minutes("code_scan", "last_code_scan_at"), + "symbol_scan": await _freshness_minutes("symbol_scan", "last_symbol_scan_at"), + "feature_catalog": await _freshness_minutes("feature_catalog", "last_feature_catalog_sync_at"), + }, + } + diff --git a/backend/app/services/project_knowledge_graph_service/service.py b/backend/app/services/project_knowledge_graph_service/service.py new file mode 100644 index 00000000..6adb67ae --- /dev/null +++ b/backend/app/services/project_knowledge_graph_service/service.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from .mixin_codebase_overview import PKGCodebaseOverviewMixin +from .mixin_crud_ingest import PKGCrudIngestMixin +from .mixin_cycle_run import PKGCycleRunMixin +from .mixin_event_queue import PKGEventQueueMixin +from .mixin_feature_catalog import PKGFeatureCatalogMixin +from .mixin_guardrails import PKGGuardrailsMixin +from .mixin_structured_delta import PKGStructuredDeltaMixin +from .mixin_structured_snapshot import PKGStructuredSnapshotMixin +from .mixin_symbol_sync import PKGSymbolSyncMixin +from .mixin_sync_runs import PKGSyncRunsMixin + + +class ProjectKnowledgeGraphService( + PKGCrudIngestMixin, + PKGSyncRunsMixin, + PKGStructuredDeltaMixin, + PKGEventQueueMixin, + PKGSymbolSyncMixin, + PKGGuardrailsMixin, + PKGFeatureCatalogMixin, + PKGCycleRunMixin, + PKGStructuredSnapshotMixin, + PKGCodebaseOverviewMixin, +): + pass + + +__all__ = ["ProjectKnowledgeGraphService"] diff --git a/backend/app/services/project_knowledge_graph_service/shared.py b/backend/app/services/project_knowledge_graph_service/shared.py new file mode 100644 index 00000000..de5cfd7d --- /dev/null +++ b/backend/app/services/project_knowledge_graph_service/shared.py @@ -0,0 +1,58 @@ +"""Project Knowledge Graph service. + +第一阶段目标: +1. 构建统一实体/关系/证据写入能力 +2. 支持结构化数据(project/task/signal/user)同步 +3. 支持代码目录扫描快照 +4. 提供图谱概览统计 +""" + +from __future__ import annotations + +import hashlib +import json +import os +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Optional + +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...models import ( + IngestEvent, + IngestOffset, + KGEvidence, + KGEntity, + KGRelation, + KGSyncRun, + Project, + FeishuItem, + Signal, + SymbolDefinition, + SymbolReference, + Task, + User, +) +from ..context_cache import context_cache_service +from ..symbol_index_service import SymbolIndexService + + +from ...utils.timezone import now_cn_naive +def _utc_now_naive() -> datetime: + return now_cn_naive() + + +DDL_BLOCKED_PREFIXES = [ + "CREATE", + "ALTER", + "DROP", + "TRUNCATE", + "RENAME", + "COMMENT", + "GRANT", + "REVOKE", +] +DDL_GUARDRAIL_POLICY_KEY = "policy:ddl_via_interface_only" +DDL_GUARDRAIL_POLICY_NAME = "DDL 只能通过受控接口/迁移流程" + diff --git a/backend/app/services/scheduler_center.py b/backend/app/services/scheduler_center.py index ff4ea6bd..a0185b7c 100644 --- a/backend/app/services/scheduler_center.py +++ b/backend/app/services/scheduler_center.py @@ -1,448 +1,15 @@ -"""轻量级调度中心。""" +"""Compatibility exports for scheduler center.""" -from __future__ import annotations +from .scheduler_center_core import ( + AIPMOCadenceScheduler, + KnowledgeGraphIncrementalScheduler, + KnowledgeUrlRefreshScheduler, + WorkloadRecalcScheduler, +) -import asyncio -import os -from datetime import datetime, timedelta -from typing import Optional - -from sqlalchemy import and_, or_, select -from sqlalchemy.ext.asyncio import async_sessionmaker - -from ..core.logging import get_logger -from ..models import KnowledgeBaseDoc, PortfolioSnapshot, Project -from .adaptive_rag_service import AdaptiveRAGService -from .ai_pmo_cadence_service import AIPMOCadenceService -from .project_knowledge_graph import ProjectKnowledgeGraphService -from . import workload_service -from . import workload_alert_service - -logger = get_logger(__name__) - - -class WorkloadRecalcScheduler: - """每日 00:00 重算 workload_ratio。""" - - def __init__(self, session_factory: async_sessionmaker): - self._session_factory = session_factory - self._task: Optional[asyncio.Task] = None - self._stopped = asyncio.Event() - - def start(self) -> None: - if self._task and not self._task.done(): - return - self._stopped.clear() - self._task = asyncio.create_task(self._run_loop(), name="workload-recalc-daily") - logger.info("workload_scheduler_started") - - async def stop(self) -> None: - self._stopped.set() - if not self._task: - return - self._task.cancel() - try: - await self._task - except asyncio.CancelledError: - pass - logger.info("workload_scheduler_stopped") - - async def run_once(self) -> dict: - """手动执行一次,便于调试与测试。""" - return await self._run_recalc() - - async def _run_loop(self) -> None: - while not self._stopped.is_set(): - seconds = self._seconds_until_next_midnight() - try: - await asyncio.wait_for(self._stopped.wait(), timeout=seconds) - break - except TimeoutError: - pass - - try: - await self._run_recalc() - except Exception as exc: # pragma: no cover - logger.warning("workload_scheduler_recalc_failed", error=str(exc)) - - @staticmethod - def _seconds_until_next_midnight() -> float: - now = datetime.now() - tomorrow = (now + timedelta(days=1)).date() - next_midnight = datetime.combine(tomorrow, datetime.min.time()) - return max((next_midnight - now).total_seconds(), 1.0) - - async def _run_recalc(self) -> dict: - async with self._session_factory() as db: - tenant_ids = await workload_service.list_tenant_ids(db) - total_users = 0 - total_suggestions = 0 - for tenant_id in tenant_ids or [1]: - total_users += await workload_service.recalculate_all_users_workload_ratio( - db, - tenant_id=tenant_id, - ) - summary = await workload_alert_service.process_tenant_unresolved_overloads( - db, - tenant_id=tenant_id, - ) - total_suggestions += int(summary.get("suggestion_count", 0)) - logger.info( - "workload_scheduler_recalc_done", - tenant_count=len(tenant_ids or [1]), - user_count=total_users, - suggestion_count=total_suggestions, - ) - return { - "tenant_count": len(tenant_ids or [1]), - "user_count": total_users, - "suggestion_count": total_suggestions, - } - - -class KnowledgeUrlRefreshScheduler: - """按周期回抓 URL 知识源(默认每 168 小时)。""" - - def __init__(self, session_factory: async_sessionmaker): - self._session_factory = session_factory - self._task: Optional[asyncio.Task] = None - self._stopped = asyncio.Event() - try: - hours = int(os.getenv("KB_URL_REFRESH_HOURS", "168")) - except Exception: - hours = 168 - self._interval_seconds = max(hours, 1) * 3600 - - def start(self) -> None: - if self._task and not self._task.done(): - return - self._stopped.clear() - self._task = asyncio.create_task(self._run_loop(), name="knowledge-url-refresh") - logger.info("knowledge_url_refresh_scheduler_started", interval_seconds=self._interval_seconds) - - async def stop(self) -> None: - self._stopped.set() - if not self._task: - return - self._task.cancel() - try: - await self._task - except asyncio.CancelledError: - pass - logger.info("knowledge_url_refresh_scheduler_stopped") - - async def run_once(self) -> dict: - return await self._run_refresh() - - async def _run_loop(self) -> None: - while not self._stopped.is_set(): - try: - await asyncio.wait_for(self._stopped.wait(), timeout=self._interval_seconds) - break - except TimeoutError: - pass - - try: - await self._run_refresh() - except Exception as exc: # pragma: no cover - logger.warning("knowledge_url_refresh_scheduler_failed", error=str(exc)) - - async def _run_refresh(self) -> dict: - async with self._session_factory() as db: - now = datetime.now() - cutoff = now - timedelta(days=7) - docs = ( - await db.execute( - select(KnowledgeBaseDoc).where( - and_( - KnowledgeBaseDoc.source_type == "url", - or_( - KnowledgeBaseDoc.last_fetched_at.is_(None), - KnowledgeBaseDoc.last_fetched_at <= cutoff, - ), - ) - ) - ) - ).scalars().all() - - refreshed = 0 - failed = 0 - for doc in docs: - tenant_id = int(getattr(doc, "tenant_id", 1) or 1) - service = AdaptiveRAGService(db, tenant_id=tenant_id) - try: - await service.refresh_url_doc(doc_id=int(doc.id), project_id=None) - refreshed += 1 - except Exception as exc: # pragma: no cover - failed += 1 - logger.warning( - "knowledge_url_refresh_doc_failed", - doc_id=int(doc.id), - tenant_id=tenant_id, - error=str(exc), - ) - - logger.info( - "knowledge_url_refresh_done", - candidate_count=len(docs), - refreshed=refreshed, - failed=failed, - ) - return { - "candidate_count": len(docs), - "refreshed": refreshed, - "failed": failed, - } - - -class KnowledgeGraphIncrementalScheduler: - """按周期执行知识图谱增量学习流水线。""" - - def __init__(self, session_factory: async_sessionmaker): - self._session_factory = session_factory - self._task: Optional[asyncio.Task] = None - self._stopped = asyncio.Event() - try: - minutes = int(os.getenv("KG_INCREMENTAL_INTERVAL_MINUTES", "5")) - except Exception: - minutes = 5 - self._interval_seconds = max(minutes, 1) * 60 - - try: - self._delta_limit = max(100, int(os.getenv("KG_INCREMENTAL_DELTA_LIMIT", "1000"))) - except Exception: - self._delta_limit = 1000 - - try: - self._event_batch = max(10, int(os.getenv("KG_INCREMENTAL_EVENT_BATCH", "200"))) - except Exception: - self._event_batch = 200 - - try: - self._code_scan_hours = max(1, int(os.getenv("KG_CODE_SCAN_INTERVAL_HOURS", "6"))) - except Exception: - self._code_scan_hours = 6 - - def start(self) -> None: - if self._task and not self._task.done(): - return - self._stopped.clear() - self._task = asyncio.create_task(self._run_loop(), name="knowledge-graph-incremental") - logger.info( - "knowledge_graph_incremental_scheduler_started", - interval_seconds=self._interval_seconds, - delta_limit=self._delta_limit, - event_batch=self._event_batch, - code_scan_hours=self._code_scan_hours, - ) - - async def stop(self) -> None: - self._stopped.set() - if not self._task: - return - self._task.cancel() - try: - await self._task - except asyncio.CancelledError: - pass - logger.info("knowledge_graph_incremental_scheduler_stopped") - - async def run_once(self) -> dict: - return await self._run_cycle() - - async def _run_loop(self) -> None: - while not self._stopped.is_set(): - try: - await asyncio.wait_for(self._stopped.wait(), timeout=self._interval_seconds) - break - except TimeoutError: - pass - - try: - await self._run_cycle() - except Exception as exc: # pragma: no cover - logger.warning("knowledge_graph_incremental_scheduler_failed", error=str(exc)) - - async def _run_cycle(self) -> dict: - async with self._session_factory() as db: - tenant_ids = await workload_service.list_tenant_ids(db) - effective_tenants = tenant_ids or [1] - tenant_results: list[dict] = [] - for tenant_id in effective_tenants: - service = ProjectKnowledgeGraphService(db, tenant_id=int(tenant_id)) - result = await service.run_incremental_cycle( - delta_limit=self._delta_limit, - event_batch=self._event_batch, - code_scan_hours=self._code_scan_hours, - run_source="scheduler", - ) - tenant_results.append(result) - - logger.info( - "knowledge_graph_incremental_scheduler_done", - tenant_count=len(effective_tenants), - results=tenant_results, - ) - return { - "tenant_count": len(effective_tenants), - "results": tenant_results, - } - - -class AIPMOCadenceScheduler: - """AI PMO cadence 自动调度。""" - - def __init__(self, session_factory: async_sessionmaker): - self._session_factory = session_factory - self._task: Optional[asyncio.Task] = None - self._stopped = asyncio.Event() - try: - minutes = int(os.getenv("AI_PMO_CADENCE_INTERVAL_MINUTES", "60")) - except Exception: - minutes = 60 - self._interval_seconds = max(minutes, 5) * 60 - - def start(self) -> None: - if self._task and not self._task.done(): - return - self._stopped.clear() - self._task = asyncio.create_task(self._run_loop(), name="ai-pmo-cadence") - logger.info("ai_pmo_cadence_scheduler_started", interval_seconds=self._interval_seconds) - - async def stop(self) -> None: - self._stopped.set() - if not self._task: - return - self._task.cancel() - try: - await self._task - except asyncio.CancelledError: - pass - logger.info("ai_pmo_cadence_scheduler_stopped") - - async def run_once(self) -> dict: - return await self._run_cycle() - - async def _run_loop(self) -> None: - while not self._stopped.is_set(): - try: - await asyncio.wait_for(self._stopped.wait(), timeout=self._interval_seconds) - break - except TimeoutError: - pass - - try: - await self._run_cycle() - except Exception as exc: # pragma: no cover - logger.warning("ai_pmo_cadence_scheduler_failed", error=str(exc)) - - async def _run_cycle(self) -> dict: - async with self._session_factory() as db: - now = datetime.now() - tenant_ids = await workload_service.list_tenant_ids(db) - effective_tenants = tenant_ids or [1] - emitted = 0 - for tenant_id in effective_tenants: - service = AIPMOCadenceService(db, tenant_id=int(tenant_id)) - project_ids = ( - await db.execute( - select(Project.id).where( - and_( - Project.tenant_id == int(tenant_id), - Project.status.in_(["active", "planning", "doing", "todo"]), - ) - ) - ) - ).scalars().all() - - if await self._snapshot_due( - db=db, - tenant_id=int(tenant_id), - scope_type="risk_review", - scope_ref=f"tenant:{tenant_id}", - freshness=timedelta(minutes=50), - ): - await service.generate_snapshot(cadence_type="risk_review", project_id=None) - emitted += 1 - - for project_id in project_ids: - if await self._snapshot_due( - db=db, - tenant_id=int(tenant_id), - scope_type="risk_review", - scope_ref=f"project:{int(project_id)}", - freshness=timedelta(minutes=50), - ): - await service.generate_snapshot(cadence_type="risk_review", project_id=int(project_id)) - emitted += 1 - - if 8 <= now.hour <= 10: - if await self._snapshot_due( - db=db, - tenant_id=int(tenant_id), - scope_type="daily_standup", - scope_ref=f"tenant:{tenant_id}", - freshness=timedelta(hours=20), - ): - await service.generate_snapshot(cadence_type="daily_standup", project_id=None) - emitted += 1 - for project_id in project_ids: - if await self._snapshot_due( - db=db, - tenant_id=int(tenant_id), - scope_type="daily_standup", - scope_ref=f"project:{int(project_id)}", - freshness=timedelta(hours=20), - ): - await service.generate_snapshot(cadence_type="daily_standup", project_id=int(project_id)) - emitted += 1 - - if 18 <= now.hour <= 20: - if await self._snapshot_due( - db=db, - tenant_id=int(tenant_id), - scope_type="milestone_check", - scope_ref=f"tenant:{tenant_id}", - freshness=timedelta(hours=20), - ): - await service.generate_snapshot(cadence_type="milestone_check", project_id=None) - emitted += 1 - - if now.weekday() == 0 and 8 <= now.hour <= 11: - if await self._snapshot_due( - db=db, - tenant_id=int(tenant_id), - scope_type="weekly_review", - scope_ref=f"tenant:{tenant_id}", - freshness=timedelta(days=6), - ): - await service.generate_snapshot(cadence_type="weekly_review", project_id=None) - emitted += 1 - - await db.commit() - logger.info("ai_pmo_cadence_scheduler_done", tenant_count=len(effective_tenants), snapshot_count=emitted) - return {"tenant_count": len(effective_tenants), "snapshot_count": emitted} - - @staticmethod - async def _snapshot_due( - *, - db, - tenant_id: int, - scope_type: str, - scope_ref: str, - freshness: timedelta, - ) -> bool: - row = ( - await db.execute( - select(PortfolioSnapshot.created_at).where( - and_( - PortfolioSnapshot.tenant_id == int(tenant_id), - PortfolioSnapshot.scope_type == scope_type, - PortfolioSnapshot.scope_ref == scope_ref[:64], - ) - ).order_by(PortfolioSnapshot.id.desc()).limit(1) - ) - ).scalars().first() - if row is None: - return True - return row <= (datetime.now() - freshness) +__all__ = [ + "WorkloadRecalcScheduler", + "KnowledgeUrlRefreshScheduler", + "KnowledgeGraphIncrementalScheduler", + "AIPMOCadenceScheduler", +] diff --git a/backend/app/services/scheduler_center_core/__init__.py b/backend/app/services/scheduler_center_core/__init__.py new file mode 100644 index 00000000..8e0f0e10 --- /dev/null +++ b/backend/app/services/scheduler_center_core/__init__.py @@ -0,0 +1,13 @@ +"""Scheduler center exports.""" + +from .ai_pmo_cadence import AIPMOCadenceScheduler +from .knowledge_graph import KnowledgeGraphIncrementalScheduler +from .knowledge_url import KnowledgeUrlRefreshScheduler +from .workload import WorkloadRecalcScheduler + +__all__ = [ + "WorkloadRecalcScheduler", + "KnowledgeUrlRefreshScheduler", + "KnowledgeGraphIncrementalScheduler", + "AIPMOCadenceScheduler", +] diff --git a/backend/app/services/scheduler_center_core/ai_pmo_cadence.py b/backend/app/services/scheduler_center_core/ai_pmo_cadence.py new file mode 100644 index 00000000..35d45761 --- /dev/null +++ b/backend/app/services/scheduler_center_core/ai_pmo_cadence.py @@ -0,0 +1,160 @@ +"""AI PMO cadence scheduler.""" + +from __future__ import annotations + +from .shared import AIPMOCadenceService, PortfolioSnapshot, Project, and_, async_sessionmaker, asyncio, datetime, logger, os, select, timedelta, workload_service + + +class AIPMOCadenceScheduler: + """AI PMO cadence 自动调度。""" + + def __init__(self, session_factory: async_sessionmaker): + self._session_factory = session_factory + self._task: asyncio.Task | None = None + self._stopped = asyncio.Event() + try: + minutes = int(os.getenv("AI_PMO_CADENCE_INTERVAL_MINUTES", "60")) + except Exception: + minutes = 60 + self._interval_seconds = max(minutes, 5) * 60 + + def start(self) -> None: + if self._task and not self._task.done(): + return + self._stopped.clear() + self._task = asyncio.create_task(self._run_loop(), name="ai-pmo-cadence") + logger.info("ai_pmo_cadence_scheduler_started", interval_seconds=self._interval_seconds) + + async def stop(self) -> None: + self._stopped.set() + if not self._task: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + logger.info("ai_pmo_cadence_scheduler_stopped") + + async def run_once(self) -> dict: + return await self._run_cycle() + + async def _run_loop(self) -> None: + while not self._stopped.is_set(): + try: + await asyncio.wait_for(self._stopped.wait(), timeout=self._interval_seconds) + break + except TimeoutError: + pass + + try: + await self._run_cycle() + except Exception as exc: # pragma: no cover + logger.warning("ai_pmo_cadence_scheduler_failed", error=str(exc)) + + async def _run_cycle(self) -> dict: + async with self._session_factory() as db: + now = datetime.now() + tenant_ids = await workload_service.list_tenant_ids(db) + effective_tenants = tenant_ids or [1] + emitted = 0 + for tenant_id in effective_tenants: + service = AIPMOCadenceService(db, tenant_id=int(tenant_id)) + project_ids = ( + await db.execute( + select(Project.id).where( + and_( + Project.tenant_id == int(tenant_id), + Project.status.in_(["active", "planning", "doing", "todo"]), + ) + ) + ) + ).scalars().all() + + if await self._snapshot_due( + db=db, + tenant_id=int(tenant_id), + scope_type="risk_review", + scope_ref=f"tenant:{tenant_id}", + freshness=timedelta(minutes=50), + ): + await service.generate_snapshot(cadence_type="risk_review", project_id=None) + emitted += 1 + + for project_id in project_ids: + if await self._snapshot_due( + db=db, + tenant_id=int(tenant_id), + scope_type="risk_review", + scope_ref=f"project:{int(project_id)}", + freshness=timedelta(minutes=50), + ): + await service.generate_snapshot(cadence_type="risk_review", project_id=int(project_id)) + emitted += 1 + + if 8 <= now.hour <= 10: + if await self._snapshot_due( + db=db, + tenant_id=int(tenant_id), + scope_type="daily_standup", + scope_ref=f"tenant:{tenant_id}", + freshness=timedelta(hours=20), + ): + await service.generate_snapshot(cadence_type="daily_standup", project_id=None) + emitted += 1 + for project_id in project_ids: + if await self._snapshot_due( + db=db, + tenant_id=int(tenant_id), + scope_type="daily_standup", + scope_ref=f"project:{int(project_id)}", + freshness=timedelta(hours=20), + ): + await service.generate_snapshot(cadence_type="daily_standup", project_id=int(project_id)) + emitted += 1 + + if 18 <= now.hour <= 20: + if await self._snapshot_due( + db=db, + tenant_id=int(tenant_id), + scope_type="milestone_check", + scope_ref=f"tenant:{tenant_id}", + freshness=timedelta(hours=20), + ): + await service.generate_snapshot(cadence_type="milestone_check", project_id=None) + emitted += 1 + + if now.weekday() == 0 and 8 <= now.hour <= 11: + if await self._snapshot_due( + db=db, + tenant_id=int(tenant_id), + scope_type="weekly_review", + scope_ref=f"tenant:{tenant_id}", + freshness=timedelta(days=6), + ): + await service.generate_snapshot(cadence_type="weekly_review", project_id=None) + emitted += 1 + + await db.commit() + logger.info("ai_pmo_cadence_scheduler_done", tenant_count=len(effective_tenants), snapshot_count=emitted) + return {"tenant_count": len(effective_tenants), "snapshot_count": emitted} + + @staticmethod + async def _snapshot_due(*, db, tenant_id: int, scope_type: str, scope_ref: str, freshness: timedelta) -> bool: + row = ( + await db.execute( + select(PortfolioSnapshot.created_at) + .where( + and_( + PortfolioSnapshot.tenant_id == int(tenant_id), + PortfolioSnapshot.scope_type == scope_type, + PortfolioSnapshot.scope_ref == scope_ref[:64], + ) + ) + .order_by(PortfolioSnapshot.id.desc()) + .limit(1) + ) + ).scalars().first() + if row is None: + return True + return row <= (datetime.now() - freshness) diff --git a/backend/app/services/scheduler_center_core/knowledge_graph.py b/backend/app/services/scheduler_center_core/knowledge_graph.py new file mode 100644 index 00000000..18701f83 --- /dev/null +++ b/backend/app/services/scheduler_center_core/knowledge_graph.py @@ -0,0 +1,99 @@ +"""Knowledge graph incremental scheduler.""" + +from __future__ import annotations + +from .shared import ProjectKnowledgeGraphService, async_sessionmaker, asyncio, logger, os, workload_service + + +class KnowledgeGraphIncrementalScheduler: + """按周期执行知识图谱增量学习流水线。""" + + def __init__(self, session_factory: async_sessionmaker): + self._session_factory = session_factory + self._task: asyncio.Task | None = None + self._stopped = asyncio.Event() + try: + minutes = int(os.getenv("KG_INCREMENTAL_INTERVAL_MINUTES", "5")) + except Exception: + minutes = 5 + self._interval_seconds = max(minutes, 1) * 60 + + try: + self._delta_limit = max(100, int(os.getenv("KG_INCREMENTAL_DELTA_LIMIT", "1000"))) + except Exception: + self._delta_limit = 1000 + + try: + self._event_batch = max(10, int(os.getenv("KG_INCREMENTAL_EVENT_BATCH", "200"))) + except Exception: + self._event_batch = 200 + + try: + self._code_scan_hours = max(1, int(os.getenv("KG_CODE_SCAN_INTERVAL_HOURS", "6"))) + except Exception: + self._code_scan_hours = 6 + + def start(self) -> None: + if self._task and not self._task.done(): + return + self._stopped.clear() + self._task = asyncio.create_task(self._run_loop(), name="knowledge-graph-incremental") + logger.info( + "knowledge_graph_incremental_scheduler_started", + interval_seconds=self._interval_seconds, + delta_limit=self._delta_limit, + event_batch=self._event_batch, + code_scan_hours=self._code_scan_hours, + ) + + async def stop(self) -> None: + self._stopped.set() + if not self._task: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + logger.info("knowledge_graph_incremental_scheduler_stopped") + + async def run_once(self) -> dict: + return await self._run_cycle() + + async def _run_loop(self) -> None: + while not self._stopped.is_set(): + try: + await asyncio.wait_for(self._stopped.wait(), timeout=self._interval_seconds) + break + except TimeoutError: + pass + + try: + await self._run_cycle() + except Exception as exc: # pragma: no cover + logger.warning("knowledge_graph_incremental_scheduler_failed", error=str(exc)) + + async def _run_cycle(self) -> dict: + async with self._session_factory() as db: + tenant_ids = await workload_service.list_tenant_ids(db) + effective_tenants = tenant_ids or [1] + tenant_results: list[dict] = [] + for tenant_id in effective_tenants: + service = ProjectKnowledgeGraphService(db, tenant_id=int(tenant_id)) + result = await service.run_incremental_cycle( + delta_limit=self._delta_limit, + event_batch=self._event_batch, + code_scan_hours=self._code_scan_hours, + run_source="scheduler", + ) + tenant_results.append(result) + + logger.info( + "knowledge_graph_incremental_scheduler_done", + tenant_count=len(effective_tenants), + results=tenant_results, + ) + return { + "tenant_count": len(effective_tenants), + "results": tenant_results, + } diff --git a/backend/app/services/scheduler_center_core/knowledge_url.py b/backend/app/services/scheduler_center_core/knowledge_url.py new file mode 100644 index 00000000..0cf3ec70 --- /dev/null +++ b/backend/app/services/scheduler_center_core/knowledge_url.py @@ -0,0 +1,100 @@ +"""Knowledge URL refresh scheduler.""" + +from __future__ import annotations + +from .shared import AdaptiveRAGService, KnowledgeBaseDoc, and_, async_sessionmaker, asyncio, datetime, logger, or_, os, select, timedelta + + +class KnowledgeUrlRefreshScheduler: + """按周期回抓 URL 知识源(默认每 168 小时)。""" + + def __init__(self, session_factory: async_sessionmaker): + self._session_factory = session_factory + self._task: asyncio.Task | None = None + self._stopped = asyncio.Event() + try: + hours = int(os.getenv("KB_URL_REFRESH_HOURS", "168")) + except Exception: + hours = 168 + self._interval_seconds = max(hours, 1) * 3600 + + def start(self) -> None: + if self._task and not self._task.done(): + return + self._stopped.clear() + self._task = asyncio.create_task(self._run_loop(), name="knowledge-url-refresh") + logger.info("knowledge_url_refresh_scheduler_started", interval_seconds=self._interval_seconds) + + async def stop(self) -> None: + self._stopped.set() + if not self._task: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + logger.info("knowledge_url_refresh_scheduler_stopped") + + async def run_once(self) -> dict: + return await self._run_refresh() + + async def _run_loop(self) -> None: + while not self._stopped.is_set(): + try: + await asyncio.wait_for(self._stopped.wait(), timeout=self._interval_seconds) + break + except TimeoutError: + pass + + try: + await self._run_refresh() + except Exception as exc: # pragma: no cover + logger.warning("knowledge_url_refresh_scheduler_failed", error=str(exc)) + + async def _run_refresh(self) -> dict: + async with self._session_factory() as db: + now = datetime.now() + cutoff = now - timedelta(days=7) + docs = ( + await db.execute( + select(KnowledgeBaseDoc).where( + and_( + KnowledgeBaseDoc.source_type == "url", + or_( + KnowledgeBaseDoc.last_fetched_at.is_(None), + KnowledgeBaseDoc.last_fetched_at <= cutoff, + ), + ) + ) + ) + ).scalars().all() + + refreshed = 0 + failed = 0 + for doc in docs: + tenant_id = int(getattr(doc, "tenant_id", 1) or 1) + service = AdaptiveRAGService(db, tenant_id=tenant_id) + try: + await service.refresh_url_doc(doc_id=int(doc.id), project_id=None) + refreshed += 1 + except Exception as exc: # pragma: no cover + failed += 1 + logger.warning( + "knowledge_url_refresh_doc_failed", + doc_id=int(doc.id), + tenant_id=tenant_id, + error=str(exc), + ) + + logger.info( + "knowledge_url_refresh_done", + candidate_count=len(docs), + refreshed=refreshed, + failed=failed, + ) + return { + "candidate_count": len(docs), + "refreshed": refreshed, + "failed": failed, + } diff --git a/backend/app/services/scheduler_center_core/shared.py b/backend/app/services/scheduler_center_core/shared.py new file mode 100644 index 00000000..d6f6a18e --- /dev/null +++ b/backend/app/services/scheduler_center_core/shared.py @@ -0,0 +1,41 @@ +"""Shared imports for scheduler center.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timedelta +import os +from typing import Optional + +from sqlalchemy import and_, or_, select +from sqlalchemy.ext.asyncio import async_sessionmaker + +from ...core.logging import get_logger +from ...models import KnowledgeBaseDoc, PortfolioSnapshot, Project +from .. import workload_alert_service, workload_service +from ..adaptive_rag_service import AdaptiveRAGService +from ..ai_pmo_cadence_service import AIPMOCadenceService +from ..project_knowledge_graph import ProjectKnowledgeGraphService + +logger = get_logger(__name__) + +__all__ = [ + "AIPMOCadenceService", + "AdaptiveRAGService", + "KnowledgeBaseDoc", + "Optional", + "PortfolioSnapshot", + "Project", + "ProjectKnowledgeGraphService", + "and_", + "async_sessionmaker", + "asyncio", + "datetime", + "logger", + "or_", + "os", + "select", + "timedelta", + "workload_alert_service", + "workload_service", +] diff --git a/backend/app/services/scheduler_center_core/workload.py b/backend/app/services/scheduler_center_core/workload.py new file mode 100644 index 00000000..022a6a7c --- /dev/null +++ b/backend/app/services/scheduler_center_core/workload.py @@ -0,0 +1,84 @@ +"""Workload recalculation scheduler.""" + +from __future__ import annotations + +from .shared import async_sessionmaker, asyncio, datetime, logger, timedelta, workload_alert_service, workload_service + + +class WorkloadRecalcScheduler: + """每日 00:00 重算 workload_ratio。""" + + def __init__(self, session_factory: async_sessionmaker): + self._session_factory = session_factory + self._task: asyncio.Task | None = None + self._stopped = asyncio.Event() + + def start(self) -> None: + if self._task and not self._task.done(): + return + self._stopped.clear() + self._task = asyncio.create_task(self._run_loop(), name="workload-recalc-daily") + logger.info("workload_scheduler_started") + + async def stop(self) -> None: + self._stopped.set() + if not self._task: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + logger.info("workload_scheduler_stopped") + + async def run_once(self) -> dict: + """手动执行一次,便于调试与测试。""" + return await self._run_recalc() + + async def _run_loop(self) -> None: + while not self._stopped.is_set(): + seconds = self._seconds_until_next_midnight() + try: + await asyncio.wait_for(self._stopped.wait(), timeout=seconds) + break + except TimeoutError: + pass + + try: + await self._run_recalc() + except Exception as exc: # pragma: no cover + logger.warning("workload_scheduler_recalc_failed", error=str(exc)) + + @staticmethod + def _seconds_until_next_midnight() -> float: + now = datetime.now() + tomorrow = (now + timedelta(days=1)).date() + next_midnight = datetime.combine(tomorrow, datetime.min.time()) + return max((next_midnight - now).total_seconds(), 1.0) + + async def _run_recalc(self) -> dict: + async with self._session_factory() as db: + tenant_ids = await workload_service.list_tenant_ids(db) + total_users = 0 + total_suggestions = 0 + for tenant_id in tenant_ids or [1]: + total_users += await workload_service.recalculate_all_users_workload_ratio( + db, + tenant_id=tenant_id, + ) + summary = await workload_alert_service.process_tenant_unresolved_overloads( + db, + tenant_id=tenant_id, + ) + total_suggestions += int(summary.get("suggestion_count", 0)) + logger.info( + "workload_scheduler_recalc_done", + tenant_count=len(tenant_ids or [1]), + user_count=total_users, + suggestion_count=total_suggestions, + ) + return { + "tenant_count": len(tenant_ids or [1]), + "user_count": total_users, + "suggestion_count": total_suggestions, + } diff --git a/backend/app/services/signal_capture.py b/backend/app/services/signal_capture.py index e77deb59..f00fc4b5 100644 --- a/backend/app/services/signal_capture.py +++ b/backend/app/services/signal_capture.py @@ -1,2281 +1,5 @@ -""" -Proactive Signal Capture Service - 主动式信号捕获服务 +from __future__ import annotations -核心功能: -1. 飞书群聊监控 - 自动捕获群内讨论的产品/技术相关内容 -2. 浏览器标签监控 - 检测用户访问的技术文档、竞品页面 -3. GitHub 仓库监控 - 跟踪上游仓库更新、竞品开源项目 -4. HackerNews 自动抓取 - 定时抓取技术前沿信息 +from .signal_capture_service import ProactiveSignalCaptureService, get_signal_capture_service -每个信号都会自动: -- 评估战略重要性 -- 提取关键技术栈 -- 生成任务建议 -- 推送给相关角色 -""" -import asyncio -import inspect -import json -import re -from uuid import uuid4 -from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Optional -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select -from sqlalchemy import and_, text - -from ..core.logging import get_logger -from ..database import AsyncSessionLocal -from ..models import ( - Signal, Project, ProjectDomain, User, - ProjectSignalLink, Task, TruthNode, - SkillTag, UserProject, UserWorkload, UserSkillBinding, -) -from ..config.ai_prompts import ( - PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, - PROMPT_FEISHU_TASK_ORCHESTRATION, -) -from .llm import LLMService -from .notification_service import NotificationService -from . import workload_service -from .adaptive_rag_service import AdaptiveRAGService -from .context_cache import context_cache_service -from .signal_source_orchestration_service import SignalSourceOrchestrationService -from .ai_pmo_kernel_service import AIPMOKernelService -from ..utils.project_members import ensure_project_default_members -from ..utils.sequence import ensure_project_id_sequence, ensure_signal_id_sequence - - -from ..utils.timezone import CN_TZ, now_cn_aware -EMOJI_UNICODE_RE = re.compile( - r"[\U0001F300-\U0001F6FF\U0001F700-\U0001F77F\U0001F780-\U0001F7FF" - r"\U0001F800-\U0001F8FF\U0001F900-\U0001F9FF\U0001FA00-\U0001FAFF" - r"\U00002600-\U000026FF\U00002700-\U000027BF]+" -) -ONLY_PUNCT_OR_SPACE_RE = re.compile(r"^[\s\W_]+$", re.UNICODE) -MENTION_TAG_RE = re.compile(r"]*>", re.IGNORECASE) -CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]+") -NON_REQUIREMENT_EXACT = { - "收到", - "收到啦", - "好的", - "ok", - "okk", - "okay", - "yes", - "no", - "1", - "2", - "3", - "thanks", - "thx", - "赞", - "辛苦", - "你是谁", - "你是誰", - "你是谁?", - "你是誰?", - "说话", - "在吗", - "在不在", - "今天什么日期", - "今天几号", - "今天星期几", - "现在几点", - "当前日期", -} -NON_REQUIREMENT_CONTAINS = [ - "你是谁", - "什么身份", - "身份确认", - "今天什么日期", - "当前日期", - "日期疑问", - "询问日期", - "现在几点", - "几点了", - "今天周几", - "今天星期几", - "说话", - "在吗", - "在不在", -] -REQUIREMENT_HINTS = [ - "需求", - "优化", - "改一下", - "调整", - "修复", - "bug", - "故障", - "报错", - "项目", - "页面", - "接口", - "功能", - "排版", - "样式", - "任务", - "立项", - "上线", - "配置", -] - -logger = get_logger(__name__) -AI_THINK_STAGE_ZH = { - "capture_start": "开始处理飞书消息", - "capture_skip_empty": "消息为空,忽略", - "capture_skip_noise": "识别为噪音消息,忽略", - "capture_done_ai_pmo": "AI PMO kernel 已完成决策闭环", - "capture_kernel_failed": "AI PMO kernel 执行异常", - "capture_kernel_failed_rollback_failed": "kernel 异常后回滚失败", - "classification_done": "语义分类完成", - "classification_parse_failed": "语义分类结果解析失败", - "capture_skip_not_requirement": "非需求消息,忽略", - "capture_skip_non_requirement": "判定为非需求,忽略", - "signal_created": "信号已创建", - "project_link_done": "项目关联完成", - "project_link_failed": "项目关联失败", - "task_orchestrate_failed": "任务编排失败", - "capture_done": "飞书消息处理完成", - "pipeline_post_orchestrate_failed": "分流后置任务编排失败", - "pipeline_post_update_signal_failed": "分流后置信号结果回写失败", - "pipeline_post_source_onboard_failed": "分流后置信号源接入编排失败", - "task_plan_llm_done": "任务编排 LLM 返回成功", - "task_plan_llm_failed": "任务编排 LLM 调用失败", - "task_created": "任务已创建", - "charter_auto_created": "自动创建宪章节点", - "hn_story_captured": "HackerNews 信号已捕获", - "hn_monitor_error": "HackerNews 监控异常", - "new_project_created_from_signal": "已根据信号自动创建项目", - "draft_new_project_brief_failed": "新项目摘要生成失败,已走兜底", -} - - -def _build_feishu_rag_conversation_key( - *, - source_chat_id: Optional[str], - source_sender_open_id: Optional[str], - sender: Optional[str], -) -> str: - chat = str(source_chat_id or "").strip() - if not chat: - return "" - actor = str(source_sender_open_id or "").strip() - if not actor: - actor = str(sender or "").strip().lower() - actor = re.sub(r"\s+", "", actor) - actor = actor[:96] if actor else "unknown" - return f"feishu:{chat}:{actor}" - - -class ProactiveSignalCaptureService: - """主动式信号捕获服务""" - - def __init__(self, db: AsyncSession): - self.db = db - self.llm = LLMService() - - async def capture_from_feishu( - self, - group_chat_id: str, - message_content: str, - sender: str, - tenant_id: int = 1, - proposer_open_id: Optional[str] = None, - captured_at: Optional[datetime] = None, - mentioned_bot_names: Optional[List[str]] = None, - feishu_message_id: Optional[str] = None, - chat_type: Optional[str] = None, - ) -> Dict[str, Any]: - """ - 从飞书群聊捕获信号 - - 触发条件: - - 提到产品、竞品、技术关键词 - - 讨论架构、技术选型 - - 分享技术文章 - """ - trace_id = f"feishu-{uuid4().hex[:12]}" - captured_dt = captured_at or now_cn_aware() - if captured_dt.tzinfo is None: - captured_dt = captured_dt.replace(tzinfo=CN_TZ) - - proposer_name = self._normalize_single_line_text(sender or "未知用户", max_len=80) or "未知用户" - proposer_open_id = self._normalize_single_line_text(proposer_open_id or "", max_len=128) or None - self._emit_ai_think( - "capture_start", - trace_id=trace_id, - tenant_id=tenant_id, - group_chat_id=group_chat_id, - sender=proposer_name, - ) - - cleaned_message = self._sanitize_feishu_message(message_content) - if not cleaned_message: - self._emit_ai_think("capture_skip_empty", trace_id=trace_id) - return {"status": "ignored", "reason": "empty_or_emoji_only"} - if self._is_non_requirement_message(cleaned_message) and not self._is_question_like(cleaned_message): - self._emit_ai_think( - "capture_skip_noise", - trace_id=trace_id, - message_preview=cleaned_message[:120], - ) - return {"status": "ignored", "reason": "non_requirement_noise"} - - kernel = AIPMOKernelService(self.db, tenant_id=tenant_id) - try: - pipeline_result = await kernel.process_feishu_message( - group_chat_id=group_chat_id, - message_content=cleaned_message, - sender=proposer_name, - source_message_id=feishu_message_id, - source_sender_open_id=proposer_open_id, - captured_at=captured_dt, - chat_type=chat_type, - ) - if not bool(pipeline_result.get("handled")): - self._emit_ai_think( - "capture_kernel_failed", - trace_id=trace_id, - reason=pipeline_result.get("reason"), - error=pipeline_result.get("error"), - ) - return { - "status": "error", - "reason": str(pipeline_result.get("reason") or "kernel_not_handled"), - "classification": "error", - "project_scope": "error", - "linked_project_ids": [], - "signal_id": None, - "created_project_id": None, - "auto_tasks": [], - "ai_trace_id": trace_id, - "direct_reply_required": True, - "direct_reply_text": "系统处理中出现异常,本次未执行入库。请稍后重试,或补充更明确的目标/对象/动作。", - } - self._emit_ai_think( - "capture_done_ai_pmo", - trace_id=trace_id, - classification=pipeline_result.get("classification"), - linked_project_ids=pipeline_result.get("linked_project_ids", []), - work_item_id=pipeline_result.get("work_item_id"), - ) - pipeline_result = await self._apply_pipeline_post_actions( - trace_id=trace_id, - tenant_id=tenant_id, - cleaned_message=cleaned_message, - proposer_name=proposer_name, - captured_at=captured_dt, - pipeline_result=pipeline_result, - ) - try: - await self._sync_feishu_recent_cache( - tenant_id=tenant_id, - group_chat_id=group_chat_id, - sender=proposer_name, - sender_open_id=proposer_open_id, - message=cleaned_message, - pipeline_result=pipeline_result, - ) - except Exception as cache_exc: - self._emit_ai_think( - "capture_recent_cache_sync_failed", - trace_id=trace_id, - error=str(cache_exc), - ) - try: - asyncio.create_task( - AdaptiveRAGService.ingest_feishu_message_async( - tenant_id=tenant_id, - content=cleaned_message, - sender=proposer_name, - chat_id=group_chat_id, - message_id=feishu_message_id, - captured_at=captured_dt, - project_id=( - int(pipeline_result["linked_project_ids"][0]) - if isinstance(pipeline_result.get("linked_project_ids"), list) - and pipeline_result.get("linked_project_ids") - else None - ), - ) - ) - except Exception: - pass - return pipeline_result - except Exception as exc: - self._emit_ai_think("capture_kernel_failed", trace_id=trace_id, error=str(exc)) - try: - await self.db.rollback() - except Exception as rollback_exc: - self._emit_ai_think( - "capture_kernel_failed_rollback_failed", - trace_id=trace_id, - error=str(rollback_exc), - ) - return { - "status": "error", - "reason": "kernel_failed", - "classification": "error", - "project_scope": "error", - "linked_project_ids": [], - "signal_id": None, - "created_project_id": None, - "auto_tasks": [], - "ai_trace_id": trace_id, - "direct_reply_required": True, - "direct_reply_text": "系统处理中出现异常,本次未执行入库。请稍后重试,或补充更明确的目标/对象/动作。", - } - - projects_context = await self._load_active_projects_context( - tenant_id=tenant_id, - ) - classification = await self._analyze_chat_message( - message=cleaned_message, - projects_context=projects_context, - tenant_id=tenant_id, - ) - self._emit_ai_think( - "classification_done", - trace_id=trace_id, - classification=classification.get("classification"), - confidence=classification.get("confidence"), - matched_project_ids=classification.get("matched_project_ids", []), - reason=classification.get("reason"), - ) - if not classification.get("is_requirement", False): - self._emit_ai_think( - "capture_skip_not_requirement", - trace_id=trace_id, - reason=classification.get("reason", "non_requirement"), - ) - return {"status": "ignored", "reason": classification.get("reason", "non_requirement")} - - project_scope = str(classification.get("classification") or "pending_classification") - if project_scope == "non_requirement": - self._emit_ai_think( - "capture_skip_non_requirement", - trace_id=trace_id, - reason=classification.get("reason", "non_requirement"), - ) - return {"status": "ignored", "reason": classification.get("reason", "non_requirement")} - - # 创建信号 - await ensure_signal_id_sequence(self.db) - strategic_payload = { - "captured_at": captured_dt.isoformat(), - "source_context": { - "group_id": group_chat_id, - "sender": proposer_name, - "sender_open_id": proposer_open_id, - "mentioned_bot_names": [ - self._normalize_single_line_text(n, max_len=80) - for n in (mentioned_bot_names or []) - if self._normalize_single_line_text(n, max_len=80) - ], - }, - "original_message": (message_content or "")[:500], - "cleaned_message": cleaned_message[:500], - "ai_analysis": { - "requirement_project_scope": project_scope, - "reason": classification.get("reason", ""), - "confidence": classification.get("confidence", 0.0), - "matched_project_ids": classification.get("matched_project_ids", []), - "matched_project_titles": classification.get("matched_project_titles", []), - "summary": classification.get("summary", ""), - }, - } - - signal = Signal( - source="Feishu", - content=f"[{proposer_name}]: {self._normalize_single_line_text(cleaned_message, max_len=200)}", - impact_score=float(classification.get("impact_score") or 0.7), - priority=(classification.get("priority") or "medium"), - processed=False, - strategic_analysis=json.dumps(strategic_payload, ensure_ascii=False), - tasks_generated=json.dumps([], ensure_ascii=False), - tenant_id=tenant_id - ) - self.db.add(signal) - await self.db.commit() - await self.db.refresh(signal) - self._emit_ai_think("signal_created", trace_id=trace_id, signal_id=signal.id) - - # 自动关联到相关项目(失败不阻断后续通知) - linked_project_ids: List[int] = [] - try: - if project_scope == "new_project": - new_project_id = await self._auto_create_project_from_new_signal( - signal_id=int(signal.id), - message=cleaned_message, - sender=proposer_name, - tenant_id=tenant_id, - requested_at=captured_dt, - ) - linked_project_ids = [new_project_id] if new_project_id else [] - else: - linked_project_ids = await self._auto_link_to_projects( - signal.id, - classification, - tenant_id, - message_content=cleaned_message, - ) - self._emit_ai_think( - "project_link_done", - trace_id=trace_id, - signal_id=signal.id, - linked_project_ids=linked_project_ids, - ) - except Exception as exc: - self._emit_ai_think("project_link_failed", trace_id=trace_id, error=str(exc)) - - auto_tasks: List[Dict[str, Any]] = [] - ai_execution: Dict[str, Any] = { - "trace_id": trace_id, - "status": "skipped", - "reason": "no_linked_project", - "project_id": None, - "summary": "", - "tasks_created": 0, - } - if linked_project_ids: - primary_project_id = int(linked_project_ids[0]) - try: - auto_tasks, ai_execution = await self._auto_orchestrate_project_tasks( - trace_id=trace_id, - tenant_id=tenant_id, - project_id=primary_project_id, - signal_id=int(signal.id), - cleaned_message=cleaned_message, - classification=classification, - proposer_name=proposer_name, - captured_at=captured_dt, - ) - except Exception as exc: - self._emit_ai_think( - "task_orchestrate_failed", - trace_id=trace_id, - project_id=primary_project_id, - error=str(exc), - ) - ai_execution = { - "trace_id": trace_id, - "status": "failed", - "reason": str(exc), - "project_id": primary_project_id, - "summary": "", - "tasks_created": 0, - } - - strategic_payload["ai_execution"] = ai_execution - signal.tasks_generated = json.dumps(auto_tasks, ensure_ascii=False) - signal.strategic_analysis = json.dumps(strategic_payload, ensure_ascii=False) - signal.processed = bool(linked_project_ids or auto_tasks) - await self.db.commit() - - # 站内通知:右上角消息提醒 - target_url = "/ai-steward" - if linked_project_ids: - target_url = f"/projects/{linked_project_ids[0]}/board" - try: - notification_service = NotificationService(self.db) - await notification_service.notify_signal_created( - tenant_id=tenant_id, - signal_id=int(signal.id), - signal_content=cleaned_message, - source="feishu", - target_url=target_url, - proposer=proposer_name, - occurred_at=captured_dt, - ) - except Exception as exc: - self._emit_ai_think("notify_failed", trace_id=trace_id, error=str(exc)) - - self._emit_ai_think( - "capture_done", - trace_id=trace_id, - signal_id=signal.id, - linked_project_ids=linked_project_ids, - auto_tasks=len(auto_tasks), - ) - return { - "signal_id": str(signal.id), - "status": "captured", - "impact_score": signal.impact_score, - "auto_tasks": auto_tasks, - "project_scope": project_scope, - "classification": project_scope, - "linked_project_ids": linked_project_ids, - "created_project_id": linked_project_ids[0] if project_scope == "new_project" and linked_project_ids else None, - "proposer": proposer_name, - "ai_trace_id": trace_id, - } - - async def _sync_feishu_recent_cache( - self, - *, - tenant_id: int, - group_chat_id: str, - sender: str, - sender_open_id: Optional[str], - message: str, - pipeline_result: Dict[str, Any], - ) -> None: - if not isinstance(pipeline_result, dict): - return - - conversation_key = _build_feishu_rag_conversation_key( - source_chat_id=group_chat_id, - source_sender_open_id=sender_open_id, - sender=sender, - ) - external_session_id = AdaptiveRAGService._external_session_id_from_key(conversation_key) - if external_session_id is None: - return - - answer_text = str(pipeline_result.get("direct_reply_text") or "").strip() - if not answer_text: - answer_text = self._build_recent_cache_ack_text(pipeline_result) - if not answer_text: - return - - recent_messages = await context_cache_service.get_session_messages( - tenant_id=int(tenant_id), - session_id=int(external_session_id), - ) - recent_turns = context_cache_service.messages_to_turns(recent_messages) - if recent_turns: - latest_turn = recent_turns[-1] - latest_question = str(latest_turn.get("question") or "").strip() - latest_answer = str(latest_turn.get("answer") or "").strip() - if latest_question == str(message or "").strip() and latest_answer == answer_text: - return - - await context_cache_service.append_session_messages( - tenant_id=int(tenant_id), - session_id=int(external_session_id), - question=str(message or "")[:3000], - answer=answer_text[:4000], - ) - - @staticmethod - def _build_recent_cache_ack_text(pipeline_result: Dict[str, Any]) -> str: - status = str(pipeline_result.get("status") or "").strip().lower() - if status == "captured": - return "已收到并记录该需求,已进入信号处理队列。" - if status == "ignored": - return "已收到该消息,本次未进入需求处理队列。" - if status == "error": - return str(pipeline_result.get("direct_reply_text") or "系统处理中出现异常,请稍后重试。").strip() - return "已收到该消息。" - - async def _apply_pipeline_post_actions( - self, - *, - trace_id: str, - tenant_id: int, - cleaned_message: str, - proposer_name: str, - captured_at: datetime, - pipeline_result: Dict[str, Any], - ) -> Dict[str, Any]: - if not isinstance(pipeline_result, dict): - return pipeline_result - if str(pipeline_result.get("status") or "").lower() != "captured": - return pipeline_result - - existing_tasks = pipeline_result.get("auto_tasks") - if isinstance(existing_tasks, list) and existing_tasks: - return pipeline_result - - linked_project_ids = pipeline_result.get("linked_project_ids") - if not isinstance(linked_project_ids, list) or not linked_project_ids: - return pipeline_result - - signal_id = self._to_int(pipeline_result.get("signal_id")) - project_id = self._to_int(linked_project_ids[0]) - if signal_id is None or project_id is None: - return pipeline_result - - signal = ( - await self.db.execute( - select(Signal).where( - and_( - Signal.id == signal_id, - Signal.tenant_id == tenant_id, - ) - ) - ) - ).scalar_one_or_none() - if signal is None: - return pipeline_result - - if self._is_question_like(cleaned_message): - return pipeline_result - - source_orchestration_result: Optional[Dict[str, Any]] = None - if SignalSourceOrchestrationService.looks_like_signal_source_request(cleaned_message): - try: - source_orchestration_result = await SignalSourceOrchestrationService(self.db).orchestrate_from_signal( - tenant_id=tenant_id, - project_id=project_id, - signal_id=signal_id, - initiating_text=cleaned_message, - requested_via="feishu", - operator_user_id=None, - dry_run=False, - ) - except Exception as exc: - self._emit_ai_think( - "pipeline_post_source_onboard_failed", - trace_id=trace_id, - signal_id=signal_id, - project_id=project_id, - error=str(exc), - ) - else: - orchestration_status = str(source_orchestration_result.get("status") or "").lower() - if orchestration_status in {"completed", "partial", "planned", "pending_manual"}: - auto_tasks = self._build_openclaw_auto_tasks( - source_orchestration_result.get("execution") - if isinstance(source_orchestration_result.get("execution"), dict) - else source_orchestration_result.get("openclaw"), - ) - try: - strategic_payload: Dict[str, Any] = {} - raw_strategic = signal.strategic_analysis - if isinstance(raw_strategic, dict): - strategic_payload = dict(raw_strategic) - elif isinstance(raw_strategic, str) and raw_strategic.strip(): - parsed = json.loads(raw_strategic) - if isinstance(parsed, dict): - strategic_payload = parsed - strategic_payload["signal_source_orchestration"] = source_orchestration_result - strategic_payload["ai_execution"] = source_orchestration_result - signal.strategic_analysis = json.dumps(strategic_payload, ensure_ascii=False) - signal.tasks_generated = json.dumps(auto_tasks, ensure_ascii=False) - signal.processed = orchestration_status in {"completed", "partial"} - await self.db.commit() - except Exception as exc: - self._emit_ai_think( - "pipeline_post_update_signal_failed", - trace_id=trace_id, - signal_id=signal_id, - project_id=project_id, - error=str(exc), - ) - pipeline_result["auto_tasks"] = auto_tasks - pipeline_result["ai_execution"] = source_orchestration_result - pipeline_result["signal_source_orchestration"] = source_orchestration_result - return pipeline_result - - signal_type = str(signal.signal_type or "").strip() - recommended_action = str(signal.recommended_action or "").strip() - should_orchestrate = ( - recommended_action in {"create_charter", "trigger_alert"} - or signal_type == "新需求" - ) - if not should_orchestrate: - return pipeline_result - - classification_payload: Dict[str, Any] = { - "classification": str(pipeline_result.get("classification") or "signal"), - "impact_score": float(signal.impact_score or 0.0), - "priority": str(signal.priority or "medium"), - "confidence": float(signal.confidence or 0.0), - "reason": str(signal.summary or "")[:300], - "matched_project_ids": [project_id], - "matched_project_titles": [], - } - - try: - auto_tasks, ai_execution = await self._auto_orchestrate_project_tasks( - trace_id=f"{trace_id}-pipeline", - tenant_id=tenant_id, - project_id=project_id, - signal_id=signal_id, - cleaned_message=cleaned_message, - classification=classification_payload, - proposer_name=proposer_name, - captured_at=captured_at, - ) - except Exception as exc: - self._emit_ai_think( - "pipeline_post_orchestrate_failed", - trace_id=trace_id, - signal_id=signal_id, - project_id=project_id, - error=str(exc), - ) - return pipeline_result - - try: - strategic_payload: Dict[str, Any] = {} - raw_strategic = signal.strategic_analysis - if isinstance(raw_strategic, dict): - strategic_payload = dict(raw_strategic) - elif isinstance(raw_strategic, str) and raw_strategic.strip(): - parsed = json.loads(raw_strategic) - if isinstance(parsed, dict): - strategic_payload = parsed - strategic_payload["ai_execution"] = ai_execution - signal.strategic_analysis = json.dumps(strategic_payload, ensure_ascii=False) - signal.tasks_generated = json.dumps(auto_tasks, ensure_ascii=False) - signal.processed = bool(signal.processed or auto_tasks) - await self.db.commit() - except Exception as exc: - self._emit_ai_think( - "pipeline_post_update_signal_failed", - trace_id=trace_id, - signal_id=signal_id, - project_id=project_id, - error=str(exc), - ) - - pipeline_result["auto_tasks"] = auto_tasks - pipeline_result["ai_execution"] = ai_execution - return pipeline_result - - @staticmethod - def _build_openclaw_auto_tasks(openclaw_payload: Any) -> List[Dict[str, Any]]: - if not isinstance(openclaw_payload, dict): - return [] - result = openclaw_payload.get("result") if isinstance(openclaw_payload.get("result"), dict) else openclaw_payload - execution = result.get("execution") if isinstance(result, dict) and isinstance(result.get("execution"), dict) else {} - rows = execution.get("results") if isinstance(execution.get("results"), list) else [] - items: List[Dict[str, Any]] = [] - for row in rows: - if not isinstance(row, dict): - continue - operation_code = str(row.get("operation_code") or "").strip() - payload = row.get("payload") if isinstance(row.get("payload"), dict) else {} - status = str(row.get("status") or "").strip() - if operation_code not in {"task.create", "task.assign", "task.prioritize", "task.status_transition"}: - continue - if status not in {"applied", "planned", "skipped"}: - continue - items.append( - { - "operation_code": operation_code, - "status": status, - "entity_id": row.get("entity_id"), - "task_id": payload.get("task_id"), - "title": payload.get("title"), - "summary": str(row.get("message") or "").strip(), - } - ) - return items - - async def capture_from_browser( - self, - user_id: int, - url: str, - title: str, - content_preview: str, - tenant_id: int = 1 - ) -> Dict[str, Any]: - """ - 从浏览器标签页捕获信号 - - 触发条件: - - 访问技术文档、API 文档 - - 浏览竞品网站、产品页面 - - 查看 GitHub issues/PRs - - 阅读技术博客 - """ - # AI 分析页面价值 - analysis = await self._analyze_webpage( - url, - title, - content_preview, - tenant_id=tenant_id, - ) - - if not analysis['is_valuable']: - return {'status': 'ignored', 'reason': analysis['reason']} - - # 创建信号 - await ensure_signal_id_sequence(self.db) - - signal = Signal( - source='Browser', - content=f"[浏览] {title}: {content_preview[:200]}", - impact_score=analysis['impact_score'], - priority=analysis['priority'], - processed=True, - strategic_analysis=json.dumps({ - 'url': url, - 'captured_at': now_cn_aware().isoformat(), - 'user_id': user_id, - 'ai_analysis': analysis.get('analysis', {}) - }), - tasks_generated=json.dumps([]), - tenant_id=tenant_id - ) - self.db.add(signal) - await self.db.commit() - await self.db.refresh(signal) - - # 自动关联到相关项目 - await self._auto_link_to_projects(signal.id, analysis, tenant_id) - - return { - 'signal_id': str(signal.id), - 'status': 'captured', - 'impact_score': signal.impact_score - } - - async def capture_from_github( - self, - repo_url: str, - event_type: str, - event_data: Dict[str, Any], - tenant_id: int = 1 - ) -> Dict[str, Any]: - """ - 从 GitHub 事件捕获信号 - - 监控事件: - - 竞品仓库更新 - - 上游依赖更新 - - 技术趋势项目发布 - """ - # AI 分析 GitHub 事件 - analysis = await self._analyze_github_event( - repo_url, - event_type, - event_data, - tenant_id=tenant_id, - ) - - if not analysis['is_valuable']: - return {'status': 'ignored', 'reason': analysis['reason']} - - # 创建信号 - await ensure_signal_id_sequence(self.db) - - signal = Signal( - source='GitHub', - content=f"[{event_type}] {repo_url}: {event_data.get('title', event_data.get('message', ''))[:200]}", - impact_score=analysis['impact_score'], - priority=analysis['priority'], - processed=True, - strategic_analysis=json.dumps({ - 'repo_url': repo_url, - 'event_type': event_type, - 'event_data': event_data, - 'captured_at': now_cn_aware().isoformat(), - 'ai_analysis': analysis.get('analysis', {}) - }), - tasks_generated=json.dumps([]), - tenant_id=tenant_id - ) - self.db.add(signal) - await self.db.commit() - await self.db.refresh(signal) - - return { - 'signal_id': str(signal.id), - 'status': 'captured', - 'impact_score': signal.impact_score - } - - async def start_hackernews_monitor( - self, - interval_minutes: int = 30, - tenant_id: int = 1 - ): - """ - 启动 HackerNews 定时监控 - - 定期抓取 HackerNews 首页,分析技术趋势 - """ - while True: - try: - # 抓取 HackerNews 首页 - from .fetcher import fetch_hn_stories - # 兼容三种实现: - # 1) async def -> 直接 await - # 2) sync def -> to_thread 防阻塞 - # 3) sync def 返回 coroutine -> 对返回值再 await - if inspect.iscoroutinefunction(fetch_hn_stories): - stories = await fetch_hn_stories(limit=30) - else: - result = await asyncio.to_thread(fetch_hn_stories, 30) - stories = await result if inspect.isawaitable(result) else result - - for story in stories[:10]: # 只分析前10条 - # AI 分析每条故事 - analysis = await self._analyze_hackernews_story( - story, - tenant_id=tenant_id, - ) - - if analysis.get('is_valuable'): - # 创建信号 - await ensure_signal_id_sequence(self.db) - - signal = Signal( - source='HackerNews', - content=f"HN: {story.get('title', '')[:200]}", - impact_score=analysis['impact_score'], - priority=analysis['priority'], - processed=True, - strategic_analysis=json.dumps({ - 'hn_url': story.get('url'), - 'hn_points': story.get('points'), - 'captured_at': now_cn_aware().isoformat(), - 'ai_analysis': analysis.get('analysis', {}) - }), - tasks_generated=json.dumps(analysis.get('suggested_tasks', [])), - tenant_id=tenant_id - ) - self.db.add(signal) - await self.db.commit() - - self._emit_ai_think( - "hn_story_captured", - title=str(story.get("title") or "")[:50], - ) - - # 等待下次抓取 - await asyncio.sleep(interval_minutes * 60) - - except Exception as e: - self._emit_ai_think("hn_monitor_error", error=str(e)) - await asyncio.sleep(interval_minutes * 60) - - async def _load_active_projects_context(self, tenant_id: int) -> List[Dict[str, Any]]: - result = await self.db.execute( - select(Project).where( - Project.status == "active", - Project.tenant_id == tenant_id, - ) - ) - projects = result.scalars().all() - rows: List[Dict[str, Any]] = [] - for project in projects: - rows.append( - { - "id": int(project.id), - "title": (project.title or project.name or "").strip(), - "description": (project.description or "").strip()[:300], - "status": project.status, - } - ) - return rows - - def _emit_ai_think(self, stage: str, **payload: Any) -> None: - safe_payload: Dict[str, Any] = {} - for key, value in payload.items(): - safe_payload[key] = self._safe_log_value(value) - logger.info("signal_capture_ai_think", stage=stage, **safe_payload) - try: - stage_zh = AI_THINK_STAGE_ZH.get(stage, stage) - now_text = self._format_cn_time(now_cn_aware()) - print( - f"[{now_text}][AI思考][SignalCapture] {stage_zh} " - + json.dumps({"stage": stage, **safe_payload}, ensure_ascii=False) - ) - except Exception: - pass - - @staticmethod - def _safe_log_value(value: Any, max_len: int = 600) -> Any: - if isinstance(value, (str, int, float, bool)) or value is None: - text = str(value) if value is not None else None - if text is None: - return None - return text if len(text) <= max_len else f"{text[:max_len]}...(truncated)" - try: - text = json.dumps(value, ensure_ascii=False, default=str) - except Exception: - text = str(value) - return text if len(text) <= max_len else f"{text[:max_len]}...(truncated)" - - async def _auto_orchestrate_project_tasks( - self, - *, - trace_id: str, - tenant_id: int, - project_id: int, - signal_id: int, - cleaned_message: str, - classification: Dict[str, Any], - proposer_name: str, - captured_at: datetime, - ) -> tuple[List[Dict[str, Any]], Dict[str, Any]]: - project = ( - await self.db.execute( - select(Project).where( - and_( - Project.id == project_id, - Project.tenant_id == tenant_id, - ) - ) - ) - ).scalar_one_or_none() - if not project: - return [], { - "trace_id": trace_id, - "status": "skipped", - "reason": "project_not_found", - "project_id": project_id, - "summary": "", - "tasks_created": 0, - } - - charter_id = await self._ensure_project_charter_node( - tenant_id=tenant_id, - project_id=project_id, - proposer_name=proposer_name, - cleaned_message=cleaned_message, - captured_at=captured_at, - ) - - team_members = await self._load_project_team_context( - tenant_id=tenant_id, - project_id=project_id, - owner_id=int(project.owner_id) if project.owner_id else None, - ) - if not team_members: - return [], { - "trace_id": trace_id, - "status": "skipped", - "reason": "no_active_members", - "project_id": project_id, - "summary": "", - "tasks_created": 0, - } - - project_payload = { - "id": int(project.id), - "title": (project.title or project.name or "").strip(), - "description": (project.description or "").strip()[:600], - "assigned_role": (project.assigned_role or "").strip(), - } - signal_payload = { - "signal_id": signal_id, - "message": cleaned_message[:1200], - "classification": classification, - "proposer": proposer_name, - "captured_at": captured_at.isoformat(), - } - prompt = PROMPT_FEISHU_TASK_ORCHESTRATION.format( - project_json=json.dumps(project_payload, ensure_ascii=False), - signal_json=json.dumps(signal_payload, ensure_ascii=False), - members_json=json.dumps(team_members, ensure_ascii=False), - ) - - llm_status = "success" - llm_error = "" - parsed: Dict[str, Any] = {} - try: - llm_raw = await self.llm.generate( - prompt, - db=self.db, - tenant_id=tenant_id, - function_name="feishu_task_orchestration", - ) - parsed = self._parse_llm_json_response(llm_raw) - self._emit_ai_think( - "task_plan_llm_done", - trace_id=trace_id, - project_id=project_id, - llm_summary=parsed.get("overall_summary", ""), - ) - except Exception as exc: - llm_status = "failed" - llm_error = str(exc) - self._emit_ai_think( - "task_plan_llm_failed", - trace_id=trace_id, - project_id=project_id, - error=llm_error, - ) - - tasks_payload = parsed.get("tasks") if isinstance(parsed.get("tasks"), list) else [] - if not tasks_payload: - tasks_payload = self._build_task_orchestration_fallback( - cleaned_message=cleaned_message, - classification=classification, - ) - - created_items: List[Dict[str, Any]] = [] - touched_user_ids: set[int] = set() - created_count = 0 - for idx, raw_task in enumerate(tasks_payload[:8]): - if not isinstance(raw_task, dict): - continue - title = self._normalize_single_line_text( - str(raw_task.get("title") or raw_task.get("task_name") or ""), - max_len=64, - ) - if not title: - continue - description = self._normalize_multiline_text( - str(raw_task.get("description") or cleaned_message), - max_len=800, - ) - priority = self._normalize_priority(raw_task.get("priority")) - estimated_hours = self._normalize_estimated_hours(raw_task.get("estimated_hours")) - required_skill_names = self._normalize_skill_name_list(raw_task.get("required_skill_names")) - required_skill_tag_ids = await self._resolve_skill_tag_ids( - tenant_id=tenant_id, - skill_names=required_skill_names, - ) - ai_recommended_assignee_id = self._to_int(raw_task.get("recommended_assignee_id")) - assignee_id, decision = self._pick_task_assignee( - team_members=team_members, - required_skill_tag_ids=required_skill_tag_ids, - ai_recommended_assignee_id=ai_recommended_assignee_id, - ) - - task = Task( - tenant_id=tenant_id, - truth_node_id=charter_id, - project_id=project_id, - assignee_id=assignee_id, - title=title, - description=description, - required_skill_tag_ids=required_skill_tag_ids, - status="todo", - priority=priority, - estimated_hours=estimated_hours, - ai_recommended_assignees={ - "trace_id": trace_id, - "ai_recommended_assignee_id": ai_recommended_assignee_id, - "final_assignee_id": assignee_id, - "assignee_reason": self._normalize_single_line_text( - str(raw_task.get("assignee_reason") or ""), - max_len=300, - ), - "required_skill_names": required_skill_names, - "decision": decision, - "confidence": float(raw_task.get("confidence") or 0.0), - }, - ) - self.db.add(task) - await self.db.flush() - created_count += 1 - - assignee_name = "" - if assignee_id: - touched_user_ids.add(int(assignee_id)) - match_member = next((m for m in team_members if int(m.get("user_id", 0)) == int(assignee_id)), None) - if isinstance(match_member, dict): - assignee_name = str(match_member.get("name") or "") - - item = { - "task_id": int(task.id), - "task_name": title, - "priority": priority, - "estimated_hours": float(task.estimated_hours or 0), - "required_skill_names": required_skill_names, - "required_skill_tag_ids": required_skill_tag_ids, - "assignee_id": int(assignee_id) if assignee_id else None, - "assignee_name": assignee_name, - "reason": decision.get("picked_reason", ""), - } - created_items.append(item) - self._emit_ai_think( - "task_created", - trace_id=trace_id, - project_id=project_id, - rank=idx + 1, - task_id=task.id, - task_name=title, - assignee_id=item["assignee_id"], - decision=item["reason"], - ) - - await self.db.commit() - for uid in sorted(touched_user_ids): - await workload_service.refresh_user_assigned_hours_from_tasks( - self.db, - tenant_id=tenant_id, - user_id=uid, - ) - - summary = self._normalize_single_line_text( - str(parsed.get("overall_summary") or ""), - max_len=200, - ) - if not summary: - summary = "AI 自动拆解并分配飞书需求任务" - - execution_meta = { - "trace_id": trace_id, - "status": "success" if created_count > 0 else "skipped", - "reason": "ok" if created_count > 0 else "no_valid_tasks", - "project_id": project_id, - "charter_id": charter_id, - "summary": summary, - "tasks_created": created_count, - "llm_status": llm_status, - "llm_error": llm_error[:300] if llm_error else "", - } - return created_items, execution_meta - - async def _ensure_project_charter_node( - self, - *, - tenant_id: int, - project_id: int, - proposer_name: str, - cleaned_message: str, - captured_at: datetime, - ) -> int: - existing = ( - await self.db.execute( - select(TruthNode).where( - and_( - TruthNode.tenant_id == tenant_id, - TruthNode.project_id == project_id, - TruthNode.node_type == "charter", - ) - ).order_by(TruthNode.updated_at.desc(), TruthNode.id.desc()) - ) - ).scalars().first() - if existing: - return int(existing.id) - - project = ( - await self.db.execute( - select(Project).where( - and_( - Project.id == project_id, - Project.tenant_id == tenant_id, - ) - ) - ) - ).scalar_one_or_none() - if not project: - raise ValueError("project_not_found_for_charter") - - creator_id = await self._pick_truth_node_creator( - tenant_id=tenant_id, - project_id=project_id, - owner_id=int(project.owner_id) if project.owner_id else None, - ) - node = TruthNode( - tenant_id=tenant_id, - node_type="charter", - project_id=project_id, - created_by=creator_id, - title=self._normalize_single_line_text( - f"{project.title or project.name or '项目'} 自动宪章", - max_len=120, - ), - content={ - "rationale": "由飞书信号自动触发的初版执行宪章", - "source": "feishu_auto_orchestration", - "proposer": proposer_name, - "captured_at": captured_at.isoformat(), - "seed_message": cleaned_message[:500], - "success_criteria": ["建立可执行任务链", "明确负责人", "控制团队负载"], - "constraints": ["后续可由管理员更新细化"], - }, - version=1, - status="active", - ) - self.db.add(node) - await self.db.flush() - self._emit_ai_think( - "charter_auto_created", - project_id=project_id, - charter_id=node.id, - created_by=creator_id, - ) - return int(node.id) - - async def _pick_truth_node_creator( - self, - *, - tenant_id: int, - project_id: int, - owner_id: Optional[int], - ) -> int: - if owner_id: - owner = ( - await self.db.execute( - select(User.id).where( - and_( - User.tenant_id == tenant_id, - User.id == owner_id, - User.status == "active", - ) - ).limit(1) - ) - ).scalars().first() - if owner: - return int(owner) - - project_member = ( - await self.db.execute( - select(UserProject.user_id) - .join( - User, - and_( - User.id == UserProject.user_id, - User.tenant_id == tenant_id, - User.status == "active", - User.is_active.is_(True), - ), - ) - .where( - and_( - UserProject.tenant_id == tenant_id, - UserProject.project_id == project_id, - ) - ) - .order_by(UserProject.user_id.asc()) - .limit(1) - ) - ).scalars().first() - if project_member: - return int(project_member) - - admin_user = ( - await self.db.execute( - select(User.id).where( - and_( - User.tenant_id == tenant_id, - User.status == "active", - User.is_active.is_(True), - User.role.in_(["BOSS", "ADMIN"]), - ) - ).order_by(User.id.asc()).limit(1) - ) - ).scalars().first() - if admin_user: - return int(admin_user) - - any_active = ( - await self.db.execute( - select(User.id).where( - and_( - User.tenant_id == tenant_id, - User.status == "active", - User.is_active.is_(True), - ) - ).order_by(User.id.asc()).limit(1) - ) - ).scalars().first() - if any_active: - return int(any_active) - raise ValueError("no_active_user_for_charter_creator") - - async def _load_project_team_context( - self, - *, - tenant_id: int, - project_id: int, - owner_id: Optional[int], - ) -> List[Dict[str, Any]]: - member_ids_result = await self.db.execute( - select(UserProject.user_id).where( - and_( - UserProject.tenant_id == tenant_id, - UserProject.project_id == project_id, - ) - ) - ) - member_ids = {int(uid) for uid in member_ids_result.scalars().all()} - if owner_id: - member_ids.add(int(owner_id)) - if not member_ids: - manager_result = await self.db.execute( - select(User.id).where( - and_( - User.tenant_id == tenant_id, - User.status == "active", - User.is_active.is_(True), - User.role.in_(["BOSS", "ADMIN"]), - ) - ).limit(5) - ) - member_ids = {int(uid) for uid in manager_result.scalars().all()} - if not member_ids: - return [] - - users = ( - await self.db.execute( - select(User).where( - and_( - User.tenant_id == tenant_id, - User.id.in_(sorted(member_ids)), - User.status == "active", - User.is_active.is_(True), - ) - ) - ) - ).scalars().all() - if not users: - return [] - - user_ids = [int(u.id) for u in users] - workload_rows = ( - await self.db.execute( - select(UserWorkload).where( - and_( - UserWorkload.tenant_id == tenant_id, - UserWorkload.user_id.in_(user_ids), - ) - ) - ) - ).scalars().all() - workload_map = {int(item.user_id): item for item in workload_rows} - - skill_rows = ( - await self.db.execute( - select( - UserSkillBinding.user_id, - SkillTag.id, - SkillTag.name, - SkillTag.slug, - UserSkillBinding.proficiency, - ) - .join(SkillTag, SkillTag.id == UserSkillBinding.skill_tag_id) - .where( - and_( - UserSkillBinding.tenant_id == tenant_id, - UserSkillBinding.user_id.in_(user_ids), - SkillTag.tenant_id == tenant_id, - ) - ) - ) - ).all() - - skill_map: Dict[int, List[Dict[str, Any]]] = {} - for uid, sid, name, slug, proficiency in skill_rows: - skill_map.setdefault(int(uid), []).append( - { - "skill_id": int(sid), - "name": str(name or "").strip(), - "slug": str(slug or "").strip(), - "proficiency": int(proficiency or 0), - } - ) - - members: List[Dict[str, Any]] = [] - for user in users: - uid = int(user.id) - workload_row = workload_map.get(uid) - member_skills = skill_map.get(uid, []) - members.append( - { - "user_id": uid, - "name": str(user.name or user.username or uid), - "role": str(user.role or ""), - "workload_ratio": round( - float( - workload_row.workload_ratio - if workload_row is not None - else float(user.workload or 0.0) - ), - 2, - ), - "current_assigned_hours": round( - float(workload_row.current_assigned_hours or 0.0) if workload_row else 0.0, - 2, - ), - "max_available_hours_per_week": round( - float(workload_row.max_available_hours_per_week or 40.0) if workload_row else 40.0, - 2, - ), - "skill_tag_ids": [int(item["skill_id"]) for item in member_skills], - "skill_names": [str(item["name"]) for item in member_skills], - "skills": member_skills, - } - ) - members.sort(key=lambda x: (float(x.get("workload_ratio") or 0.0), int(x.get("user_id") or 0))) - return members - - async def _resolve_skill_tag_ids( - self, - *, - tenant_id: int, - skill_names: List[str], - ) -> List[int]: - if not skill_names: - return [] - result = await self.db.execute( - select(SkillTag).where(SkillTag.tenant_id == tenant_id) - ) - tags = result.scalars().all() - if not tags: - return [] - - exact: Dict[str, int] = {} - fuzzy: List[tuple[str, int]] = [] - for tag in tags: - name = str(tag.name or "").strip().lower() - slug = str(tag.slug or "").strip().lower() - if name: - exact[name] = int(tag.id) - fuzzy.append((name, int(tag.id))) - if slug: - exact[slug] = int(tag.id) - fuzzy.append((slug, int(tag.id))) - - resolved_ids: List[int] = [] - for raw in skill_names: - key = str(raw or "").strip().lower() - if not key: - continue - if key in exact: - resolved_ids.append(exact[key]) - continue - matched = None - for candidate, sid in fuzzy: - if key in candidate or candidate in key: - matched = sid - break - if matched: - resolved_ids.append(int(matched)) - return sorted(set(resolved_ids)) - - def _pick_task_assignee( - self, - *, - team_members: List[Dict[str, Any]], - required_skill_tag_ids: List[int], - ai_recommended_assignee_id: Optional[int], - ) -> tuple[Optional[int], Dict[str, Any]]: - if not team_members: - return None, {"picked_reason": "no_team_members", "candidate_scores": []} - - candidates: List[Dict[str, Any]] = [] - for member in team_members: - uid = self._to_int(member.get("user_id")) - if not uid: - continue - skill_ids = {int(v) for v in (member.get("skill_tag_ids") or []) if self._to_int(v)} - workload_ratio = float(member.get("workload_ratio") or 0.0) - if required_skill_tag_ids: - matched = sum(1 for sid in required_skill_tag_ids if sid in skill_ids) - skill_score = matched / len(required_skill_tag_ids) - else: - skill_score = 0.4 - workload_score = max(0.0, 140.0 - workload_ratio) / 140.0 - ai_bonus = 0.15 if ai_recommended_assignee_id and uid == int(ai_recommended_assignee_id) else 0.0 - overload_penalty = 0.2 if workload_ratio >= 150 else 0.0 - total_score = (skill_score * 0.7) + (workload_score * 0.3) + ai_bonus - overload_penalty - candidates.append( - { - "user_id": uid, - "name": member.get("name"), - "skill_score": round(skill_score, 4), - "workload_ratio": round(workload_ratio, 2), - "total_score": round(total_score, 4), - } - ) - - candidates.sort(key=lambda x: (-x["total_score"], x["workload_ratio"], x["user_id"])) - if not candidates: - return None, {"picked_reason": "no_valid_candidate", "candidate_scores": []} - - top = candidates[0] - if float(top.get("workload_ratio") or 0.0) > 160 and float(top.get("skill_score") or 0.0) < 0.5: - return None, { - "picked_reason": "all_candidates_overloaded", - "candidate_scores": candidates[:3], - } - picked_uid = int(top["user_id"]) - return picked_uid, { - "picked_reason": ( - f"score={top['total_score']},skill={top['skill_score']}," - f"workload={top['workload_ratio']}" - ), - "candidate_scores": candidates[:3], - } - - def _build_task_orchestration_fallback( - self, - *, - cleaned_message: str, - classification: Dict[str, Any], - ) -> List[Dict[str, Any]]: - summary = self._normalize_single_line_text( - str(classification.get("summary") or cleaned_message), - max_len=120, - ) - return [ - { - "title": "需求澄清与范围确认", - "description": f"基于飞书信号补全需求边界、验收口径与优先级。上下文:{summary}", - "priority": "high", - "estimated_hours": 4, - "required_skill_names": ["产品管理", "项目管理"], - "recommended_assignee_id": None, - "assignee_reason": "fallback_without_llm", - "confidence": 0.0, - }, - { - "title": "核心方案落地", - "description": f"围绕需求信号推进可交付实现,拆分后端/前端/AI 子任务并排期。上下文:{summary}", - "priority": "medium", - "estimated_hours": 12, - "required_skill_names": ["FastAPI", "React", "LangChain"], - "recommended_assignee_id": None, - "assignee_reason": "fallback_without_llm", - "confidence": 0.0, - }, - ] - - @staticmethod - def _normalize_priority(value: Any) -> str: - priority = str(value or "medium").strip().lower() - if priority in {"p0", "critical", "high"}: - return "high" - if priority in {"p2", "low"}: - return "low" - return "medium" - - @staticmethod - def _normalize_estimated_hours(value: Any) -> Optional[float]: - try: - hours = float(value) - except Exception: - return 8.0 - if hours <= 0: - return 1.0 - return round(min(hours, 80.0), 2) - - def _normalize_skill_name_list(self, value: Any) -> List[str]: - if isinstance(value, list): - names = [self._normalize_single_line_text(str(item or ""), max_len=60) for item in value] - return [name for name in names if name] - if isinstance(value, str): - split = re.split(r"[,,/|]", value) - names = [self._normalize_single_line_text(part, max_len=60) for part in split] - return [name for name in names if name] - return [] - - @staticmethod - def _to_int(value: Any) -> Optional[int]: - try: - if value is None or value == "": - return None - return int(value) - except Exception: - return None - - @staticmethod - def _parse_llm_json_response(raw_response: str) -> Dict[str, Any]: - content = (raw_response or "").strip() - if not content: - raise ValueError("empty llm response") - if content.startswith("```"): - parts = content.split("```") - if len(parts) >= 2: - content = parts[1].strip() - if content.startswith("json"): - content = content[4:].strip() - try: - parsed = json.loads(content.strip()) - if isinstance(parsed, dict): - return parsed - except Exception: - pass - - # 兼容模型返回“解释文本 + JSON”场景:抽取首个 JSON 对象再解析 - obj_text = ProactiveSignalCaptureService._extract_first_json_object(content) - if not obj_text: - raise ValueError("empty llm response") - parsed = json.loads(obj_text) - if isinstance(parsed, dict): - return parsed - raise ValueError("llm json is not object") - - @staticmethod - def _extract_first_json_object(text: str) -> Optional[str]: - raw = str(text or "") - start = raw.find("{") - if start < 0: - return None - depth = 0 - in_str = False - escaped = False - for i in range(start, len(raw)): - ch = raw[i] - if in_str: - if escaped: - escaped = False - elif ch == "\\": - escaped = True - elif ch == '"': - in_str = False - continue - if ch == '"': - in_str = True - continue - if ch == "{": - depth += 1 - elif ch == "}": - depth -= 1 - if depth == 0: - return raw[start : i + 1] - return None - - async def _analyze_chat_message( - self, - *, - message: str, - projects_context: List[Dict[str, Any]], - tenant_id: int, - ) -> Dict[str, Any]: - """AI 语义分类:existing/new/pending/non_requirement。""" - prompt = PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION.format( - projects_json=json.dumps(projects_context, ensure_ascii=False), - message=message[:800], - ) - - try: - response = await self.llm.generate( - prompt, - db=self.db, - tenant_id=tenant_id, - function_name="feishu_project_signal_classification", - ) - try: - parsed = self._parse_llm_json_response(response) - except Exception as parse_exc: - self._emit_ai_think( - "classification_parse_failed", - error=str(parse_exc), - response_preview=(response or "")[:500], - ) - raise - classification = str(parsed.get("classification") or "pending_classification").lower() - if classification not in { - "existing_project", - "new_project", - "pending_classification", - "non_requirement", - }: - classification = "pending_classification" - - matched_project_ids: List[int] = [] - for item in parsed.get("matched_project_ids", []) or []: - try: - matched_project_ids.append(int(item)) - except Exception: - continue - - matched_titles = [str(x) for x in (parsed.get("matched_project_titles") or []) if str(x).strip()] - if classification == "existing_project" and not matched_project_ids: - classification = "pending_classification" - # 对“明确新建项目意图”进行升级,避免模型过度保守地给 pending。 - if classification == "pending_classification" and self._has_explicit_new_project_intent(message): - classification = "new_project" - raw_is_requirement = parsed.get("is_requirement") - if isinstance(raw_is_requirement, bool): - is_requirement = raw_is_requirement - elif isinstance(raw_is_requirement, str): - is_requirement = raw_is_requirement.strip().lower() in {"1", "true", "yes", "y"} - else: - is_requirement = classification != "non_requirement" - if classification in {"existing_project", "new_project", "pending_classification"}: - is_requirement = True - if classification == "non_requirement": - is_requirement = False - return { - "is_requirement": is_requirement, - "reason": str(parsed.get("reason", "") or "AI 判定"), - "confidence": float(parsed.get("confidence") or 0.0), - "classification": classification, - "matched_project_ids": matched_project_ids, - "matched_project_titles": matched_titles, - "impact_score": float(parsed.get("impact_score") or 0.7), - "priority": str(parsed.get("priority", "medium") or "medium"), - "summary": str(parsed.get("summary") or "")[:200], - } - except Exception as exc: - self._emit_ai_think("classification_failed", error=str(exc), message_preview=message[:120]) - fallback_new_project = self._has_explicit_new_project_intent(message) - return { - "is_requirement": bool(fallback_new_project), - "reason": "AI 分析失败,启用保守策略", - "confidence": 0.0, - "classification": "new_project" if fallback_new_project else "non_requirement", - "matched_project_ids": [], - "matched_project_titles": [], - "impact_score": 0.6, - "priority": "medium", - "summary": "", - } - - @staticmethod - def _sanitize_feishu_message(message: str) -> str: - text = ProactiveSignalCaptureService._normalize_single_line_text(message or "", max_len=2000) - if not text: - return "" - - text = MENTION_TAG_RE.sub(" ", text) - text = text.replace("@所有人", " ").replace("@everyone", " ") - text = re.sub(r"@[_a-zA-Z0-9\-]+", " ", text) - text = EMOJI_UNICODE_RE.sub(" ", text) - text = re.sub(r"\s+", " ", text).strip() - - if not text or ONLY_PUNCT_OR_SPACE_RE.match(text): - return "" - return text - - @staticmethod - def _normalize_single_line_text(text: str, *, max_len: int = 500) -> str: - content = str(text or "") - if not content: - return "" - content = content.replace("\ufffd", "") - content = CONTROL_CHAR_RE.sub(" ", content) - content = re.sub(r"\s+", " ", content).strip() - if max_len > 0: - content = content[:max_len] - return content - - @staticmethod - def _normalize_multiline_text(text: str, *, max_len: int = 2000) -> str: - content = str(text or "") - if not content: - return "" - content = content.replace("\ufffd", "") - content = content.replace("\r\n", "\n").replace("\r", "\n") - content = CONTROL_CHAR_RE.sub(" ", content) - content = re.sub(r"[ \t]+", " ", content) - content = re.sub(r"\n{3,}", "\n\n", content).strip() - if max_len > 0: - content = content[:max_len] - return content - - @staticmethod - def _format_cn_time(dt: datetime) -> str: - safe_dt = dt - if safe_dt.tzinfo is None: - safe_dt = safe_dt.replace(tzinfo=CN_TZ) - cn_tz = timezone(timedelta(hours=8)) - return safe_dt.astimezone(cn_tz).strftime("%Y-%m-%d %H:%M:%S") - - @staticmethod - def _is_non_requirement_message(message: str) -> bool: - content = (message or "").strip().lower() - if not content: - return True - normalized = re.sub(r"\s+", "", content) - if content in NON_REQUIREMENT_EXACT: - return True - if normalized in NON_REQUIREMENT_EXACT: - return True - if any(k in normalized for k in NON_REQUIREMENT_CONTAINS) and not any( - hint in normalized for hint in REQUIREMENT_HINTS - ): - return True - if len(content) <= 2 and content.isdigit(): - return True - non_requirement_keywords = [ - "收到", - "辛苦了", - "赞", - "哈哈", - "good", - "nice", - "ok", - ] - if len(content) <= 8 and any(k in content for k in non_requirement_keywords): - return True - if ONLY_PUNCT_OR_SPACE_RE.match(content): - return True - return False - - @staticmethod - def _has_explicit_new_project_intent(message: str) -> bool: - content = (message or "").strip().lower() - if not content: - return False - explicit_phrases = [ - "新建项目", - "新增项目", - "新开项目", - "独立立项", - "重新立项", - "单独立项", - "开个新项目", - "做个新项目", - "从0到1", - ] - if any(p in content for p in explicit_phrases): - return True - # 兼容常见中文表达:“新增个需求,关于 xxx 的项目” - if ("新增" in content or "新增个需求" in content) and "项目" in content: - return True - return False - - @staticmethod - def _is_question_like(message: str) -> bool: - text = str(message or "").strip().lower() - if not text: - return False - if "?" in text or "?" in text: - return True - hints = ("吗", "怎么", "如何", "为什么", "谁", "多少", "是否", "有没有", "几号", "几点", "周几") - return any(k in text for k in hints) - - def _rule_based_chat_signal(self, message: str) -> Optional[Dict[str, Any]]: - _ = message - return None - - async def _analyze_webpage(self, url: str, title: str, content: str, tenant_id: int) -> Dict[str, Any]: - """AI 分析网页价值""" - prompt = f"""分析以下网页内容是否有价值: - -标题:{title} -URL: {url} -内容摘要:{content[:500]} - -请判断: -1. 是否是技术文档、竞品信息、行业趋势? -2. 是否值得团队关注? -3. 影响程度(0-1)? - -以 JSON 格式返回: -{{ - "is_valuable": boolean, - "reason": "原因", - "impact_score": 0.8, - "priority": "high/medium/low", - "analysis": {{}}, - "suggested_tasks": [{{"task_name": "任务", "type": "backend", "estimated_hours": 8}}] -}} - -如果不是有价值内容,is_valuable 设为 false。""" - - try: - response = await self.llm.generate( - prompt, - db=self.db, - tenant_id=tenant_id, - ) - response = response.strip() - if response.startswith('```'): - response = response.split('```')[1] - if response.startswith('json'): - response = response[4:] - return json.loads(response.strip()) - except: - return {'is_valuable': False, 'reason': 'AI 分析失败'} - - async def _analyze_github_event(self, repo_url: str, event_type: str, event_data: Dict, tenant_id: int) -> Dict[str, Any]: - """AI 分析 GitHub 事件""" - description = f"{event_type}: {event_data.get('title', event_data.get('message', ''))}" - - prompt = f"""分析以下 GitHub 事件是否有价值: - -仓库:{repo_url} -事件:{description} - -请判断: -1. 是否是技术栈更新、安全修复、重要功能? -2. 是否影响我们的项目? -3. 影响程度(0-1)? - -以 JSON 格式返回: -{{ - "is_valuable": boolean, - "reason": "原因", - "impact_score": 0.8, - "priority": "high/medium/low", - "analysis": {{}}, - "suggested_tasks": [{{"task_name": "任务", "type": "backend", "estimated_hours": 8}}] -}} - -如果不是有价值内容,is_valuable 设为 false。""" - - try: - response = await self.llm.generate( - prompt, - db=self.db, - tenant_id=tenant_id, - ) - response = response.strip() - if response.startswith('```'): - response = response.split('```')[1] - if response.startswith('json'): - response = response[4:] - return json.loads(response.strip()) - except: - return {'is_valuable': False, 'reason': 'AI 分析失败'} - - async def _analyze_hackernews_story(self, story: Dict, tenant_id: int) -> Dict[str, Any]: - """AI 分析 HackerNews 故事""" - prompt = f"""分析以下 HackerNews 故事是否是重要技术趋势: - -标题:{story.get('title', '')} -链接:{story.get('url', '')} -得分:{story.get('points', 0)} - -请判断: -1. 是否是行业趋势、技术突破? -2. 是否值得我们关注? -3. 影响程度(0-1)? - -以 JSON 格式返回: -{{ - "is_strategic": boolean, - "impact_score": 0.8, - "priority": "high/medium/low", - "analysis": {{}}, - "suggested_tasks": [{{"task_name": "任务", "type": "backend", "estimated_hours": 8}}] -}} - -如果不是战略性内容,is_strategic 设为 false。""" - - try: - response = await self.llm.generate( - prompt, - db=self.db, - tenant_id=tenant_id, - ) - response = response.strip() - if response.startswith('```'): - response = response.split('```')[1] - if response.startswith('json'): - response = response[4:] - result = json.loads(response.strip()) - # 重命名字段以匹配其他方法 - return { - 'is_valuable': result.get('is_strategic', False), - 'reason': result.get('reason', ''), - 'impact_score': result.get('impact_score', 0.5), - 'priority': result.get('priority', 'low'), - 'analysis': result.get('analysis', {}), - 'suggested_tasks': result.get('suggested_tasks', []) - } - except: - return {'is_valuable': False, 'reason': 'AI 分析失败'} - - async def _auto_link_to_projects( - self, - signal_id: int, - analysis: Dict[str, Any], - tenant_id: int, - message_content: Optional[str] = None, - ) -> List[int]: - """自动关联信号到相关项目""" - _ = message_content - classification = str(analysis.get("classification") or "").lower() - if classification in {"new_project", "pending_classification", "non_requirement"}: - return [] - - if classification == "existing_project": - matched_ids: List[int] = [] - for pid in analysis.get("matched_project_ids", []) or []: - try: - matched_ids.append(int(pid)) - except Exception: - continue - matched_ids = sorted(set(matched_ids)) - if not matched_ids: - return [] - - result = await self.db.execute( - select(Project.id).where( - and_( - Project.id.in_(matched_ids), - Project.status == "active", - Project.tenant_id == tenant_id, - ) - ) - ) - valid_ids = {int(v) for v in result.scalars().all()} - if not valid_ids: - return [] - confidence = float(analysis.get("confidence") or 0.0) - relevance = max(0.5, min(0.98, 0.55 + confidence * 0.4)) - link_type = "ai_existing_project_signal" - else: - result = await self.db.execute( - select(Project.id).where( - and_(Project.status == "active", Project.tenant_id == tenant_id) - ) - ) - valid_ids = {int(v) for v in result.scalars().all()} - if not valid_ids: - return [] - relevance = 0.7 - link_type = "auto_captured" - - linked_project_ids: List[int] = [] - - for project_id in sorted(valid_ids): - existing = await self.db.execute( - text("SELECT id FROM project_signal_links WHERE project_id = :project_id AND signal_id = :signal_id"), - {"project_id": project_id, "signal_id": signal_id}, - ) - if existing.first() is None: - from ..models import ProjectSignalLink - link = ProjectSignalLink( - project_id=project_id, - signal_id=signal_id, - link_type=link_type, - relevance_score=relevance, - tenant_id=tenant_id, - ) - self.db.add(link) - linked_project_ids.append(project_id) - - await self.db.commit() - return linked_project_ids - - async def _auto_create_project_from_new_signal( - self, - *, - signal_id: int, - message: str, - sender: str, - tenant_id: int, - requested_at: Optional[datetime] = None, - ) -> Optional[int]: - """当分类为 new_project 时,自动创建项目并关联当前信号。""" - requested_dt = requested_at or now_cn_aware() - if requested_dt.tzinfo is None: - requested_dt = requested_dt.replace(tzinfo=CN_TZ) - project_brief = await self._draft_new_project_brief( - message=message, - tenant_id=tenant_id, - sender=sender, - requested_at=requested_dt, - ) - title = self._normalize_single_line_text(project_brief.get("title") or "", max_len=120) - if not title: - title = self._normalize_single_line_text(message or "飞书新需求立项", max_len=40) or "飞书新需求立项" - raw_desc = self._normalize_multiline_text(project_brief.get("description") or message or "", max_len=1200) - requested_at_text = self._format_cn_time(requested_dt) - description = self._normalize_multiline_text( - f"需求提出人:{sender}\n" - f"提出时间:{requested_at_text}\n" - f"需求来源:飞书群聊\n\n" - f"需求说明:{raw_desc or self._normalize_multiline_text(message, max_len=500)}", - max_len=1800, - ) - assigned_role = str(project_brief.get("primary_role") or "ai_engineer").strip()[:50] or "ai_engineer" - - owner_id: Optional[int] = None - owner_result = await self.db.execute( - select(User.id).where( - and_( - User.tenant_id == tenant_id, - User.status == "active", - User.role.in_(["BOSS", "ADMIN"]), - ) - ).order_by(User.id.asc()).limit(1) - ) - owner_id = owner_result.scalar_one_or_none() - - await ensure_project_id_sequence(self.db) - project = Project( - name=title[:80] or "新项目", - title=title, - description=description, - owner_id=owner_id, - status="active", - assigned_role=assigned_role, - signal_count=1, - tenant_id=tenant_id, - ) - self.db.add(project) - await self.db.flush() - await ensure_project_default_members( - self.db, - tenant_id=tenant_id, - project_id=int(project.id), - owner_id=int(project.owner_id) if project.owner_id is not None else None, - assigned_role=project.assigned_role, - ) - - existing = await self.db.execute( - text("SELECT id FROM project_signal_links WHERE project_id = :project_id AND signal_id = :signal_id"), - {"project_id": int(project.id), "signal_id": int(signal_id)}, - ) - if existing.first() is None: - link = ProjectSignalLink( - project_id=int(project.id), - signal_id=int(signal_id), - link_type="ai_new_project_signal", - relevance_score=0.98, - tenant_id=tenant_id, - ) - self.db.add(link) - - await self.db.execute( - text("UPDATE signals SET processed = :processed WHERE id = :signal_id"), - {"processed": True, "signal_id": int(signal_id)}, - ) - await self.db.commit() - - self._emit_ai_think( - "new_project_created_from_signal", - signal_id=signal_id, - project_id=int(project.id), - sender=sender, - ) - return int(project.id) - - async def _draft_new_project_brief( - self, - *, - message: str, - tenant_id: int, - sender: str, - requested_at: datetime, - ) -> Dict[str, Any]: - requested_at_text = self._format_cn_time(requested_at) - prompt = f"""你是 PMFlow 的立项助手。基于下面需求消息,提炼新项目基础信息。 - -需求提出人:{sender} -提出时间:{requested_at_text} - -需求消息: -{message[:1000]} - -输出 JSON: -{{ - "title": "项目标题(15-30字)", - "description": "项目简介(80-200字)", - "primary_role": "ai_engineer|backend|frontend|devops" -}} - -只输出 JSON,不要 markdown,不要解释,不要乱码。""" - try: - raw = await self.llm.generate( - prompt, - db=self.db, - tenant_id=tenant_id, - function_name="feishu_new_project_brief", - ) - parsed = self._parse_llm_json_response(raw) - role = str(parsed.get("primary_role") or "ai_engineer").strip().lower() - if role not in {"ai_engineer", "backend", "frontend", "devops"}: - role = "ai_engineer" - return { - "title": self._normalize_single_line_text(str(parsed.get("title") or ""), max_len=120), - "description": self._normalize_multiline_text(str(parsed.get("description") or ""), max_len=1200), - "primary_role": role, - } - except Exception as exc: - self._emit_ai_think("draft_new_project_brief_failed", error=str(exc)) - return { - "title": self._normalize_single_line_text(message or "飞书新需求立项", max_len=40), - "description": self._normalize_multiline_text(message, max_len=300), - "primary_role": "ai_engineer", - } - - -# 全局服务实例 -_signal_capture_service: Optional[ProactiveSignalCaptureService] = None - - -def get_signal_capture_service() -> ProactiveSignalCaptureService: - """获取信号捕获服务实例(需要在 async 上下文中使用)""" - return ProactiveSignalCaptureService(None) # Placeholder +__all__ = ["ProactiveSignalCaptureService", "get_signal_capture_service"] diff --git a/backend/app/services/signal_capture_service/__init__.py b/backend/app/services/signal_capture_service/__init__.py new file mode 100644 index 00000000..393dba6e --- /dev/null +++ b/backend/app/services/signal_capture_service/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .service import ProactiveSignalCaptureService, get_signal_capture_service + +__all__ = ["ProactiveSignalCaptureService", "get_signal_capture_service"] diff --git a/backend/app/services/signal_capture_service/mixin_analysis_normalize.py b/backend/app/services/signal_capture_service/mixin_analysis_normalize.py new file mode 100644 index 00000000..490ca424 --- /dev/null +++ b/backend/app/services/signal_capture_service/mixin_analysis_normalize.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +from .shared import * + +class SignalCaptureAnalysisNormalizeMixin: + @staticmethod + def _parse_llm_json_response(raw_response: str) -> Dict[str, Any]: + content = (raw_response or "").strip() + if not content: + raise ValueError("empty llm response") + if content.startswith("```"): + parts = content.split("```") + if len(parts) >= 2: + content = parts[1].strip() + if content.startswith("json"): + content = content[4:].strip() + try: + parsed = json.loads(content.strip()) + if isinstance(parsed, dict): + return parsed + except Exception: + pass + + # 兼容模型返回“解释文本 + JSON”场景:抽取首个 JSON 对象再解析 + obj_text = ProactiveSignalCaptureService._extract_first_json_object(content) + if not obj_text: + raise ValueError("empty llm response") + parsed = json.loads(obj_text) + if isinstance(parsed, dict): + return parsed + raise ValueError("llm json is not object") + + @staticmethod + def _extract_first_json_object(text: str) -> Optional[str]: + raw = str(text or "") + start = raw.find("{") + if start < 0: + return None + depth = 0 + in_str = False + escaped = False + for i in range(start, len(raw)): + ch = raw[i] + if in_str: + if escaped: + escaped = False + elif ch == "\\": + escaped = True + elif ch == '"': + in_str = False + continue + if ch == '"': + in_str = True + continue + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return raw[start : i + 1] + return None + + async def _analyze_chat_message( + self, + *, + message: str, + projects_context: List[Dict[str, Any]], + tenant_id: int, + ) -> Dict[str, Any]: + """AI 语义分类:existing/new/pending/non_requirement。""" + prompt = PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION.format( + projects_json=json.dumps(projects_context, ensure_ascii=False), + message=message[:800], + ) + + try: + response = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + function_name="feishu_project_signal_classification", + ) + try: + parsed = self._parse_llm_json_response(response) + except Exception as parse_exc: + self._emit_ai_think( + "classification_parse_failed", + error=str(parse_exc), + response_preview=(response or "")[:500], + ) + raise + classification = str(parsed.get("classification") or "pending_classification").lower() + if classification not in { + "existing_project", + "new_project", + "pending_classification", + "non_requirement", + }: + classification = "pending_classification" + + matched_project_ids: List[int] = [] + for item in parsed.get("matched_project_ids", []) or []: + try: + matched_project_ids.append(int(item)) + except Exception: + continue + + matched_titles = [str(x) for x in (parsed.get("matched_project_titles") or []) if str(x).strip()] + if classification == "existing_project" and not matched_project_ids: + classification = "pending_classification" + # 对“明确新建项目意图”进行升级,避免模型过度保守地给 pending。 + if classification == "pending_classification" and self._has_explicit_new_project_intent(message): + classification = "new_project" + raw_is_requirement = parsed.get("is_requirement") + if isinstance(raw_is_requirement, bool): + is_requirement = raw_is_requirement + elif isinstance(raw_is_requirement, str): + is_requirement = raw_is_requirement.strip().lower() in {"1", "true", "yes", "y"} + else: + is_requirement = classification != "non_requirement" + if classification in {"existing_project", "new_project", "pending_classification"}: + is_requirement = True + if classification == "non_requirement": + is_requirement = False + return { + "is_requirement": is_requirement, + "reason": str(parsed.get("reason", "") or "AI 判定"), + "confidence": float(parsed.get("confidence") or 0.0), + "classification": classification, + "matched_project_ids": matched_project_ids, + "matched_project_titles": matched_titles, + "impact_score": float(parsed.get("impact_score") or 0.7), + "priority": str(parsed.get("priority", "medium") or "medium"), + "summary": str(parsed.get("summary") or "")[:200], + } + except Exception as exc: + self._emit_ai_think("classification_failed", error=str(exc), message_preview=message[:120]) + fallback_new_project = self._has_explicit_new_project_intent(message) + return { + "is_requirement": bool(fallback_new_project), + "reason": "AI 分析失败,启用保守策略", + "confidence": 0.0, + "classification": "new_project" if fallback_new_project else "non_requirement", + "matched_project_ids": [], + "matched_project_titles": [], + "impact_score": 0.6, + "priority": "medium", + "summary": "", + } + + @staticmethod + def _sanitize_feishu_message(message: str) -> str: + text = ProactiveSignalCaptureService._normalize_single_line_text(message or "", max_len=2000) + if not text: + return "" + + text = MENTION_TAG_RE.sub(" ", text) + text = text.replace("@所有人", " ").replace("@everyone", " ") + text = re.sub(r"@[_a-zA-Z0-9\-]+", " ", text) + text = EMOJI_UNICODE_RE.sub(" ", text) + text = re.sub(r"\s+", " ", text).strip() + + if not text or ONLY_PUNCT_OR_SPACE_RE.match(text): + return "" + return text + + @staticmethod + def _normalize_single_line_text(text: str, *, max_len: int = 500) -> str: + content = str(text or "") + if not content: + return "" + content = content.replace("\ufffd", "") + content = CONTROL_CHAR_RE.sub(" ", content) + content = re.sub(r"\s+", " ", content).strip() + if max_len > 0: + content = content[:max_len] + return content + + @staticmethod + def _normalize_multiline_text(text: str, *, max_len: int = 2000) -> str: + content = str(text or "") + if not content: + return "" + content = content.replace("\ufffd", "") + content = content.replace("\r\n", "\n").replace("\r", "\n") + content = CONTROL_CHAR_RE.sub(" ", content) + content = re.sub(r"[ \t]+", " ", content) + content = re.sub(r"\n{3,}", "\n\n", content).strip() + if max_len > 0: + content = content[:max_len] + return content + + @staticmethod + def _format_cn_time(dt: datetime) -> str: + safe_dt = dt + if safe_dt.tzinfo is None: + safe_dt = safe_dt.replace(tzinfo=CN_TZ) + cn_tz = timezone(timedelta(hours=8)) + return safe_dt.astimezone(cn_tz).strftime("%Y-%m-%d %H:%M:%S") + + @staticmethod + def _is_non_requirement_message(message: str) -> bool: + content = (message or "").strip().lower() + if not content: + return True + normalized = re.sub(r"\s+", "", content) + if content in NON_REQUIREMENT_EXACT: + return True + if normalized in NON_REQUIREMENT_EXACT: + return True + if any(k in normalized for k in NON_REQUIREMENT_CONTAINS) and not any( + hint in normalized for hint in REQUIREMENT_HINTS + ): + return True + if len(content) <= 2 and content.isdigit(): + return True + non_requirement_keywords = [ + "收到", + "辛苦了", + "赞", + "哈哈", + "good", + "nice", + "ok", + ] + if len(content) <= 8 and any(k in content for k in non_requirement_keywords): + return True + if ONLY_PUNCT_OR_SPACE_RE.match(content): + return True + return False + + @staticmethod + def _has_explicit_new_project_intent(message: str) -> bool: + content = (message or "").strip().lower() + if not content: + return False + explicit_phrases = [ + "新建项目", + "新增项目", + "新开项目", + "独立立项", + "重新立项", + "单独立项", + "开个新项目", + "做个新项目", + "从0到1", + ] + if any(p in content for p in explicit_phrases): + return True + # 兼容常见中文表达:“新增个需求,关于 xxx 的项目” + if ("新增" in content or "新增个需求" in content) and "项目" in content: + return True + return False + + @staticmethod + def _is_question_like(message: str) -> bool: + text = str(message or "").strip().lower() + if not text: + return False + if "?" in text or "?" in text: + return True + hints = ("吗", "怎么", "如何", "为什么", "谁", "多少", "是否", "有没有", "几号", "几点", "周几") + return any(k in text for k in hints) + + def _rule_based_chat_signal(self, message: str) -> Optional[Dict[str, Any]]: + _ = message + return None + diff --git a/backend/app/services/signal_capture_service/mixin_capture_entry.py b/backend/app/services/signal_capture_service/mixin_capture_entry.py new file mode 100644 index 00000000..075c816d --- /dev/null +++ b/backend/app/services/signal_capture_service/mixin_capture_entry.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from .shared import * + +class SignalCaptureEntryMixin: + def __init__(self, db: AsyncSession): + self.db = db + self.llm = LLMService() + + async def capture_from_feishu( + self, + group_chat_id: str, + message_content: str, + sender: str, + tenant_id: int = 1, + proposer_open_id: Optional[str] = None, + captured_at: Optional[datetime] = None, + mentioned_bot_names: Optional[List[str]] = None, + feishu_message_id: Optional[str] = None, + chat_type: Optional[str] = None, + ) -> Dict[str, Any]: + """ + 从飞书群聊捕获信号 + + 触发条件: + - 提到产品、竞品、技术关键词 + - 讨论架构、技术选型 + - 分享技术文章 + """ + trace_id = f"feishu-{uuid4().hex[:12]}" + captured_dt = captured_at or now_cn_aware() + if captured_dt.tzinfo is None: + captured_dt = captured_dt.replace(tzinfo=CN_TZ) + + proposer_name = self._normalize_single_line_text(sender or "未知用户", max_len=80) or "未知用户" + proposer_open_id = self._normalize_single_line_text(proposer_open_id or "", max_len=128) or None + self._emit_ai_think( + "capture_start", + trace_id=trace_id, + tenant_id=tenant_id, + group_chat_id=group_chat_id, + sender=proposer_name, + ) + + cleaned_message = self._sanitize_feishu_message(message_content) + if not cleaned_message: + self._emit_ai_think("capture_skip_empty", trace_id=trace_id) + return {"status": "ignored", "reason": "empty_or_emoji_only"} + if self._is_non_requirement_message(cleaned_message) and not self._is_question_like(cleaned_message): + self._emit_ai_think( + "capture_skip_noise", + trace_id=trace_id, + message_preview=cleaned_message[:120], + ) + return {"status": "ignored", "reason": "non_requirement_noise"} + + kernel = AIPMOKernelService(self.db, tenant_id=tenant_id) + try: + pipeline_result = await kernel.process_feishu_message( + group_chat_id=group_chat_id, + message_content=cleaned_message, + sender=proposer_name, + source_message_id=feishu_message_id, + source_sender_open_id=proposer_open_id, + captured_at=captured_dt, + chat_type=chat_type, + ) + if not bool(pipeline_result.get("handled")): + self._emit_ai_think( + "capture_kernel_failed", + trace_id=trace_id, + reason=pipeline_result.get("reason"), + error=pipeline_result.get("error"), + ) + return { + "status": "error", + "reason": str(pipeline_result.get("reason") or "kernel_not_handled"), + "classification": "error", + "project_scope": "error", + "linked_project_ids": [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": trace_id, + "direct_reply_required": True, + "direct_reply_text": "系统处理中出现异常,本次未执行入库。请稍后重试,或补充更明确的目标/对象/动作。", + } + self._emit_ai_think( + "capture_done_ai_pmo", + trace_id=trace_id, + classification=pipeline_result.get("classification"), + linked_project_ids=pipeline_result.get("linked_project_ids", []), + work_item_id=pipeline_result.get("work_item_id"), + ) + pipeline_result = await self._apply_pipeline_post_actions( + trace_id=trace_id, + tenant_id=tenant_id, + cleaned_message=cleaned_message, + proposer_name=proposer_name, + captured_at=captured_dt, + pipeline_result=pipeline_result, + ) + try: + await self._sync_feishu_recent_cache( + tenant_id=tenant_id, + group_chat_id=group_chat_id, + sender=proposer_name, + sender_open_id=proposer_open_id, + message=cleaned_message, + pipeline_result=pipeline_result, + ) + except Exception as cache_exc: + self._emit_ai_think( + "capture_recent_cache_sync_failed", + trace_id=trace_id, + error=str(cache_exc), + ) + try: + asyncio.create_task( + AdaptiveRAGService.ingest_feishu_message_async( + tenant_id=tenant_id, + content=cleaned_message, + sender=proposer_name, + chat_id=group_chat_id, + message_id=feishu_message_id, + captured_at=captured_dt, + project_id=( + int(pipeline_result["linked_project_ids"][0]) + if isinstance(pipeline_result.get("linked_project_ids"), list) + and pipeline_result.get("linked_project_ids") + else None + ), + ) + ) + except Exception: + pass + return pipeline_result + except Exception as exc: + self._emit_ai_think("capture_kernel_failed", trace_id=trace_id, error=str(exc)) + try: + await self.db.rollback() + except Exception as rollback_exc: + self._emit_ai_think( + "capture_kernel_failed_rollback_failed", + trace_id=trace_id, + error=str(rollback_exc), + ) + return { + "status": "error", + "reason": "kernel_failed", + "classification": "error", + "project_scope": "error", + "linked_project_ids": [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": trace_id, + "direct_reply_required": True, + "direct_reply_text": "系统处理中出现异常,本次未执行入库。请稍后重试,或补充更明确的目标/对象/动作。", + } + + return await self._capture_from_feishu_legacy_flow( + trace_id=trace_id, + group_chat_id=group_chat_id, + message_content=message_content, + cleaned_message=cleaned_message, + proposer_name=proposer_name, + proposer_open_id=proposer_open_id, + mentioned_bot_names=mentioned_bot_names, + tenant_id=tenant_id, + captured_dt=captured_dt, + ) + diff --git a/backend/app/services/signal_capture_service/mixin_capture_legacy.py b/backend/app/services/signal_capture_service/mixin_capture_legacy.py new file mode 100644 index 00000000..904b14a6 --- /dev/null +++ b/backend/app/services/signal_capture_service/mixin_capture_legacy.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +from .shared import * + +class SignalCaptureLegacyMixin: + async def _capture_from_feishu_legacy_flow( + self, + *, + trace_id: str, + group_chat_id: str, + message_content: str, + cleaned_message: str, + proposer_name: str, + proposer_open_id: Optional[str], + mentioned_bot_names: Optional[List[str]], + tenant_id: int, + captured_dt: datetime, + ) -> Dict[str, Any]: + projects_context = await self._load_active_projects_context( + tenant_id=tenant_id, + ) + classification = await self._analyze_chat_message( + message=cleaned_message, + projects_context=projects_context, + tenant_id=tenant_id, + ) + self._emit_ai_think( + "classification_done", + trace_id=trace_id, + classification=classification.get("classification"), + confidence=classification.get("confidence"), + matched_project_ids=classification.get("matched_project_ids", []), + reason=classification.get("reason"), + ) + if not classification.get("is_requirement", False): + self._emit_ai_think( + "capture_skip_not_requirement", + trace_id=trace_id, + reason=classification.get("reason", "non_requirement"), + ) + return {"status": "ignored", "reason": classification.get("reason", "non_requirement")} + + project_scope = str(classification.get("classification") or "pending_classification") + if project_scope == "non_requirement": + self._emit_ai_think( + "capture_skip_non_requirement", + trace_id=trace_id, + reason=classification.get("reason", "non_requirement"), + ) + return {"status": "ignored", "reason": classification.get("reason", "non_requirement")} + + await ensure_signal_id_sequence(self.db) + strategic_payload = { + "captured_at": captured_dt.isoformat(), + "source_context": { + "group_id": group_chat_id, + "sender": proposer_name, + "sender_open_id": proposer_open_id, + "mentioned_bot_names": [ + self._normalize_single_line_text(name, max_len=80) + for name in (mentioned_bot_names or []) + if self._normalize_single_line_text(name, max_len=80) + ], + }, + "original_message": (message_content or "")[:500], + "cleaned_message": cleaned_message[:500], + "ai_analysis": { + "requirement_project_scope": project_scope, + "reason": classification.get("reason", ""), + "confidence": classification.get("confidence", 0.0), + "matched_project_ids": classification.get("matched_project_ids", []), + "matched_project_titles": classification.get("matched_project_titles", []), + "summary": classification.get("summary", ""), + }, + } + + signal = Signal( + source="Feishu", + content=f"[{proposer_name}]: {self._normalize_single_line_text(cleaned_message, max_len=200)}", + impact_score=float(classification.get("impact_score") or 0.7), + priority=(classification.get("priority") or "medium"), + processed=False, + strategic_analysis=json.dumps(strategic_payload, ensure_ascii=False), + tasks_generated=json.dumps([], ensure_ascii=False), + tenant_id=tenant_id, + ) + self.db.add(signal) + await self.db.commit() + await self.db.refresh(signal) + self._emit_ai_think("signal_created", trace_id=trace_id, signal_id=signal.id) + + linked_project_ids: List[int] = [] + try: + if project_scope == "new_project": + new_project_id = await self._auto_create_project_from_new_signal( + signal_id=int(signal.id), + message=cleaned_message, + sender=proposer_name, + tenant_id=tenant_id, + requested_at=captured_dt, + ) + linked_project_ids = [new_project_id] if new_project_id else [] + else: + linked_project_ids = await self._auto_link_to_projects( + signal.id, + classification, + tenant_id, + message_content=cleaned_message, + ) + self._emit_ai_think( + "project_link_done", + trace_id=trace_id, + signal_id=signal.id, + linked_project_ids=linked_project_ids, + ) + except Exception as exc: + self._emit_ai_think("project_link_failed", trace_id=trace_id, error=str(exc)) + + auto_tasks: List[Dict[str, Any]] = [] + ai_execution: Dict[str, Any] = { + "trace_id": trace_id, + "status": "skipped", + "reason": "no_linked_project", + "project_id": None, + "summary": "", + "tasks_created": 0, + } + if linked_project_ids: + primary_project_id = int(linked_project_ids[0]) + try: + auto_tasks, ai_execution = await self._auto_orchestrate_project_tasks( + trace_id=trace_id, + tenant_id=tenant_id, + project_id=primary_project_id, + signal_id=int(signal.id), + cleaned_message=cleaned_message, + classification=classification, + proposer_name=proposer_name, + captured_at=captured_dt, + ) + except Exception as exc: + self._emit_ai_think( + "task_orchestrate_failed", + trace_id=trace_id, + project_id=primary_project_id, + error=str(exc), + ) + ai_execution = { + "trace_id": trace_id, + "status": "failed", + "reason": str(exc), + "project_id": primary_project_id, + "summary": "", + "tasks_created": 0, + } + + strategic_payload["ai_execution"] = ai_execution + signal.tasks_generated = json.dumps(auto_tasks, ensure_ascii=False) + signal.strategic_analysis = json.dumps(strategic_payload, ensure_ascii=False) + signal.processed = bool(linked_project_ids or auto_tasks) + await self.db.commit() + + target_url = "/ai-steward" + if linked_project_ids: + target_url = f"/projects/{linked_project_ids[0]}/board" + try: + notification_service = NotificationService(self.db) + await notification_service.notify_signal_created( + tenant_id=tenant_id, + signal_id=int(signal.id), + signal_content=cleaned_message, + source="feishu", + target_url=target_url, + proposer=proposer_name, + occurred_at=captured_dt, + ) + except Exception as exc: + self._emit_ai_think("notify_failed", trace_id=trace_id, error=str(exc)) + + self._emit_ai_think( + "capture_done", + trace_id=trace_id, + signal_id=signal.id, + linked_project_ids=linked_project_ids, + auto_tasks=len(auto_tasks), + ) + return { + "signal_id": str(signal.id), + "status": "captured", + "impact_score": signal.impact_score, + "auto_tasks": auto_tasks, + "project_scope": project_scope, + "classification": project_scope, + "linked_project_ids": linked_project_ids, + "created_project_id": linked_project_ids[0] if project_scope == "new_project" and linked_project_ids else None, + "proposer": proposer_name, + "ai_trace_id": trace_id, + } + diff --git a/backend/app/services/signal_capture_service/mixin_external_sources.py b/backend/app/services/signal_capture_service/mixin_external_sources.py new file mode 100644 index 00000000..72b4a067 --- /dev/null +++ b/backend/app/services/signal_capture_service/mixin_external_sources.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +from .shared import * + +class SignalCaptureExternalSourcesMixin: + async def capture_from_browser( + self, + user_id: int, + url: str, + title: str, + content_preview: str, + tenant_id: int = 1 + ) -> Dict[str, Any]: + """ + 从浏览器标签页捕获信号 + + 触发条件: + - 访问技术文档、API 文档 + - 浏览竞品网站、产品页面 + - 查看 GitHub issues/PRs + - 阅读技术博客 + """ + # AI 分析页面价值 + analysis = await self._analyze_webpage( + url, + title, + content_preview, + tenant_id=tenant_id, + ) + + if not analysis['is_valuable']: + return {'status': 'ignored', 'reason': analysis['reason']} + + # 创建信号 + await ensure_signal_id_sequence(self.db) + + signal = Signal( + source='Browser', + content=f"[浏览] {title}: {content_preview[:200]}", + impact_score=analysis['impact_score'], + priority=analysis['priority'], + processed=True, + strategic_analysis=json.dumps({ + 'url': url, + 'captured_at': now_cn_aware().isoformat(), + 'user_id': user_id, + 'ai_analysis': analysis.get('analysis', {}) + }), + tasks_generated=json.dumps([]), + tenant_id=tenant_id + ) + self.db.add(signal) + await self.db.commit() + await self.db.refresh(signal) + + # 自动关联到相关项目 + await self._auto_link_to_projects(signal.id, analysis, tenant_id) + + return { + 'signal_id': str(signal.id), + 'status': 'captured', + 'impact_score': signal.impact_score + } + + async def capture_from_github( + self, + repo_url: str, + event_type: str, + event_data: Dict[str, Any], + tenant_id: int = 1 + ) -> Dict[str, Any]: + """ + 从 GitHub 事件捕获信号 + + 监控事件: + - 竞品仓库更新 + - 上游依赖更新 + - 技术趋势项目发布 + """ + # AI 分析 GitHub 事件 + analysis = await self._analyze_github_event( + repo_url, + event_type, + event_data, + tenant_id=tenant_id, + ) + + if not analysis['is_valuable']: + return {'status': 'ignored', 'reason': analysis['reason']} + + # 创建信号 + await ensure_signal_id_sequence(self.db) + + signal = Signal( + source='GitHub', + content=f"[{event_type}] {repo_url}: {event_data.get('title', event_data.get('message', ''))[:200]}", + impact_score=analysis['impact_score'], + priority=analysis['priority'], + processed=True, + strategic_analysis=json.dumps({ + 'repo_url': repo_url, + 'event_type': event_type, + 'event_data': event_data, + 'captured_at': now_cn_aware().isoformat(), + 'ai_analysis': analysis.get('analysis', {}) + }), + tasks_generated=json.dumps([]), + tenant_id=tenant_id + ) + self.db.add(signal) + await self.db.commit() + await self.db.refresh(signal) + + return { + 'signal_id': str(signal.id), + 'status': 'captured', + 'impact_score': signal.impact_score + } + + async def start_hackernews_monitor( + self, + interval_minutes: int = 30, + tenant_id: int = 1 + ): + """ + 启动 HackerNews 定时监控 + + 定期抓取 HackerNews 首页,分析技术趋势 + """ + while True: + try: + # 抓取 HackerNews 首页 + from .fetcher import fetch_hn_stories + # 兼容三种实现: + # 1) async def -> 直接 await + # 2) sync def -> to_thread 防阻塞 + # 3) sync def 返回 coroutine -> 对返回值再 await + if inspect.iscoroutinefunction(fetch_hn_stories): + stories = await fetch_hn_stories(limit=30) + else: + result = await asyncio.to_thread(fetch_hn_stories, 30) + stories = await result if inspect.isawaitable(result) else result + + for story in stories[:10]: # 只分析前10条 + # AI 分析每条故事 + analysis = await self._analyze_hackernews_story( + story, + tenant_id=tenant_id, + ) + + if analysis.get('is_valuable'): + # 创建信号 + await ensure_signal_id_sequence(self.db) + + signal = Signal( + source='HackerNews', + content=f"HN: {story.get('title', '')[:200]}", + impact_score=analysis['impact_score'], + priority=analysis['priority'], + processed=True, + strategic_analysis=json.dumps({ + 'hn_url': story.get('url'), + 'hn_points': story.get('points'), + 'captured_at': now_cn_aware().isoformat(), + 'ai_analysis': analysis.get('analysis', {}) + }), + tasks_generated=json.dumps(analysis.get('suggested_tasks', [])), + tenant_id=tenant_id + ) + self.db.add(signal) + await self.db.commit() + + self._emit_ai_think( + "hn_story_captured", + title=str(story.get("title") or "")[:50], + ) + + # 等待下次抓取 + await asyncio.sleep(interval_minutes * 60) + + except Exception as e: + self._emit_ai_think("hn_monitor_error", error=str(e)) + await asyncio.sleep(interval_minutes * 60) + + async def _load_active_projects_context(self, tenant_id: int) -> List[Dict[str, Any]]: + result = await self.db.execute( + select(Project).where( + Project.status == "active", + Project.tenant_id == tenant_id, + ) + ) + projects = result.scalars().all() + rows: List[Dict[str, Any]] = [] + for project in projects: + rows.append( + { + "id": int(project.id), + "title": (project.title or project.name or "").strip(), + "description": (project.description or "").strip()[:300], + "status": project.status, + } + ) + return rows + + def _emit_ai_think(self, stage: str, **payload: Any) -> None: + safe_payload: Dict[str, Any] = {} + for key, value in payload.items(): + safe_payload[key] = self._safe_log_value(value) + logger.info("signal_capture_ai_think", stage=stage, **safe_payload) + try: + stage_zh = AI_THINK_STAGE_ZH.get(stage, stage) + now_text = self._format_cn_time(now_cn_aware()) + print( + f"[{now_text}][AI思考][SignalCapture] {stage_zh} " + + json.dumps({"stage": stage, **safe_payload}, ensure_ascii=False) + ) + except Exception: + pass + + @staticmethod + def _safe_log_value(value: Any, max_len: int = 600) -> Any: + if isinstance(value, (str, int, float, bool)) or value is None: + text = str(value) if value is not None else None + if text is None: + return None + return text if len(text) <= max_len else f"{text[:max_len]}...(truncated)" + try: + text = json.dumps(value, ensure_ascii=False, default=str) + except Exception: + text = str(value) + return text if len(text) <= max_len else f"{text[:max_len]}...(truncated)" + diff --git a/backend/app/services/signal_capture_service/mixin_new_project.py b/backend/app/services/signal_capture_service/mixin_new_project.py new file mode 100644 index 00000000..1b567eb0 --- /dev/null +++ b/backend/app/services/signal_capture_service/mixin_new_project.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from .shared import * + +class SignalCaptureNewProjectMixin: + async def _auto_create_project_from_new_signal( + self, + *, + signal_id: int, + message: str, + sender: str, + tenant_id: int, + requested_at: Optional[datetime] = None, + ) -> Optional[int]: + """当分类为 new_project 时,自动创建项目并关联当前信号。""" + requested_dt = requested_at or now_cn_aware() + if requested_dt.tzinfo is None: + requested_dt = requested_dt.replace(tzinfo=CN_TZ) + project_brief = await self._draft_new_project_brief( + message=message, + tenant_id=tenant_id, + sender=sender, + requested_at=requested_dt, + ) + title = self._normalize_single_line_text(project_brief.get("title") or "", max_len=120) + if not title: + title = self._normalize_single_line_text(message or "飞书新需求立项", max_len=40) or "飞书新需求立项" + raw_desc = self._normalize_multiline_text(project_brief.get("description") or message or "", max_len=1200) + requested_at_text = self._format_cn_time(requested_dt) + description = self._normalize_multiline_text( + f"需求提出人:{sender}\n" + f"提出时间:{requested_at_text}\n" + f"需求来源:飞书群聊\n\n" + f"需求说明:{raw_desc or self._normalize_multiline_text(message, max_len=500)}", + max_len=1800, + ) + assigned_role = str(project_brief.get("primary_role") or "ai_engineer").strip()[:50] or "ai_engineer" + + owner_id: Optional[int] = None + owner_result = await self.db.execute( + select(User.id).where( + and_( + User.tenant_id == tenant_id, + User.status == "active", + User.role.in_(["BOSS", "ADMIN"]), + ) + ).order_by(User.id.asc()).limit(1) + ) + owner_id = owner_result.scalar_one_or_none() + + await ensure_project_id_sequence(self.db) + project = Project( + name=title[:80] or "新项目", + title=title, + description=description, + owner_id=owner_id, + status="active", + assigned_role=assigned_role, + signal_count=1, + tenant_id=tenant_id, + ) + self.db.add(project) + await self.db.flush() + await ensure_project_default_members( + self.db, + tenant_id=tenant_id, + project_id=int(project.id), + owner_id=int(project.owner_id) if project.owner_id is not None else None, + assigned_role=project.assigned_role, + ) + + existing = await self.db.execute( + text("SELECT id FROM project_signal_links WHERE project_id = :project_id AND signal_id = :signal_id"), + {"project_id": int(project.id), "signal_id": int(signal_id)}, + ) + if existing.first() is None: + link = ProjectSignalLink( + project_id=int(project.id), + signal_id=int(signal_id), + link_type="ai_new_project_signal", + relevance_score=0.98, + tenant_id=tenant_id, + ) + self.db.add(link) + + await self.db.execute( + text("UPDATE signals SET processed = :processed WHERE id = :signal_id"), + {"processed": True, "signal_id": int(signal_id)}, + ) + await self.db.commit() + + self._emit_ai_think( + "new_project_created_from_signal", + signal_id=signal_id, + project_id=int(project.id), + sender=sender, + ) + return int(project.id) + + async def _draft_new_project_brief( + self, + *, + message: str, + tenant_id: int, + sender: str, + requested_at: datetime, + ) -> Dict[str, Any]: + requested_at_text = self._format_cn_time(requested_at) + prompt = f"""你是 PMFlow 的立项助手。基于下面需求消息,提炼新项目基础信息。 + +需求提出人:{sender} +提出时间:{requested_at_text} + +需求消息: +{message[:1000]} + +输出 JSON: +{{ + "title": "项目标题(15-30字)", + "description": "项目简介(80-200字)", + "primary_role": "ai_engineer|backend|frontend|devops" +}} + +只输出 JSON,不要 markdown,不要解释,不要乱码。""" + try: + raw = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + function_name="feishu_new_project_brief", + ) + parsed = self._parse_llm_json_response(raw) + role = str(parsed.get("primary_role") or "ai_engineer").strip().lower() + if role not in {"ai_engineer", "backend", "frontend", "devops"}: + role = "ai_engineer" + return { + "title": self._normalize_single_line_text(str(parsed.get("title") or ""), max_len=120), + "description": self._normalize_multiline_text(str(parsed.get("description") or ""), max_len=1200), + "primary_role": role, + } + except Exception as exc: + self._emit_ai_think("draft_new_project_brief_failed", error=str(exc)) + return { + "title": self._normalize_single_line_text(message or "飞书新需求立项", max_len=40), + "description": self._normalize_multiline_text(message, max_len=300), + "primary_role": "ai_engineer", + } + diff --git a/backend/app/services/signal_capture_service/mixin_pipeline_cache.py b/backend/app/services/signal_capture_service/mixin_pipeline_cache.py new file mode 100644 index 00000000..1570cbff --- /dev/null +++ b/backend/app/services/signal_capture_service/mixin_pipeline_cache.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +from .shared import * + +class SignalCapturePipelineCacheMixin: + async def _sync_feishu_recent_cache( + self, + *, + tenant_id: int, + group_chat_id: str, + sender: str, + sender_open_id: Optional[str], + message: str, + pipeline_result: Dict[str, Any], + ) -> None: + if not isinstance(pipeline_result, dict): + return + + conversation_key = _build_feishu_rag_conversation_key( + source_chat_id=group_chat_id, + source_sender_open_id=sender_open_id, + sender=sender, + ) + external_session_id = AdaptiveRAGService._external_session_id_from_key(conversation_key) + if external_session_id is None: + return + + answer_text = str(pipeline_result.get("direct_reply_text") or "").strip() + if not answer_text: + answer_text = self._build_recent_cache_ack_text(pipeline_result) + if not answer_text: + return + + recent_messages = await context_cache_service.get_session_messages( + tenant_id=int(tenant_id), + session_id=int(external_session_id), + ) + recent_turns = context_cache_service.messages_to_turns(recent_messages) + if recent_turns: + latest_turn = recent_turns[-1] + latest_question = str(latest_turn.get("question") or "").strip() + latest_answer = str(latest_turn.get("answer") or "").strip() + if latest_question == str(message or "").strip() and latest_answer == answer_text: + return + + await context_cache_service.append_session_messages( + tenant_id=int(tenant_id), + session_id=int(external_session_id), + question=str(message or "")[:3000], + answer=answer_text[:4000], + ) + + @staticmethod + def _build_recent_cache_ack_text(pipeline_result: Dict[str, Any]) -> str: + status = str(pipeline_result.get("status") or "").strip().lower() + if status == "captured": + return "已收到并记录该需求,已进入信号处理队列。" + if status == "ignored": + return "已收到该消息,本次未进入需求处理队列。" + if status == "error": + return str(pipeline_result.get("direct_reply_text") or "系统处理中出现异常,请稍后重试。").strip() + return "已收到该消息。" + + async def _apply_pipeline_post_actions( + self, + *, + trace_id: str, + tenant_id: int, + cleaned_message: str, + proposer_name: str, + captured_at: datetime, + pipeline_result: Dict[str, Any], + ) -> Dict[str, Any]: + if not isinstance(pipeline_result, dict): + return pipeline_result + if str(pipeline_result.get("status") or "").lower() != "captured": + return pipeline_result + + existing_tasks = pipeline_result.get("auto_tasks") + if isinstance(existing_tasks, list) and existing_tasks: + return pipeline_result + + linked_project_ids = pipeline_result.get("linked_project_ids") + if not isinstance(linked_project_ids, list) or not linked_project_ids: + return pipeline_result + + signal_id = self._to_int(pipeline_result.get("signal_id")) + project_id = self._to_int(linked_project_ids[0]) + if signal_id is None or project_id is None: + return pipeline_result + + signal = ( + await self.db.execute( + select(Signal).where( + and_( + Signal.id == signal_id, + Signal.tenant_id == tenant_id, + ) + ) + ) + ).scalar_one_or_none() + if signal is None: + return pipeline_result + + if self._is_question_like(cleaned_message): + return pipeline_result + + source_orchestration_result: Optional[Dict[str, Any]] = None + if SignalSourceOrchestrationService.looks_like_signal_source_request(cleaned_message): + try: + source_orchestration_result = await SignalSourceOrchestrationService(self.db).orchestrate_from_signal( + tenant_id=tenant_id, + project_id=project_id, + signal_id=signal_id, + initiating_text=cleaned_message, + requested_via="feishu", + operator_user_id=None, + dry_run=False, + ) + except Exception as exc: + self._emit_ai_think( + "pipeline_post_source_onboard_failed", + trace_id=trace_id, + signal_id=signal_id, + project_id=project_id, + error=str(exc), + ) + else: + orchestration_status = str(source_orchestration_result.get("status") or "").lower() + if orchestration_status in {"completed", "partial", "planned", "pending_manual"}: + auto_tasks = self._build_openclaw_auto_tasks( + source_orchestration_result.get("execution") + if isinstance(source_orchestration_result.get("execution"), dict) + else source_orchestration_result.get("openclaw"), + ) + try: + strategic_payload: Dict[str, Any] = {} + raw_strategic = signal.strategic_analysis + if isinstance(raw_strategic, dict): + strategic_payload = dict(raw_strategic) + elif isinstance(raw_strategic, str) and raw_strategic.strip(): + parsed = json.loads(raw_strategic) + if isinstance(parsed, dict): + strategic_payload = parsed + strategic_payload["signal_source_orchestration"] = source_orchestration_result + strategic_payload["ai_execution"] = source_orchestration_result + signal.strategic_analysis = json.dumps(strategic_payload, ensure_ascii=False) + signal.tasks_generated = json.dumps(auto_tasks, ensure_ascii=False) + signal.processed = orchestration_status in {"completed", "partial"} + await self.db.commit() + except Exception as exc: + self._emit_ai_think( + "pipeline_post_update_signal_failed", + trace_id=trace_id, + signal_id=signal_id, + project_id=project_id, + error=str(exc), + ) + pipeline_result["auto_tasks"] = auto_tasks + pipeline_result["ai_execution"] = source_orchestration_result + pipeline_result["signal_source_orchestration"] = source_orchestration_result + return pipeline_result + + signal_type = str(signal.signal_type or "").strip() + recommended_action = str(signal.recommended_action or "").strip() + should_orchestrate = ( + recommended_action in {"create_charter", "trigger_alert"} + or signal_type == "新需求" + ) + if not should_orchestrate: + return pipeline_result + + classification_payload: Dict[str, Any] = { + "classification": str(pipeline_result.get("classification") or "signal"), + "impact_score": float(signal.impact_score or 0.0), + "priority": str(signal.priority or "medium"), + "confidence": float(signal.confidence or 0.0), + "reason": str(signal.summary or "")[:300], + "matched_project_ids": [project_id], + "matched_project_titles": [], + } + + try: + auto_tasks, ai_execution = await self._auto_orchestrate_project_tasks( + trace_id=f"{trace_id}-pipeline", + tenant_id=tenant_id, + project_id=project_id, + signal_id=signal_id, + cleaned_message=cleaned_message, + classification=classification_payload, + proposer_name=proposer_name, + captured_at=captured_at, + ) + except Exception as exc: + self._emit_ai_think( + "pipeline_post_orchestrate_failed", + trace_id=trace_id, + signal_id=signal_id, + project_id=project_id, + error=str(exc), + ) + return pipeline_result + + try: + strategic_payload: Dict[str, Any] = {} + raw_strategic = signal.strategic_analysis + if isinstance(raw_strategic, dict): + strategic_payload = dict(raw_strategic) + elif isinstance(raw_strategic, str) and raw_strategic.strip(): + parsed = json.loads(raw_strategic) + if isinstance(parsed, dict): + strategic_payload = parsed + strategic_payload["ai_execution"] = ai_execution + signal.strategic_analysis = json.dumps(strategic_payload, ensure_ascii=False) + signal.tasks_generated = json.dumps(auto_tasks, ensure_ascii=False) + signal.processed = bool(signal.processed or auto_tasks) + await self.db.commit() + except Exception as exc: + self._emit_ai_think( + "pipeline_post_update_signal_failed", + trace_id=trace_id, + signal_id=signal_id, + project_id=project_id, + error=str(exc), + ) + + pipeline_result["auto_tasks"] = auto_tasks + pipeline_result["ai_execution"] = ai_execution + return pipeline_result + + @staticmethod + def _build_openclaw_auto_tasks(openclaw_payload: Any) -> List[Dict[str, Any]]: + if not isinstance(openclaw_payload, dict): + return [] + result = openclaw_payload.get("result") if isinstance(openclaw_payload.get("result"), dict) else openclaw_payload + execution = result.get("execution") if isinstance(result, dict) and isinstance(result.get("execution"), dict) else {} + rows = execution.get("results") if isinstance(execution.get("results"), list) else [] + items: List[Dict[str, Any]] = [] + for row in rows: + if not isinstance(row, dict): + continue + operation_code = str(row.get("operation_code") or "").strip() + payload = row.get("payload") if isinstance(row.get("payload"), dict) else {} + status = str(row.get("status") or "").strip() + if operation_code not in {"task.create", "task.assign", "task.prioritize", "task.status_transition"}: + continue + if status not in {"applied", "planned", "skipped"}: + continue + items.append( + { + "operation_code": operation_code, + "status": status, + "entity_id": row.get("entity_id"), + "task_id": payload.get("task_id"), + "title": payload.get("title"), + "summary": str(row.get("message") or "").strip(), + } + ) + return items + diff --git a/backend/app/services/signal_capture_service/mixin_source_analyzers.py b/backend/app/services/signal_capture_service/mixin_source_analyzers.py new file mode 100644 index 00000000..6d666f44 --- /dev/null +++ b/backend/app/services/signal_capture_service/mixin_source_analyzers.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +from .shared import * + +class SignalCaptureSourceAnalyzersMixin: + async def _analyze_webpage(self, url: str, title: str, content: str, tenant_id: int) -> Dict[str, Any]: + """AI 分析网页价值""" + prompt = f"""分析以下网页内容是否有价值: + +标题:{title} +URL: {url} +内容摘要:{content[:500]} + +请判断: +1. 是否是技术文档、竞品信息、行业趋势? +2. 是否值得团队关注? +3. 影响程度(0-1)? + +以 JSON 格式返回: +{{ + "is_valuable": boolean, + "reason": "原因", + "impact_score": 0.8, + "priority": "high/medium/low", + "analysis": {{}}, + "suggested_tasks": [{{"task_name": "任务", "type": "backend", "estimated_hours": 8}}] +}} + +如果不是有价值内容,is_valuable 设为 false。""" + + try: + response = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + ) + response = response.strip() + if response.startswith('```'): + response = response.split('```')[1] + if response.startswith('json'): + response = response[4:] + return json.loads(response.strip()) + except: + return {'is_valuable': False, 'reason': 'AI 分析失败'} + + async def _analyze_github_event(self, repo_url: str, event_type: str, event_data: Dict, tenant_id: int) -> Dict[str, Any]: + """AI 分析 GitHub 事件""" + description = f"{event_type}: {event_data.get('title', event_data.get('message', ''))}" + + prompt = f"""分析以下 GitHub 事件是否有价值: + +仓库:{repo_url} +事件:{description} + +请判断: +1. 是否是技术栈更新、安全修复、重要功能? +2. 是否影响我们的项目? +3. 影响程度(0-1)? + +以 JSON 格式返回: +{{ + "is_valuable": boolean, + "reason": "原因", + "impact_score": 0.8, + "priority": "high/medium/low", + "analysis": {{}}, + "suggested_tasks": [{{"task_name": "任务", "type": "backend", "estimated_hours": 8}}] +}} + +如果不是有价值内容,is_valuable 设为 false。""" + + try: + response = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + ) + response = response.strip() + if response.startswith('```'): + response = response.split('```')[1] + if response.startswith('json'): + response = response[4:] + return json.loads(response.strip()) + except: + return {'is_valuable': False, 'reason': 'AI 分析失败'} + + async def _analyze_hackernews_story(self, story: Dict, tenant_id: int) -> Dict[str, Any]: + """AI 分析 HackerNews 故事""" + prompt = f"""分析以下 HackerNews 故事是否是重要技术趋势: + +标题:{story.get('title', '')} +链接:{story.get('url', '')} +得分:{story.get('points', 0)} + +请判断: +1. 是否是行业趋势、技术突破? +2. 是否值得我们关注? +3. 影响程度(0-1)? + +以 JSON 格式返回: +{{ + "is_strategic": boolean, + "impact_score": 0.8, + "priority": "high/medium/low", + "analysis": {{}}, + "suggested_tasks": [{{"task_name": "任务", "type": "backend", "estimated_hours": 8}}] +}} + +如果不是战略性内容,is_strategic 设为 false。""" + + try: + response = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + ) + response = response.strip() + if response.startswith('```'): + response = response.split('```')[1] + if response.startswith('json'): + response = response[4:] + result = json.loads(response.strip()) + # 重命名字段以匹配其他方法 + return { + 'is_valuable': result.get('is_strategic', False), + 'reason': result.get('reason', ''), + 'impact_score': result.get('impact_score', 0.5), + 'priority': result.get('priority', 'low'), + 'analysis': result.get('analysis', {}), + 'suggested_tasks': result.get('suggested_tasks', []) + } + except: + return {'is_valuable': False, 'reason': 'AI 分析失败'} + + async def _auto_link_to_projects( + self, + signal_id: int, + analysis: Dict[str, Any], + tenant_id: int, + message_content: Optional[str] = None, + ) -> List[int]: + """自动关联信号到相关项目""" + _ = message_content + classification = str(analysis.get("classification") or "").lower() + if classification in {"new_project", "pending_classification", "non_requirement"}: + return [] + + if classification == "existing_project": + matched_ids: List[int] = [] + for pid in analysis.get("matched_project_ids", []) or []: + try: + matched_ids.append(int(pid)) + except Exception: + continue + matched_ids = sorted(set(matched_ids)) + if not matched_ids: + return [] + + result = await self.db.execute( + select(Project.id).where( + and_( + Project.id.in_(matched_ids), + Project.status == "active", + Project.tenant_id == tenant_id, + ) + ) + ) + valid_ids = {int(v) for v in result.scalars().all()} + if not valid_ids: + return [] + confidence = float(analysis.get("confidence") or 0.0) + relevance = max(0.5, min(0.98, 0.55 + confidence * 0.4)) + link_type = "ai_existing_project_signal" + else: + result = await self.db.execute( + select(Project.id).where( + and_(Project.status == "active", Project.tenant_id == tenant_id) + ) + ) + valid_ids = {int(v) for v in result.scalars().all()} + if not valid_ids: + return [] + relevance = 0.7 + link_type = "auto_captured" + + linked_project_ids: List[int] = [] + + for project_id in sorted(valid_ids): + existing = await self.db.execute( + text("SELECT id FROM project_signal_links WHERE project_id = :project_id AND signal_id = :signal_id"), + {"project_id": project_id, "signal_id": signal_id}, + ) + if existing.first() is None: + from ..models import ProjectSignalLink + link = ProjectSignalLink( + project_id=project_id, + signal_id=signal_id, + link_type=link_type, + relevance_score=relevance, + tenant_id=tenant_id, + ) + self.db.add(link) + linked_project_ids.append(project_id) + + await self.db.commit() + return linked_project_ids + diff --git a/backend/app/services/signal_capture_service/mixin_task_orchestration_main.py b/backend/app/services/signal_capture_service/mixin_task_orchestration_main.py new file mode 100644 index 00000000..1bc00669 --- /dev/null +++ b/backend/app/services/signal_capture_service/mixin_task_orchestration_main.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +from .shared import * + +class SignalCaptureTaskOrchestrationMainMixin: + async def _auto_orchestrate_project_tasks( + self, + *, + trace_id: str, + tenant_id: int, + project_id: int, + signal_id: int, + cleaned_message: str, + classification: Dict[str, Any], + proposer_name: str, + captured_at: datetime, + ) -> tuple[List[Dict[str, Any]], Dict[str, Any]]: + project = ( + await self.db.execute( + select(Project).where( + and_( + Project.id == project_id, + Project.tenant_id == tenant_id, + ) + ) + ) + ).scalar_one_or_none() + if not project: + return [], { + "trace_id": trace_id, + "status": "skipped", + "reason": "project_not_found", + "project_id": project_id, + "summary": "", + "tasks_created": 0, + } + + charter_id = await self._ensure_project_charter_node( + tenant_id=tenant_id, + project_id=project_id, + proposer_name=proposer_name, + cleaned_message=cleaned_message, + captured_at=captured_at, + ) + + team_members = await self._load_project_team_context( + tenant_id=tenant_id, + project_id=project_id, + owner_id=int(project.owner_id) if project.owner_id else None, + ) + if not team_members: + return [], { + "trace_id": trace_id, + "status": "skipped", + "reason": "no_active_members", + "project_id": project_id, + "summary": "", + "tasks_created": 0, + } + + project_payload = { + "id": int(project.id), + "title": (project.title or project.name or "").strip(), + "description": (project.description or "").strip()[:600], + "assigned_role": (project.assigned_role or "").strip(), + } + signal_payload = { + "signal_id": signal_id, + "message": cleaned_message[:1200], + "classification": classification, + "proposer": proposer_name, + "captured_at": captured_at.isoformat(), + } + prompt = PROMPT_FEISHU_TASK_ORCHESTRATION.format( + project_json=json.dumps(project_payload, ensure_ascii=False), + signal_json=json.dumps(signal_payload, ensure_ascii=False), + members_json=json.dumps(team_members, ensure_ascii=False), + ) + + llm_status = "success" + llm_error = "" + parsed: Dict[str, Any] = {} + try: + llm_raw = await self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + function_name="feishu_task_orchestration", + ) + parsed = self._parse_llm_json_response(llm_raw) + self._emit_ai_think( + "task_plan_llm_done", + trace_id=trace_id, + project_id=project_id, + llm_summary=parsed.get("overall_summary", ""), + ) + except Exception as exc: + llm_status = "failed" + llm_error = str(exc) + self._emit_ai_think( + "task_plan_llm_failed", + trace_id=trace_id, + project_id=project_id, + error=llm_error, + ) + + tasks_payload = parsed.get("tasks") if isinstance(parsed.get("tasks"), list) else [] + if not tasks_payload: + tasks_payload = self._build_task_orchestration_fallback( + cleaned_message=cleaned_message, + classification=classification, + ) + + created_items: List[Dict[str, Any]] = [] + touched_user_ids: set[int] = set() + created_count = 0 + for idx, raw_task in enumerate(tasks_payload[:8]): + if not isinstance(raw_task, dict): + continue + title = self._normalize_single_line_text( + str(raw_task.get("title") or raw_task.get("task_name") or ""), + max_len=64, + ) + if not title: + continue + description = self._normalize_multiline_text( + str(raw_task.get("description") or cleaned_message), + max_len=800, + ) + priority = self._normalize_priority(raw_task.get("priority")) + estimated_hours = self._normalize_estimated_hours(raw_task.get("estimated_hours")) + required_skill_names = self._normalize_skill_name_list(raw_task.get("required_skill_names")) + required_skill_tag_ids = await self._resolve_skill_tag_ids( + tenant_id=tenant_id, + skill_names=required_skill_names, + ) + ai_recommended_assignee_id = self._to_int(raw_task.get("recommended_assignee_id")) + assignee_id, decision = self._pick_task_assignee( + team_members=team_members, + required_skill_tag_ids=required_skill_tag_ids, + ai_recommended_assignee_id=ai_recommended_assignee_id, + ) + + task = Task( + tenant_id=tenant_id, + truth_node_id=charter_id, + project_id=project_id, + assignee_id=assignee_id, + title=title, + description=description, + required_skill_tag_ids=required_skill_tag_ids, + status="todo", + priority=priority, + estimated_hours=estimated_hours, + ai_recommended_assignees={ + "trace_id": trace_id, + "ai_recommended_assignee_id": ai_recommended_assignee_id, + "final_assignee_id": assignee_id, + "assignee_reason": self._normalize_single_line_text( + str(raw_task.get("assignee_reason") or ""), + max_len=300, + ), + "required_skill_names": required_skill_names, + "decision": decision, + "confidence": float(raw_task.get("confidence") or 0.0), + }, + ) + self.db.add(task) + await self.db.flush() + created_count += 1 + + assignee_name = "" + if assignee_id: + touched_user_ids.add(int(assignee_id)) + match_member = next((m for m in team_members if int(m.get("user_id", 0)) == int(assignee_id)), None) + if isinstance(match_member, dict): + assignee_name = str(match_member.get("name") or "") + + item = { + "task_id": int(task.id), + "task_name": title, + "priority": priority, + "estimated_hours": float(task.estimated_hours or 0), + "required_skill_names": required_skill_names, + "required_skill_tag_ids": required_skill_tag_ids, + "assignee_id": int(assignee_id) if assignee_id else None, + "assignee_name": assignee_name, + "reason": decision.get("picked_reason", ""), + } + created_items.append(item) + self._emit_ai_think( + "task_created", + trace_id=trace_id, + project_id=project_id, + rank=idx + 1, + task_id=task.id, + task_name=title, + assignee_id=item["assignee_id"], + decision=item["reason"], + ) + + await self.db.commit() + for uid in sorted(touched_user_ids): + await workload_service.refresh_user_assigned_hours_from_tasks( + self.db, + tenant_id=tenant_id, + user_id=uid, + ) + + summary = self._normalize_single_line_text( + str(parsed.get("overall_summary") or ""), + max_len=200, + ) + if not summary: + summary = "AI 自动拆解并分配飞书需求任务" + + execution_meta = { + "trace_id": trace_id, + "status": "success" if created_count > 0 else "skipped", + "reason": "ok" if created_count > 0 else "no_valid_tasks", + "project_id": project_id, + "charter_id": charter_id, + "summary": summary, + "tasks_created": created_count, + "llm_status": llm_status, + "llm_error": llm_error[:300] if llm_error else "", + } + return created_items, execution_meta + diff --git a/backend/app/services/signal_capture_service/mixin_task_orchestration_skill.py b/backend/app/services/signal_capture_service/mixin_task_orchestration_skill.py new file mode 100644 index 00000000..9a87e1a9 --- /dev/null +++ b/backend/app/services/signal_capture_service/mixin_task_orchestration_skill.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from .shared import * + +class SignalCaptureTaskOrchestrationSkillMixin: + async def _resolve_skill_tag_ids( + self, + *, + tenant_id: int, + skill_names: List[str], + ) -> List[int]: + if not skill_names: + return [] + result = await self.db.execute( + select(SkillTag).where(SkillTag.tenant_id == tenant_id) + ) + tags = result.scalars().all() + if not tags: + return [] + + exact: Dict[str, int] = {} + fuzzy: List[tuple[str, int]] = [] + for tag in tags: + name = str(tag.name or "").strip().lower() + slug = str(tag.slug or "").strip().lower() + if name: + exact[name] = int(tag.id) + fuzzy.append((name, int(tag.id))) + if slug: + exact[slug] = int(tag.id) + fuzzy.append((slug, int(tag.id))) + + resolved_ids: List[int] = [] + for raw in skill_names: + key = str(raw or "").strip().lower() + if not key: + continue + if key in exact: + resolved_ids.append(exact[key]) + continue + matched = None + for candidate, sid in fuzzy: + if key in candidate or candidate in key: + matched = sid + break + if matched: + resolved_ids.append(int(matched)) + return sorted(set(resolved_ids)) + + def _pick_task_assignee( + self, + *, + team_members: List[Dict[str, Any]], + required_skill_tag_ids: List[int], + ai_recommended_assignee_id: Optional[int], + ) -> tuple[Optional[int], Dict[str, Any]]: + if not team_members: + return None, {"picked_reason": "no_team_members", "candidate_scores": []} + + candidates: List[Dict[str, Any]] = [] + for member in team_members: + uid = self._to_int(member.get("user_id")) + if not uid: + continue + skill_ids = {int(v) for v in (member.get("skill_tag_ids") or []) if self._to_int(v)} + workload_ratio = float(member.get("workload_ratio") or 0.0) + if required_skill_tag_ids: + matched = sum(1 for sid in required_skill_tag_ids if sid in skill_ids) + skill_score = matched / len(required_skill_tag_ids) + else: + skill_score = 0.4 + workload_score = max(0.0, 140.0 - workload_ratio) / 140.0 + ai_bonus = 0.15 if ai_recommended_assignee_id and uid == int(ai_recommended_assignee_id) else 0.0 + overload_penalty = 0.2 if workload_ratio >= 150 else 0.0 + total_score = (skill_score * 0.7) + (workload_score * 0.3) + ai_bonus - overload_penalty + candidates.append( + { + "user_id": uid, + "name": member.get("name"), + "skill_score": round(skill_score, 4), + "workload_ratio": round(workload_ratio, 2), + "total_score": round(total_score, 4), + } + ) + + candidates.sort(key=lambda x: (-x["total_score"], x["workload_ratio"], x["user_id"])) + if not candidates: + return None, {"picked_reason": "no_valid_candidate", "candidate_scores": []} + + top = candidates[0] + if float(top.get("workload_ratio") or 0.0) > 160 and float(top.get("skill_score") or 0.0) < 0.5: + return None, { + "picked_reason": "all_candidates_overloaded", + "candidate_scores": candidates[:3], + } + picked_uid = int(top["user_id"]) + return picked_uid, { + "picked_reason": ( + f"score={top['total_score']},skill={top['skill_score']}," + f"workload={top['workload_ratio']}" + ), + "candidate_scores": candidates[:3], + } + + def _build_task_orchestration_fallback( + self, + *, + cleaned_message: str, + classification: Dict[str, Any], + ) -> List[Dict[str, Any]]: + summary = self._normalize_single_line_text( + str(classification.get("summary") or cleaned_message), + max_len=120, + ) + return [ + { + "title": "需求澄清与范围确认", + "description": f"基于飞书信号补全需求边界、验收口径与优先级。上下文:{summary}", + "priority": "high", + "estimated_hours": 4, + "required_skill_names": ["产品管理", "项目管理"], + "recommended_assignee_id": None, + "assignee_reason": "fallback_without_llm", + "confidence": 0.0, + }, + { + "title": "核心方案落地", + "description": f"围绕需求信号推进可交付实现,拆分后端/前端/AI 子任务并排期。上下文:{summary}", + "priority": "medium", + "estimated_hours": 12, + "required_skill_names": ["FastAPI", "React", "LangChain"], + "recommended_assignee_id": None, + "assignee_reason": "fallback_without_llm", + "confidence": 0.0, + }, + ] + + @staticmethod + def _normalize_priority(value: Any) -> str: + priority = str(value or "medium").strip().lower() + if priority in {"p0", "critical", "high"}: + return "high" + if priority in {"p2", "low"}: + return "low" + return "medium" + + @staticmethod + def _normalize_estimated_hours(value: Any) -> Optional[float]: + try: + hours = float(value) + except Exception: + return 8.0 + if hours <= 0: + return 1.0 + return round(min(hours, 80.0), 2) + + def _normalize_skill_name_list(self, value: Any) -> List[str]: + if isinstance(value, list): + names = [self._normalize_single_line_text(str(item or ""), max_len=60) for item in value] + return [name for name in names if name] + if isinstance(value, str): + split = re.split(r"[,,/|]", value) + names = [self._normalize_single_line_text(part, max_len=60) for part in split] + return [name for name in names if name] + return [] + + @staticmethod + def _to_int(value: Any) -> Optional[int]: + try: + if value is None or value == "": + return None + return int(value) + except Exception: + return None + diff --git a/backend/app/services/signal_capture_service/mixin_task_orchestration_team.py b/backend/app/services/signal_capture_service/mixin_task_orchestration_team.py new file mode 100644 index 00000000..27571bda --- /dev/null +++ b/backend/app/services/signal_capture_service/mixin_task_orchestration_team.py @@ -0,0 +1,280 @@ +from __future__ import annotations + +from .shared import * + +class SignalCaptureTaskOrchestrationTeamMixin: + async def _ensure_project_charter_node( + self, + *, + tenant_id: int, + project_id: int, + proposer_name: str, + cleaned_message: str, + captured_at: datetime, + ) -> int: + existing = ( + await self.db.execute( + select(TruthNode).where( + and_( + TruthNode.tenant_id == tenant_id, + TruthNode.project_id == project_id, + TruthNode.node_type == "charter", + ) + ).order_by(TruthNode.updated_at.desc(), TruthNode.id.desc()) + ) + ).scalars().first() + if existing: + return int(existing.id) + + project = ( + await self.db.execute( + select(Project).where( + and_( + Project.id == project_id, + Project.tenant_id == tenant_id, + ) + ) + ) + ).scalar_one_or_none() + if not project: + raise ValueError("project_not_found_for_charter") + + creator_id = await self._pick_truth_node_creator( + tenant_id=tenant_id, + project_id=project_id, + owner_id=int(project.owner_id) if project.owner_id else None, + ) + node = TruthNode( + tenant_id=tenant_id, + node_type="charter", + project_id=project_id, + created_by=creator_id, + title=self._normalize_single_line_text( + f"{project.title or project.name or '项目'} 自动宪章", + max_len=120, + ), + content={ + "rationale": "由飞书信号自动触发的初版执行宪章", + "source": "feishu_auto_orchestration", + "proposer": proposer_name, + "captured_at": captured_at.isoformat(), + "seed_message": cleaned_message[:500], + "success_criteria": ["建立可执行任务链", "明确负责人", "控制团队负载"], + "constraints": ["后续可由管理员更新细化"], + }, + version=1, + status="active", + ) + self.db.add(node) + await self.db.flush() + self._emit_ai_think( + "charter_auto_created", + project_id=project_id, + charter_id=node.id, + created_by=creator_id, + ) + return int(node.id) + + async def _pick_truth_node_creator( + self, + *, + tenant_id: int, + project_id: int, + owner_id: Optional[int], + ) -> int: + if owner_id: + owner = ( + await self.db.execute( + select(User.id).where( + and_( + User.tenant_id == tenant_id, + User.id == owner_id, + User.status == "active", + ) + ).limit(1) + ) + ).scalars().first() + if owner: + return int(owner) + + project_member = ( + await self.db.execute( + select(UserProject.user_id) + .join( + User, + and_( + User.id == UserProject.user_id, + User.tenant_id == tenant_id, + User.status == "active", + User.is_active.is_(True), + ), + ) + .where( + and_( + UserProject.tenant_id == tenant_id, + UserProject.project_id == project_id, + ) + ) + .order_by(UserProject.user_id.asc()) + .limit(1) + ) + ).scalars().first() + if project_member: + return int(project_member) + + admin_user = ( + await self.db.execute( + select(User.id).where( + and_( + User.tenant_id == tenant_id, + User.status == "active", + User.is_active.is_(True), + User.role.in_(["BOSS", "ADMIN"]), + ) + ).order_by(User.id.asc()).limit(1) + ) + ).scalars().first() + if admin_user: + return int(admin_user) + + any_active = ( + await self.db.execute( + select(User.id).where( + and_( + User.tenant_id == tenant_id, + User.status == "active", + User.is_active.is_(True), + ) + ).order_by(User.id.asc()).limit(1) + ) + ).scalars().first() + if any_active: + return int(any_active) + raise ValueError("no_active_user_for_charter_creator") + + async def _load_project_team_context( + self, + *, + tenant_id: int, + project_id: int, + owner_id: Optional[int], + ) -> List[Dict[str, Any]]: + member_ids_result = await self.db.execute( + select(UserProject.user_id).where( + and_( + UserProject.tenant_id == tenant_id, + UserProject.project_id == project_id, + ) + ) + ) + member_ids = {int(uid) for uid in member_ids_result.scalars().all()} + if owner_id: + member_ids.add(int(owner_id)) + if not member_ids: + manager_result = await self.db.execute( + select(User.id).where( + and_( + User.tenant_id == tenant_id, + User.status == "active", + User.is_active.is_(True), + User.role.in_(["BOSS", "ADMIN"]), + ) + ).limit(5) + ) + member_ids = {int(uid) for uid in manager_result.scalars().all()} + if not member_ids: + return [] + + users = ( + await self.db.execute( + select(User).where( + and_( + User.tenant_id == tenant_id, + User.id.in_(sorted(member_ids)), + User.status == "active", + User.is_active.is_(True), + ) + ) + ) + ).scalars().all() + if not users: + return [] + + user_ids = [int(u.id) for u in users] + workload_rows = ( + await self.db.execute( + select(UserWorkload).where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id.in_(user_ids), + ) + ) + ) + ).scalars().all() + workload_map = {int(item.user_id): item for item in workload_rows} + + skill_rows = ( + await self.db.execute( + select( + UserSkillBinding.user_id, + SkillTag.id, + SkillTag.name, + SkillTag.slug, + UserSkillBinding.proficiency, + ) + .join(SkillTag, SkillTag.id == UserSkillBinding.skill_tag_id) + .where( + and_( + UserSkillBinding.tenant_id == tenant_id, + UserSkillBinding.user_id.in_(user_ids), + SkillTag.tenant_id == tenant_id, + ) + ) + ) + ).all() + + skill_map: Dict[int, List[Dict[str, Any]]] = {} + for uid, sid, name, slug, proficiency in skill_rows: + skill_map.setdefault(int(uid), []).append( + { + "skill_id": int(sid), + "name": str(name or "").strip(), + "slug": str(slug or "").strip(), + "proficiency": int(proficiency or 0), + } + ) + + members: List[Dict[str, Any]] = [] + for user in users: + uid = int(user.id) + workload_row = workload_map.get(uid) + member_skills = skill_map.get(uid, []) + members.append( + { + "user_id": uid, + "name": str(user.name or user.username or uid), + "role": str(user.role or ""), + "workload_ratio": round( + float( + workload_row.workload_ratio + if workload_row is not None + else float(user.workload or 0.0) + ), + 2, + ), + "current_assigned_hours": round( + float(workload_row.current_assigned_hours or 0.0) if workload_row else 0.0, + 2, + ), + "max_available_hours_per_week": round( + float(workload_row.max_available_hours_per_week or 40.0) if workload_row else 40.0, + 2, + ), + "skill_tag_ids": [int(item["skill_id"]) for item in member_skills], + "skill_names": [str(item["name"]) for item in member_skills], + "skills": member_skills, + } + ) + members.sort(key=lambda x: (float(x.get("workload_ratio") or 0.0), int(x.get("user_id") or 0))) + return members + diff --git a/backend/app/services/signal_capture_service/service.py b/backend/app/services/signal_capture_service/service.py new file mode 100644 index 00000000..d116adba --- /dev/null +++ b/backend/app/services/signal_capture_service/service.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from .mixin_analysis_normalize import SignalCaptureAnalysisNormalizeMixin +from .mixin_capture_entry import SignalCaptureEntryMixin +from .mixin_capture_legacy import SignalCaptureLegacyMixin +from .mixin_external_sources import SignalCaptureExternalSourcesMixin +from .mixin_new_project import SignalCaptureNewProjectMixin +from .mixin_pipeline_cache import SignalCapturePipelineCacheMixin +from .mixin_source_analyzers import SignalCaptureSourceAnalyzersMixin +from .mixin_task_orchestration_main import SignalCaptureTaskOrchestrationMainMixin +from .mixin_task_orchestration_skill import SignalCaptureTaskOrchestrationSkillMixin +from .mixin_task_orchestration_team import SignalCaptureTaskOrchestrationTeamMixin + + +class ProactiveSignalCaptureService( + SignalCaptureEntryMixin, + SignalCaptureLegacyMixin, + SignalCapturePipelineCacheMixin, + SignalCaptureExternalSourcesMixin, + SignalCaptureTaskOrchestrationMainMixin, + SignalCaptureTaskOrchestrationTeamMixin, + SignalCaptureTaskOrchestrationSkillMixin, + SignalCaptureAnalysisNormalizeMixin, + SignalCaptureSourceAnalyzersMixin, + SignalCaptureNewProjectMixin, +): + """主动式信号捕获服务。""" + + + +def get_signal_capture_service() -> ProactiveSignalCaptureService: + """获取信号捕获服务实例。""" + return ProactiveSignalCaptureService + + +__all__ = ["ProactiveSignalCaptureService", "get_signal_capture_service"] diff --git a/backend/app/services/signal_capture_service/shared.py b/backend/app/services/signal_capture_service/shared.py new file mode 100644 index 00000000..571e8fa7 --- /dev/null +++ b/backend/app/services/signal_capture_service/shared.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +""" +Proactive Signal Capture Service - 主动式信号捕获服务 + +核心功能: +1. 飞书群聊监控 - 自动捕获群内讨论的产品/技术相关内容 +2. 浏览器标签监控 - 检测用户访问的技术文档、竞品页面 +3. GitHub 仓库监控 - 跟踪上游仓库更新、竞品开源项目 +4. HackerNews 自动抓取 - 定时抓取技术前沿信息 + +每个信号都会自动: +- 评估战略重要性 +- 提取关键技术栈 +- 生成任务建议 +- 推送给相关角色 +""" +import asyncio +import inspect +import json +import re +from uuid import uuid4 +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy import and_, text + +from ...core.logging import get_logger +from ...database import AsyncSessionLocal +from ...models import ( + Signal, Project, ProjectDomain, User, + ProjectSignalLink, Task, TruthNode, + SkillTag, UserProject, UserWorkload, UserSkillBinding, +) +from ...config.ai_prompts import ( + PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, + PROMPT_FEISHU_TASK_ORCHESTRATION, +) +from ..llm import LLMService +from ..notification_service import NotificationService +from .. import workload_service +from ..adaptive_rag_service import AdaptiveRAGService +from ..context_cache import context_cache_service +from ..signal_source_orchestration_service import SignalSourceOrchestrationService +from ..ai_pmo_kernel_service import AIPMOKernelService +from ...utils.project_members import ensure_project_default_members +from ...utils.sequence import ensure_project_id_sequence, ensure_signal_id_sequence + + +from ...utils.timezone import CN_TZ, now_cn_aware +EMOJI_UNICODE_RE = re.compile( + r"[\U0001F300-\U0001F6FF\U0001F700-\U0001F77F\U0001F780-\U0001F7FF" + r"\U0001F800-\U0001F8FF\U0001F900-\U0001F9FF\U0001FA00-\U0001FAFF" + r"\U00002600-\U000026FF\U00002700-\U000027BF]+" +) +ONLY_PUNCT_OR_SPACE_RE = re.compile(r"^[\s\W_]+$", re.UNICODE) +MENTION_TAG_RE = re.compile(r"]*>", re.IGNORECASE) +CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]+") +NON_REQUIREMENT_EXACT = { + "收到", + "收到啦", + "好的", + "ok", + "okk", + "okay", + "yes", + "no", + "1", + "2", + "3", + "thanks", + "thx", + "赞", + "辛苦", + "你是谁", + "你是誰", + "你是谁?", + "你是誰?", + "说话", + "在吗", + "在不在", + "今天什么日期", + "今天几号", + "今天星期几", + "现在几点", + "当前日期", +} +NON_REQUIREMENT_CONTAINS = [ + "你是谁", + "什么身份", + "身份确认", + "今天什么日期", + "当前日期", + "日期疑问", + "询问日期", + "现在几点", + "几点了", + "今天周几", + "今天星期几", + "说话", + "在吗", + "在不在", +] +REQUIREMENT_HINTS = [ + "需求", + "优化", + "改一下", + "调整", + "修复", + "bug", + "故障", + "报错", + "项目", + "页面", + "接口", + "功能", + "排版", + "样式", + "任务", + "立项", + "上线", + "配置", +] + +logger = get_logger(__name__) +AI_THINK_STAGE_ZH = { + "capture_start": "开始处理飞书消息", + "capture_skip_empty": "消息为空,忽略", + "capture_skip_noise": "识别为噪音消息,忽略", + "capture_done_ai_pmo": "AI PMO kernel 已完成决策闭环", + "capture_kernel_failed": "AI PMO kernel 执行异常", + "capture_kernel_failed_rollback_failed": "kernel 异常后回滚失败", + "classification_done": "语义分类完成", + "classification_parse_failed": "语义分类结果解析失败", + "capture_skip_not_requirement": "非需求消息,忽略", + "capture_skip_non_requirement": "判定为非需求,忽略", + "signal_created": "信号已创建", + "project_link_done": "项目关联完成", + "project_link_failed": "项目关联失败", + "task_orchestrate_failed": "任务编排失败", + "capture_done": "飞书消息处理完成", + "pipeline_post_orchestrate_failed": "分流后置任务编排失败", + "pipeline_post_update_signal_failed": "分流后置信号结果回写失败", + "pipeline_post_source_onboard_failed": "分流后置信号源接入编排失败", + "task_plan_llm_done": "任务编排 LLM 返回成功", + "task_plan_llm_failed": "任务编排 LLM 调用失败", + "task_created": "任务已创建", + "charter_auto_created": "自动创建宪章节点", + "hn_story_captured": "HackerNews 信号已捕获", + "hn_monitor_error": "HackerNews 监控异常", + "new_project_created_from_signal": "已根据信号自动创建项目", + "draft_new_project_brief_failed": "新项目摘要生成失败,已走兜底", +} + + +def _build_feishu_rag_conversation_key( + *, + source_chat_id: Optional[str], + source_sender_open_id: Optional[str], + sender: Optional[str], +) -> str: + chat = str(source_chat_id or "").strip() + if not chat: + return "" + actor = str(source_sender_open_id or "").strip() + if not actor: + actor = str(sender or "").strip().lower() + actor = re.sub(r"\s+", "", actor) + actor = actor[:96] if actor else "unknown" + return f"feishu:{chat}:{actor}" + + diff --git a/backend/app/services/signal_planner/__init__.py b/backend/app/services/signal_planner/__init__.py new file mode 100644 index 00000000..731171dd --- /dev/null +++ b/backend/app/services/signal_planner/__init__.py @@ -0,0 +1,5 @@ +"""Signal planner exports.""" + +from .service import SignalPlannerService + +__all__ = ["SignalPlannerService"] diff --git a/backend/app/services/signal_planner/mixin_analyze.py b/backend/app/services/signal_planner/mixin_analyze.py new file mode 100644 index 00000000..65d91826 --- /dev/null +++ b/backend/app/services/signal_planner/mixin_analyze.py @@ -0,0 +1,187 @@ +"""Analyze flow for signal planner service.""" + +from __future__ import annotations + +from .shared import ( + AsyncSession, + AsyncSessionLocal, + FeishuOutboxService, + ModelRoutingService, + Optional, + PLANNER_MAX_WAIT_SECONDS, + Signal, + SignalStrategicAnalysisService, + async_sessionmaker, + asyncio, + llm_service, + logger, +) + + +class SignalPlannerAnalyzeMixin: + """Executes planner LLM flow and failure handling.""" + + @classmethod + async def _analyze_with_session( + cls, + *, + db: AsyncSession, + signal_id: int, + tenant_id: int, + source_message_id: Optional[str], + source_chat_id: Optional[str], + dedupe_seed: Optional[str], + ) -> None: + signal: Signal | None = None + try: + signal = await cls._load_signal(db, tenant_id=int(tenant_id), signal_id=int(signal_id)) + if signal is None: + logger.warning( + "signal_planner_signal_missing", + tenant_id=int(tenant_id), + signal_id=int(signal_id), + ) + return + + project_id = cls._safe_int(getattr(signal, "matched_project_id", None)) + project_title = await cls._load_project_title( + db, + tenant_id=int(tenant_id), + project_id=project_id, + ) + recent_signals = await cls._load_recent_signal_summaries( + db, + tenant_id=int(tenant_id), + project_id=project_id, + exclude_signal_id=int(signal.id), + ) + members = await cls._load_member_profiles( + db, + tenant_id=int(tenant_id), + project_id=project_id, + ) + prompt = cls._build_prompt( + signal=signal, + project_title=project_title, + recent_signals=recent_signals, + members=members, + ) + route = await ModelRoutingService.get_effective_routing( + db, + tenant_id=int(tenant_id), + function_name="signal_planner_analyze", + ) + if str(route.get("source") or "") != "override": + logger.warning( + "signal_planner_routing_override_missing", + tenant_id=int(tenant_id), + signal_id=int(signal.id), + fallback_provider=str(route.get("provider") or ""), + fallback_model=str(route.get("model") or ""), + ) + + extraction = None + try: + extraction = await asyncio.wait_for( + llm_service.generate_structured_object( + prompt, + db=db, + tenant_id=int(tenant_id), + function_name="signal_planner_analyze", + temperature=0.0, + max_tokens=1600, + ), + timeout=PLANNER_MAX_WAIT_SECONDS, + ) + except asyncio.TimeoutError: + raise RuntimeError( + f"signal planner timeout after {int(PLANNER_MAX_WAIT_SECONDS)}s" + ) from None + + payload = ( + extraction.payload + if extraction is not None and isinstance(extraction.payload, dict) + else None + ) + if payload is None: + raise RuntimeError("signal planner empty payload") + + await SignalStrategicAnalysisService.merge_and_save( + db, + signal_id=int(signal.id), + planner_result=payload, + tenant_id=int(tenant_id), + ) + await db.commit() + logger.info( + "signal_planner_completed", + tenant_id=int(tenant_id), + signal_id=int(signal.id), + ) + except Exception as exc: + try: + if signal is None: + signal = await cls._load_signal(db, tenant_id=int(tenant_id), signal_id=int(signal_id)) + if signal is not None: + await SignalStrategicAnalysisService.mark_failed( + db, + signal_id=int(signal.id), + tenant_id=int(tenant_id), + reason=str(exc), + ) + chat_id, source_mid = cls._resolve_outbox_targets( + signal=signal, + source_message_id=source_message_id, + source_chat_id=source_chat_id, + ) + if chat_id: + await FeishuOutboxService.enqueue( + db, + tenant_id=int(tenant_id), + chat_id=chat_id, + text="信号已入库,但 AI 分析失败,请检查模型配置或稍后手动重试。", + source_message_id=source_mid, + dedupe_seed=dedupe_seed, + ) + await db.commit() + except Exception: + await db.rollback() + logger.warning( + "signal_planner_failed", + tenant_id=int(tenant_id), + signal_id=int(signal_id), + error=str(exc)[:1000], + ) + + @classmethod + async def analyze( + cls, + *, + signal_id: int, + tenant_id: int, + source_message_id: Optional[str] = None, + source_chat_id: Optional[str] = None, + dedupe_seed: Optional[str] = None, + session_factory: Optional[async_sessionmaker[AsyncSession]] = None, + ) -> None: + if session_factory is not None: + async with session_factory() as db: + await cls._analyze_with_session( + db=db, + signal_id=signal_id, + tenant_id=tenant_id, + source_message_id=source_message_id, + source_chat_id=source_chat_id, + dedupe_seed=dedupe_seed, + ) + return + + async with AsyncSessionLocal() as db: + await cls._analyze_with_session( + db=db, + signal_id=signal_id, + tenant_id=tenant_id, + source_message_id=source_message_id, + source_chat_id=source_chat_id, + dedupe_seed=dedupe_seed, + ) diff --git a/backend/app/services/signal_planner/mixin_loaders.py b/backend/app/services/signal_planner/mixin_loaders.py new file mode 100644 index 00000000..43a1f37f --- /dev/null +++ b/backend/app/services/signal_planner/mixin_loaders.py @@ -0,0 +1,196 @@ +"""Data loading helpers for signal planner service.""" + +from __future__ import annotations + +from .shared import ( + Any, + AsyncSession, + Optional, + Project, + Signal, + SkillTag, + User, + UserProject, + UserSkillBinding, + UserWorkload, + and_, + select, +) + + +class SignalPlannerLoadersMixin: + """Load signal/project/member contexts for planner prompt.""" + + @classmethod + async def _load_signal( + cls, + db: AsyncSession, + *, + tenant_id: int, + signal_id: int, + ) -> Signal | None: + return ( + await db.execute( + select(Signal) + .where( + and_( + Signal.tenant_id == int(tenant_id), + Signal.id == int(signal_id), + ) + ) + .limit(1) + ) + ).scalar_one_or_none() + + @classmethod + async def _load_project_title( + cls, + db: AsyncSession, + *, + tenant_id: int, + project_id: Optional[int], + ) -> str: + if project_id is None: + return "" + row = ( + await db.execute( + select(Project.title) + .where( + and_( + Project.tenant_id == int(tenant_id), + Project.id == int(project_id), + ) + ) + .limit(1) + ) + ).scalar_one_or_none() + return str(row or "") + + @classmethod + async def _load_recent_signal_summaries( + cls, + db: AsyncSession, + *, + tenant_id: int, + project_id: Optional[int], + exclude_signal_id: int, + ) -> list[dict[str, Any]]: + if project_id is None: + return [] + rows = ( + await db.execute( + select(Signal.id, Signal.title, Signal.summary, Signal.priority) + .where( + and_( + Signal.tenant_id == int(tenant_id), + Signal.matched_project_id == int(project_id), + Signal.id != int(exclude_signal_id), + ) + ) + .order_by(Signal.created_at.desc()) + .limit(8) + ) + ).all() + items: list[dict[str, Any]] = [] + for row in rows: + items.append( + { + "id": int(row[0]), + "title": str(row[1] or "")[:120], + "summary": str(row[2] or "")[:180], + "priority": str(row[3] or "")[:20], + } + ) + return items + + @classmethod + async def _load_member_profiles( + cls, + db: AsyncSession, + *, + tenant_id: int, + project_id: Optional[int], + ) -> list[dict[str, Any]]: + if project_id is None: + return [] + + rows = ( + await db.execute( + select( + User.id, + User.name, + User.role, + UserWorkload.workload_ratio, + SkillTag.name, + UserSkillBinding.proficiency, + ) + .join( + UserProject, + and_( + UserProject.tenant_id == int(tenant_id), + UserProject.user_id == User.id, + UserProject.project_id == int(project_id), + ), + ) + .join( + UserWorkload, + and_( + UserWorkload.tenant_id == int(tenant_id), + UserWorkload.user_id == User.id, + ), + isouter=True, + ) + .join( + UserSkillBinding, + and_( + UserSkillBinding.tenant_id == int(tenant_id), + UserSkillBinding.user_id == User.id, + ), + isouter=True, + ) + .join( + SkillTag, + and_( + SkillTag.tenant_id == int(tenant_id), + SkillTag.id == UserSkillBinding.skill_tag_id, + ), + isouter=True, + ) + .where( + and_( + User.tenant_id == int(tenant_id), + User.is_active == True, + ) + ) + .order_by(User.id.asc()) + ) + ).all() + + by_user: dict[int, dict[str, Any]] = {} + for row in rows: + user_id = cls._safe_int(row[0]) + if user_id is None: + continue + profile = by_user.setdefault( + int(user_id), + { + "user_id": int(user_id), + "name": str(row[1] or "成员"), + "role": str(row[2] or ""), + "workload_ratio": float(row[3] or 0.0), + "skills": [], + }, + ) + skill_name = str(row[4] or "").strip() + proficiency = cls._safe_int(row[5]) + if skill_name: + profile["skills"].append( + { + "name": skill_name, + "proficiency": proficiency, + } + ) + items = list(by_user.values()) + for item in items: + item["skills"] = item["skills"][:10] + return items diff --git a/backend/app/services/signal_planner/mixin_prompt.py b/backend/app/services/signal_planner/mixin_prompt.py new file mode 100644 index 00000000..bcccb24d --- /dev/null +++ b/backend/app/services/signal_planner/mixin_prompt.py @@ -0,0 +1,41 @@ +"""Prompt assembly for signal planner service.""" + +from __future__ import annotations + +from .shared import Any, PROMPT_SIGNAL_PLANNER, Signal, json, now_cn_naive + + +class SignalPlannerPromptMixin: + """Build planner prompt text from signal and project context.""" + + @classmethod + def _build_prompt( + cls, + *, + signal: Signal, + project_title: str, + recent_signals: list[dict[str, Any]], + members: list[dict[str, Any]], + ) -> str: + strategic = cls._as_dict(signal.strategic_analysis) + ai_analysis = ( + strategic.get("ai_analysis") + if isinstance(strategic.get("ai_analysis"), dict) + else {} + ) + return PROMPT_SIGNAL_PLANNER.format( + signal_id=int(signal.id), + signal_title=str(signal.title or ""), + signal_summary=str(signal.summary or ai_analysis.get("summary") or ""), + signal_content=str(signal.content or ""), + project_id=( + str(signal.matched_project_id) + if signal.matched_project_id is not None + else "" + ), + project_title=str(project_title or ""), + project_scope=str(ai_analysis.get("requirement_project_scope") or ""), + recent_signals=json.dumps(recent_signals, ensure_ascii=False), + member_profiles=json.dumps(members, ensure_ascii=False), + now_cn=now_cn_naive().isoformat(), + ) diff --git a/backend/app/services/signal_planner/mixin_utils.py b/backend/app/services/signal_planner/mixin_utils.py new file mode 100644 index 00000000..bda1cacf --- /dev/null +++ b/backend/app/services/signal_planner/mixin_utils.py @@ -0,0 +1,124 @@ +"""Utility helpers for signal planner service.""" + +from __future__ import annotations + +from .shared import Any, Optional, Signal, json + + +class SignalPlannerUtilsMixin: + """Type-safe conversion and fallback builder helpers.""" + + @staticmethod + def _as_dict(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return dict(value) + if isinstance(value, str): + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + return {} + + @staticmethod + def _safe_int(value: Any) -> Optional[int]: + try: + return int(str(value).strip()) + except (TypeError, ValueError, AttributeError): + return None + + @staticmethod + def _safe_float(value: Any) -> Optional[float]: + try: + return float(value) + except (TypeError, ValueError): + return None + + @classmethod + def _resolve_outbox_targets( + cls, + *, + signal: Signal, + source_message_id: Optional[str], + source_chat_id: Optional[str], + ) -> tuple[Optional[str], Optional[str]]: + strategic = cls._as_dict(signal.strategic_analysis) + source_context = ( + strategic.get("source_context") + if isinstance(strategic.get("source_context"), dict) + else {} + ) + resolved_chat_id = ( + str(source_chat_id or "").strip() + or str(source_context.get("source_chat_id") or "").strip() + or str(source_context.get("chat_id") or "").strip() + or str(source_context.get("channel_ref") or "").strip() + or str(source_context.get("group_chat_id") or "").strip() + or None + ) + resolved_source_message_id = ( + str(source_message_id or "").strip() + or str(source_context.get("source_message_id") or "").strip() + or str(source_context.get("message_id") or "").strip() + or None + ) + return resolved_chat_id, resolved_source_message_id + + @classmethod + def _pick_fallback_assignee_user_id( + cls, + *, + members: list[dict[str, Any]], + ) -> Optional[int]: + candidates: list[tuple[float, int]] = [] + for item in members: + uid = cls._safe_int(item.get("user_id")) + workload_ratio = cls._safe_float(item.get("workload_ratio")) + if uid is None: + continue + candidates.append((float(workload_ratio if workload_ratio is not None else 1000.0), int(uid))) + if not candidates: + return None + candidates.sort(key=lambda row: (row[0], row[1])) + return int(candidates[0][1]) + + @classmethod + def _build_fallback_planner_result( + cls, + *, + signal: Signal, + members: list[dict[str, Any]], + reason: str, + ) -> dict[str, Any]: + fallback_assignee = cls._pick_fallback_assignee_user_id(members=members) + summary_seed = ( + str(signal.summary or "").strip() + or str(signal.title or "").strip() + or str(signal.content or "").strip() + or "信号已入库,模型结构化输出异常,已自动降级生成初始执行建议。" + ) + execution_plan: list[dict[str, Any]] = [ + { + "step": "澄清信号目标与边界", + "owner_user_id": fallback_assignee, + "eta": "4h", + }, + { + "step": "拆解最小可执行任务并确认优先级", + "owner_user_id": fallback_assignee, + "eta": "1d", + }, + ] + return { + "summary": summary_seed[:180], + "priority": str(signal.priority or "medium").strip() or "medium", + "execution_plan": execution_plan, + "risks": [ + "模型返回非预期结构,已使用降级策略生成建议,请人工复核。", + ], + "confidence": 0.2, + "assignee_user_id": fallback_assignee, + "assignee_reason": "按当前最低负载成员自动指派(降级模式)", + "fallback_reason": str(reason or "structured_schema_invalid")[:200], + "fallback_mode": True, + } diff --git a/backend/app/services/signal_planner/service.py b/backend/app/services/signal_planner/service.py new file mode 100644 index 00000000..517fd8b5 --- /dev/null +++ b/backend/app/services/signal_planner/service.py @@ -0,0 +1,19 @@ +"""Signal planner service composition.""" + +from __future__ import annotations + +from .mixin_analyze import SignalPlannerAnalyzeMixin +from .mixin_loaders import SignalPlannerLoadersMixin +from .mixin_prompt import SignalPlannerPromptMixin +from .mixin_utils import SignalPlannerUtilsMixin + + +class SignalPlannerService( + SignalPlannerUtilsMixin, + SignalPlannerLoadersMixin, + SignalPlannerPromptMixin, + SignalPlannerAnalyzeMixin, +): + """Async member matching and execution planning for one signal.""" + + pass diff --git a/backend/app/services/signal_planner/shared.py b/backend/app/services/signal_planner/shared.py new file mode 100644 index 00000000..ffd144ae --- /dev/null +++ b/backend/app/services/signal_planner/shared.py @@ -0,0 +1,60 @@ +"""Shared imports/constants for signal planner service.""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any, Optional + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from ...config.ai_prompts import PROMPT_SIGNAL_PLANNER +from ...core.logging import get_logger +from ...database import AsyncSessionLocal +from ...models import ( + Project, + ProjectSignalLink, + Signal, + SkillTag, + User, + UserProject, + UserSkillBinding, + UserWorkload, +) +from ...utils.timezone import now_cn_naive +from ..feishu_outbox_service import FeishuOutboxService +from ..llm import llm_service +from ..model_routing_service import ModelRoutingService +from ..signal_strategic_analysis_service import SignalStrategicAnalysisService + +logger = get_logger(__name__) +PLANNER_MAX_WAIT_SECONDS = 25.0 + +__all__ = [ + "Any", + "AsyncSession", + "AsyncSessionLocal", + "FeishuOutboxService", + "ModelRoutingService", + "Optional", + "PLANNER_MAX_WAIT_SECONDS", + "PROMPT_SIGNAL_PLANNER", + "Project", + "ProjectSignalLink", + "Signal", + "SignalStrategicAnalysisService", + "SkillTag", + "User", + "UserProject", + "UserSkillBinding", + "UserWorkload", + "and_", + "async_sessionmaker", + "asyncio", + "json", + "llm_service", + "logger", + "now_cn_naive", + "select", +] diff --git a/backend/app/services/signal_planner_service.py b/backend/app/services/signal_planner_service.py index fd68eeea..d0a5c758 100644 --- a/backend/app/services/signal_planner_service.py +++ b/backend/app/services/signal_planner_service.py @@ -1,481 +1,5 @@ -"""Signal Planner 异步分析服务。""" +"""Compatibility exports for signal planner service.""" -from __future__ import annotations +from .signal_planner import SignalPlannerService -import asyncio -import json -from typing import Any, Optional - -from sqlalchemy import and_, select -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker - -from ..config.ai_prompts import PROMPT_SIGNAL_PLANNER -from ..core.logging import get_logger -from ..database import AsyncSessionLocal -from ..models import Project, Signal, SkillTag, User, UserProject, UserSkillBinding, UserWorkload -from ..utils.timezone import now_cn_naive -from .feishu_outbox_service import FeishuOutboxService -from .llm import llm_service -from .model_routing_service import ModelRoutingService -from .signal_strategic_analysis_service import SignalStrategicAnalysisService - -logger = get_logger(__name__) -PLANNER_MAX_WAIT_SECONDS = 25.0 - - -class SignalPlannerService: - """对单条 signal 进行异步成员匹配与计划建议。""" - - @staticmethod - def _as_dict(value: Any) -> dict[str, Any]: - if isinstance(value, dict): - return dict(value) - if isinstance(value, str): - try: - parsed = json.loads(value) - return parsed if isinstance(parsed, dict) else {} - except Exception: - return {} - return {} - - @staticmethod - def _safe_int(value: Any) -> Optional[int]: - try: - return int(str(value).strip()) - except (TypeError, ValueError, AttributeError): - return None - - @staticmethod - def _safe_float(value: Any) -> Optional[float]: - try: - return float(value) - except (TypeError, ValueError): - return None - - @classmethod - async def _load_signal(cls, db: AsyncSession, *, tenant_id: int, signal_id: int) -> Signal | None: - return ( - await db.execute( - select(Signal).where( - and_( - Signal.tenant_id == int(tenant_id), - Signal.id == int(signal_id), - ) - ).limit(1) - ) - ).scalar_one_or_none() - - @classmethod - async def _load_project_title( - cls, - db: AsyncSession, - *, - tenant_id: int, - project_id: Optional[int], - ) -> str: - if project_id is None: - return "" - row = ( - await db.execute( - select(Project.title).where( - and_( - Project.tenant_id == int(tenant_id), - Project.id == int(project_id), - ) - ).limit(1) - ) - ).scalar_one_or_none() - return str(row or "") - - @classmethod - async def _load_recent_signal_summaries( - cls, - db: AsyncSession, - *, - tenant_id: int, - project_id: Optional[int], - exclude_signal_id: int, - ) -> list[dict[str, Any]]: - if project_id is None: - return [] - rows = ( - await db.execute( - select(Signal.id, Signal.title, Signal.summary, Signal.priority) - .where( - and_( - Signal.tenant_id == int(tenant_id), - Signal.matched_project_id == int(project_id), - Signal.id != int(exclude_signal_id), - ) - ) - .order_by(Signal.created_at.desc()) - .limit(8) - ) - ).all() - items: list[dict[str, Any]] = [] - for row in rows: - items.append( - { - "id": int(row[0]), - "title": str(row[1] or "")[:120], - "summary": str(row[2] or "")[:180], - "priority": str(row[3] or "")[:20], - } - ) - return items - - @classmethod - async def _load_member_profiles( - cls, - db: AsyncSession, - *, - tenant_id: int, - project_id: Optional[int], - ) -> list[dict[str, Any]]: - if project_id is None: - return [] - - rows = ( - await db.execute( - select( - User.id, - User.name, - User.role, - UserWorkload.workload_ratio, - SkillTag.name, - UserSkillBinding.proficiency, - ) - .join( - UserProject, - and_( - UserProject.tenant_id == int(tenant_id), - UserProject.user_id == User.id, - UserProject.project_id == int(project_id), - ), - ) - .join( - UserWorkload, - and_( - UserWorkload.tenant_id == int(tenant_id), - UserWorkload.user_id == User.id, - ), - isouter=True, - ) - .join( - UserSkillBinding, - and_( - UserSkillBinding.tenant_id == int(tenant_id), - UserSkillBinding.user_id == User.id, - ), - isouter=True, - ) - .join( - SkillTag, - and_( - SkillTag.tenant_id == int(tenant_id), - SkillTag.id == UserSkillBinding.skill_tag_id, - ), - isouter=True, - ) - .where( - and_( - User.tenant_id == int(tenant_id), - User.is_active == True, - ) - ) - .order_by(User.id.asc()) - ) - ).all() - - by_user: dict[int, dict[str, Any]] = {} - for row in rows: - user_id = cls._safe_int(row[0]) - if user_id is None: - continue - profile = by_user.setdefault( - int(user_id), - { - "user_id": int(user_id), - "name": str(row[1] or "成员"), - "role": str(row[2] or ""), - "workload_ratio": float(row[3] or 0.0), - "skills": [], - }, - ) - skill_name = str(row[4] or "").strip() - proficiency = cls._safe_int(row[5]) - if skill_name: - profile["skills"].append( - { - "name": skill_name, - "proficiency": proficiency, - } - ) - items = list(by_user.values()) - for item in items: - item["skills"] = item["skills"][:10] - return items - - @classmethod - def _resolve_outbox_targets( - cls, - *, - signal: Signal, - source_message_id: Optional[str], - source_chat_id: Optional[str], - ) -> tuple[Optional[str], Optional[str]]: - strategic = cls._as_dict(signal.strategic_analysis) - source_context = strategic.get("source_context") if isinstance(strategic.get("source_context"), dict) else {} - resolved_chat_id = ( - str(source_chat_id or "").strip() - or str(source_context.get("source_chat_id") or "").strip() - or str(source_context.get("chat_id") or "").strip() - or str(source_context.get("channel_ref") or "").strip() - or str(source_context.get("group_chat_id") or "").strip() - or None - ) - resolved_source_message_id = ( - str(source_message_id or "").strip() - or str(source_context.get("source_message_id") or "").strip() - or str(source_context.get("message_id") or "").strip() - or None - ) - return resolved_chat_id, resolved_source_message_id - - @classmethod - def _build_prompt( - cls, - *, - signal: Signal, - project_title: str, - recent_signals: list[dict[str, Any]], - members: list[dict[str, Any]], - ) -> str: - strategic = cls._as_dict(signal.strategic_analysis) - ai_analysis = strategic.get("ai_analysis") if isinstance(strategic.get("ai_analysis"), dict) else {} - return PROMPT_SIGNAL_PLANNER.format( - signal_id=int(signal.id), - signal_title=str(signal.title or ""), - signal_summary=str(signal.summary or ai_analysis.get("summary") or ""), - signal_content=str(signal.content or ""), - project_id=(str(signal.matched_project_id) if signal.matched_project_id is not None else ""), - project_title=str(project_title or ""), - project_scope=str(ai_analysis.get("requirement_project_scope") or ""), - recent_signals=json.dumps(recent_signals, ensure_ascii=False), - member_profiles=json.dumps(members, ensure_ascii=False), - now_cn=now_cn_naive().isoformat(), - ) - - @classmethod - def _pick_fallback_assignee_user_id(cls, *, members: list[dict[str, Any]]) -> Optional[int]: - candidates: list[tuple[float, int]] = [] - for item in members: - uid = cls._safe_int(item.get("user_id")) - workload_ratio = cls._safe_float(item.get("workload_ratio")) - if uid is None: - continue - candidates.append((float(workload_ratio if workload_ratio is not None else 1000.0), int(uid))) - if not candidates: - return None - candidates.sort(key=lambda row: (row[0], row[1])) - return int(candidates[0][1]) - - @classmethod - def _build_fallback_planner_result( - cls, - *, - signal: Signal, - members: list[dict[str, Any]], - reason: str, - ) -> dict[str, Any]: - fallback_assignee = cls._pick_fallback_assignee_user_id(members=members) - summary_seed = ( - str(signal.summary or "").strip() - or str(signal.title or "").strip() - or str(signal.content or "").strip() - or "信号已入库,模型结构化输出异常,已自动降级生成初始执行建议。" - ) - execution_plan: list[dict[str, Any]] = [ - { - "step": "澄清信号目标与边界", - "owner_user_id": fallback_assignee, - "eta": "4h", - }, - { - "step": "拆解最小可执行任务并确认优先级", - "owner_user_id": fallback_assignee, - "eta": "1d", - }, - ] - return { - "summary": summary_seed[:180], - "priority": str(signal.priority or "medium").strip() or "medium", - "execution_plan": execution_plan, - "risks": [ - "模型返回非预期结构,已使用降级策略生成建议,请人工复核。", - ], - "confidence": 0.2, - "assignee_user_id": fallback_assignee, - "assignee_reason": "按当前最低负载成员自动指派(降级模式)", - "fallback_reason": str(reason or "structured_schema_invalid")[:200], - "fallback_mode": True, - } - - @classmethod - async def _analyze_with_session( - cls, - *, - db: AsyncSession, - signal_id: int, - tenant_id: int, - source_message_id: Optional[str], - source_chat_id: Optional[str], - dedupe_seed: Optional[str], - ) -> None: - signal: Signal | None = None - try: - signal = await cls._load_signal(db, tenant_id=int(tenant_id), signal_id=int(signal_id)) - if signal is None: - logger.warning( - "signal_planner_signal_missing", - tenant_id=int(tenant_id), - signal_id=int(signal_id), - ) - return - - project_id = cls._safe_int(getattr(signal, "matched_project_id", None)) - project_title = await cls._load_project_title( - db, - tenant_id=int(tenant_id), - project_id=project_id, - ) - recent_signals = await cls._load_recent_signal_summaries( - db, - tenant_id=int(tenant_id), - project_id=project_id, - exclude_signal_id=int(signal.id), - ) - members = await cls._load_member_profiles( - db, - tenant_id=int(tenant_id), - project_id=project_id, - ) - prompt = cls._build_prompt( - signal=signal, - project_title=project_title, - recent_signals=recent_signals, - members=members, - ) - route = await ModelRoutingService.get_effective_routing( - db, - tenant_id=int(tenant_id), - function_name="signal_planner_analyze", - ) - if str(route.get("source") or "") != "override": - logger.warning( - "signal_planner_routing_override_missing", - tenant_id=int(tenant_id), - signal_id=int(signal.id), - fallback_provider=str(route.get("provider") or ""), - fallback_model=str(route.get("model") or ""), - ) - - extraction = None - try: - extraction = await asyncio.wait_for( - llm_service.generate_structured_object( - prompt, - db=db, - tenant_id=int(tenant_id), - function_name="signal_planner_analyze", - temperature=0.0, - max_tokens=1600, - ), - timeout=PLANNER_MAX_WAIT_SECONDS, - ) - except asyncio.TimeoutError: - raise RuntimeError(f"signal planner timeout after {int(PLANNER_MAX_WAIT_SECONDS)}s") from None - - payload = extraction.payload if extraction is not None and isinstance(extraction.payload, dict) else None - if payload is None: - raise RuntimeError("signal planner empty payload") - - await SignalStrategicAnalysisService.merge_and_save( - db, - signal_id=int(signal.id), - planner_result=payload, - tenant_id=int(tenant_id), - ) - await db.commit() - logger.info( - "signal_planner_completed", - tenant_id=int(tenant_id), - signal_id=int(signal.id), - ) - except Exception as exc: - try: - if signal is None: - signal = await cls._load_signal(db, tenant_id=int(tenant_id), signal_id=int(signal_id)) - if signal is not None: - await SignalStrategicAnalysisService.mark_failed( - db, - signal_id=int(signal.id), - tenant_id=int(tenant_id), - reason=str(exc), - ) - chat_id, source_mid = cls._resolve_outbox_targets( - signal=signal, - source_message_id=source_message_id, - source_chat_id=source_chat_id, - ) - if chat_id: - await FeishuOutboxService.enqueue( - db, - tenant_id=int(tenant_id), - chat_id=chat_id, - text="信号已入库,但 AI 分析失败,请检查模型配置或稍后手动重试。", - source_message_id=source_mid, - dedupe_seed=dedupe_seed, - ) - await db.commit() - except Exception: - await db.rollback() - logger.warning( - "signal_planner_failed", - tenant_id=int(tenant_id), - signal_id=int(signal_id), - error=str(exc)[:1000], - ) - - @classmethod - async def analyze( - cls, - *, - signal_id: int, - tenant_id: int, - source_message_id: Optional[str] = None, - source_chat_id: Optional[str] = None, - dedupe_seed: Optional[str] = None, - session_factory: Optional[async_sessionmaker[AsyncSession]] = None, - ) -> None: - if session_factory is not None: - async with session_factory() as db: - await cls._analyze_with_session( - db=db, - signal_id=signal_id, - tenant_id=tenant_id, - source_message_id=source_message_id, - source_chat_id=source_chat_id, - dedupe_seed=dedupe_seed, - ) - return - - async with AsyncSessionLocal() as db: - await cls._analyze_with_session( - db=db, - signal_id=signal_id, - tenant_id=tenant_id, - source_message_id=source_message_id, - source_chat_id=source_chat_id, - dedupe_seed=dedupe_seed, - ) +__all__ = ["SignalPlannerService"] diff --git a/backend/app/services/signal_source_orchestration/__init__.py b/backend/app/services/signal_source_orchestration/__init__.py new file mode 100644 index 00000000..d0e3082a --- /dev/null +++ b/backend/app/services/signal_source_orchestration/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .service import SignalSourceOrchestrationService + +__all__ = ["SignalSourceOrchestrationService"] diff --git a/backend/app/services/signal_source_orchestration/mixin_entry_orchestrate.py b/backend/app/services/signal_source_orchestration/mixin_entry_orchestrate.py new file mode 100644 index 00000000..7fb9adff --- /dev/null +++ b/backend/app/services/signal_source_orchestration/mixin_entry_orchestrate.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +from .shared import * + +class SignalSourceEntryOrchestrateMixin: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + @classmethod + def looks_like_signal_source_request(cls, text: str) -> bool: + normalized = re.sub(r"\s+", "", str(text or "")) + if len(normalized) < 3: + return False + if "信号源" in normalized and any( + token in normalized + for token in ("新增", "增加", "添加", "接入", "配置", "注册", "建立", "加个", "加一个", "加") + ): + return True + return bool(SOURCE_ONBOARDING_ACTION_RE.search(normalized)) + + async def orchestrate_from_signal( + self, + *, + tenant_id: int, + project_id: int, + signal_id: Optional[int], + initiating_text: str, + requested_via: str, + operator_user_id: Optional[int] = None, + dry_run: bool = False, + response_mode: str = "fast", + ) -> dict[str, Any]: + response_mode = "complete" if str(response_mode or "").strip().lower() == "complete" else "fast" + trace_id = f"source-onboard-{tenant_id}-{project_id}-{int(time.time() * 1000)}-{uuid4().hex[:8]}" + project = await self._load_project(tenant_id=tenant_id, project_id=project_id) + signal = await self._load_signal(tenant_id=tenant_id, signal_id=signal_id) if signal_id is not None else None + run_service = AIOrchestrationRunService(self.db) if self.db is not None else None + if project is None: + return { + "trace_id": trace_id, + "status": "skipped", + "reason": "project_or_signal_not_found", + } + if signal is None: + if dry_run: + signal = self._build_ephemeral_signal(initiating_text=initiating_text) + else: + return { + "trace_id": trace_id, + "status": "skipped", + "reason": "project_or_signal_not_found", + } + project_ref = SimpleNamespace( + id=int(project.id), + title=str(project.title or "").strip(), + name=str(project.name or "").strip(), + description=str(project.description or "").strip(), + owner_id=int(project.owner_id) if getattr(project, "owner_id", None) else None, + ) + signal_ref = SimpleNamespace( + id=int(signal.id) if getattr(signal, "id", None) is not None else None, + title=str(signal.title or "").strip(), + summary=str(signal.summary or "").strip(), + signal_type=str(signal.signal_type or "").strip(), + recommended_action=str(signal.recommended_action or "").strip(), + content=str(signal.content or "").strip(), + ) + real_signal_id = signal_ref.id + persisted_signal_id = int(real_signal_id) if real_signal_id and int(real_signal_id) > 0 else None + + if not self.looks_like_signal_source_request(initiating_text): + return await self._handle_non_source_request( + tenant_id=tenant_id, + project_id=project_id, + persisted_signal_id=persisted_signal_id, + operator_user_id=operator_user_id, + trace_id=trace_id, + response_mode=response_mode, + run_service=run_service, + ) + + plan, planner_status, planner_diagnostics = await self._plan_onboarding( + tenant_id=tenant_id, + project=project_ref, + signal=signal_ref, + initiating_text=initiating_text, + requested_via=requested_via, + response_mode=response_mode, + ) + if not bool(plan.get("should_onboard")): + return await self._handle_planner_rejected( + tenant_id=tenant_id, + project_id=project_id, + persisted_signal_id=persisted_signal_id, + operator_user_id=operator_user_id, + trace_id=trace_id, + response_mode=response_mode, + planner_status=planner_status, + planner_diagnostics=planner_diagnostics, + summary=str(plan.get("summary") or "").strip(), + run_service=run_service, + ) + + source_payload = plan.get("source") if isinstance(plan.get("source"), dict) else {} + worker_role_keys = self._normalize_worker_roles(plan.get("worker_roles")) + registry_result = await self._create_or_reuse_registry( + tenant_id=tenant_id, + project_id=project_id, + signal_id=signal_id, + operator_user_id=operator_user_id, + requested_via=requested_via, + source_payload=source_payload, + initiating_text=initiating_text, + dry_run=dry_run, + ) + + operator_user = await self._resolve_operator_user( + tenant_id=tenant_id, + project=project_ref, + operator_user_id=operator_user_id, + ) + operator_ref = self._build_user_snapshot(operator_user) + execution_result = await self._execute_planned_operations( + tenant_id=tenant_id, + operator_user=operator_ref, + project=project_ref, + instruction=str(plan.get("execution_instruction") or "").strip(), + dry_run=dry_run, + response_mode=response_mode, + ) + execution_status = str(execution_result.get("status") or "").strip().lower() + base_result = self._build_orchestration_result( + trace_id=trace_id, + response_mode=response_mode, + planner_status=planner_status, + planner_diagnostics=planner_diagnostics, + summary=str(plan.get("summary") or "").strip(), + registry_result=registry_result, + source_payload=source_payload, + execution_result=execution_result, + worker_results=[], + pending_workers=list(worker_role_keys), + status="planned" if dry_run else "partial", + run_status="completed" if dry_run or not worker_role_keys else "running", + ) + + if dry_run: + return await self._handle_dry_run_plan( + tenant_id=tenant_id, + project_id=project_id, + persisted_signal_id=persisted_signal_id, + operator_user_id=operator_user_id, + trace_id=trace_id, + response_mode=response_mode, + planner_status=planner_status, + planner_diagnostics=planner_diagnostics, + summary=str(plan.get("summary") or "").strip(), + registry_result=registry_result, + source_payload=source_payload, + execution_result=execution_result, + execution_status=execution_status, + worker_role_keys=worker_role_keys, + run_service=run_service, + ) + + if not worker_role_keys: + return await self._handle_no_worker_plan( + tenant_id=tenant_id, + project_id=project_id, + persisted_signal_id=persisted_signal_id, + operator_user_id=operator_user_id, + trace_id=trace_id, + response_mode=response_mode, + planner_status=planner_status, + planner_diagnostics=planner_diagnostics, + summary=str(plan.get("summary") or "").strip(), + registry_result=registry_result, + source_payload=source_payload, + execution_result=execution_result, + execution_status=execution_status, + real_signal_id=real_signal_id, + run_service=run_service, + ) + + return await self._run_workers_background_or_wait( + tenant_id=tenant_id, + project_id=project_id, + persisted_signal_id=persisted_signal_id, + operator_user_id=operator_user_id, + trace_id=trace_id, + response_mode=response_mode, + planner_status=planner_status, + planner_diagnostics=planner_diagnostics, + summary=str(plan.get("summary") or "").strip(), + project_ref=project_ref, + signal_ref=signal_ref, + registry_result=registry_result, + execution_result=execution_result, + execution_status=execution_status, + worker_role_keys=worker_role_keys, + operator_ref=operator_ref, + base_result=base_result, + run_service=run_service, + ) + diff --git a/backend/app/services/signal_source_orchestration/mixin_entry_terminal_a.py b/backend/app/services/signal_source_orchestration/mixin_entry_terminal_a.py new file mode 100644 index 00000000..1ef1d6b7 --- /dev/null +++ b/backend/app/services/signal_source_orchestration/mixin_entry_terminal_a.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from .shared import * + +class SignalSourceEntryTerminalAMixin: + async def _handle_non_source_request( + self, + *, + tenant_id: int, + project_id: int, + persisted_signal_id: Optional[int], + operator_user_id: Optional[int], + trace_id: str, + response_mode: str, + run_service: Optional[AIOrchestrationRunService], + ) -> dict[str, Any]: + result = { + "trace_id": trace_id, + "status": "skipped", + "run_status": "completed", + "response_mode": response_mode, + "planner_status": "skipped", + "planner_diagnostics": None, + "summary": "当前输入不属于信号源接入请求。", + "pending_workers": [], + "worker_executions": [], + "execution": {"status": "skipped", "reason": "execution_removed"}, + "reason": "not_signal_source_request", + } + if run_service is not None: + await run_service.create_run( + tenant_id=tenant_id, + trace_id=trace_id, + run_type="signal_source_onboarding", + project_id=project_id, + signal_id=persisted_signal_id, + response_mode=response_mode, + status="completed", + planner_status="skipped", + execution_status="skipped", + worker_total=0, + worker_completed=0, + worker_failed=0, + summary=str(result.get("summary") or "").strip(), + planner_diagnostics=None, + result_payload=dict(result), + created_by_user_id=operator_user_id, + completed_at=_utc_now_naive(), + ) + await self.db.commit() + return result + + async def _handle_planner_rejected( + self, + *, + tenant_id: int, + project_id: int, + persisted_signal_id: Optional[int], + operator_user_id: Optional[int], + trace_id: str, + response_mode: str, + planner_status: str, + planner_diagnostics: Optional[dict[str, Any]], + summary: str, + run_service: Optional[AIOrchestrationRunService], + ) -> dict[str, Any]: + result = { + "trace_id": trace_id, + "status": "skipped", + "run_status": "completed", + "response_mode": response_mode, + "reason": "planner_rejected", + "planner_status": planner_status, + "planner_diagnostics": planner_diagnostics, + "summary": summary, + "pending_workers": [], + "worker_executions": [], + "execution": {"status": "skipped", "reason": "planner_rejected"}, + } + if run_service is not None: + await run_service.create_run( + tenant_id=tenant_id, + trace_id=trace_id, + run_type="signal_source_onboarding", + project_id=project_id, + signal_id=persisted_signal_id, + response_mode=response_mode, + status="completed", + planner_status=planner_status, + execution_status="skipped", + worker_total=0, + worker_completed=0, + worker_failed=0, + summary=str(result.get("summary") or "").strip(), + planner_diagnostics=planner_diagnostics, + result_payload=dict(result), + created_by_user_id=operator_user_id, + completed_at=_utc_now_naive(), + ) + await self.db.commit() + return result + + async def _handle_dry_run_plan( + self, + *, + tenant_id: int, + project_id: int, + persisted_signal_id: Optional[int], + operator_user_id: Optional[int], + trace_id: str, + response_mode: str, + planner_status: str, + planner_diagnostics: Optional[dict[str, Any]], + summary: str, + registry_result: dict[str, Any], + source_payload: dict[str, Any], + execution_result: dict[str, Any], + execution_status: str, + worker_role_keys: list[str], + run_service: Optional[AIOrchestrationRunService], + ) -> dict[str, Any]: + planned_results = self._build_planned_worker_results(worker_role_keys) + final_result = self._build_orchestration_result( + trace_id=trace_id, + response_mode=response_mode, + planner_status=planner_status, + planner_diagnostics=planner_diagnostics, + summary=summary, + registry_result=registry_result, + source_payload=source_payload, + execution_result=execution_result, + worker_results=planned_results, + pending_workers=[], + status="planned", + run_status="completed", + ) + if run_service is not None: + await run_service.create_run( + tenant_id=tenant_id, + trace_id=trace_id, + run_type="signal_source_onboarding", + project_id=project_id, + signal_id=persisted_signal_id, + response_mode=response_mode, + status="completed", + planner_status=planner_status, + execution_status=execution_status, + worker_total=len(worker_role_keys), + worker_completed=len(planned_results), + worker_failed=0, + summary=str(final_result.get("summary") or "").strip(), + planner_diagnostics=planner_diagnostics, + result_payload=dict(final_result), + created_by_user_id=operator_user_id, + completed_at=_utc_now_naive(), + ) + await self.db.commit() + return final_result + diff --git a/backend/app/services/signal_source_orchestration/mixin_entry_terminal_b.py b/backend/app/services/signal_source_orchestration/mixin_entry_terminal_b.py new file mode 100644 index 00000000..3c9a5849 --- /dev/null +++ b/backend/app/services/signal_source_orchestration/mixin_entry_terminal_b.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from .shared import * + +class SignalSourceEntryTerminalBMixin: + async def _handle_no_worker_plan( + self, + *, + tenant_id: int, + project_id: int, + persisted_signal_id: Optional[int], + operator_user_id: Optional[int], + trace_id: str, + response_mode: str, + planner_status: str, + planner_diagnostics: Optional[dict[str, Any]], + summary: str, + registry_result: dict[str, Any], + source_payload: dict[str, Any], + execution_result: dict[str, Any], + execution_status: str, + real_signal_id: Optional[int], + run_service: Optional[AIOrchestrationRunService], + ) -> dict[str, Any]: + registry_status, onboarding_status = self._resolve_registry_activation( + source_payload=source_payload, + execution_result=execution_result, + worker_results=[], + ) + final_status = self._resolve_orchestration_status(openclaw_status=execution_status, worker_results=[]) + if final_status == "completed" and onboarding_status != "completed": + final_status = "pending_manual" + final_result = self._build_orchestration_result( + trace_id=trace_id, + response_mode=response_mode, + planner_status=planner_status, + planner_diagnostics=planner_diagnostics, + summary=summary, + registry_result={ + "registry_id": registry_result.get("registry_id"), + "status": registry_status, + "onboarding_status": onboarding_status, + }, + source_payload=source_payload, + execution_result=execution_result, + worker_results=[], + pending_workers=[], + status=final_status, + run_status="completed", + ) + if run_service is not None: + await run_service.create_run( + tenant_id=tenant_id, + trace_id=trace_id, + run_type="signal_source_onboarding", + project_id=project_id, + signal_id=persisted_signal_id, + response_mode=response_mode, + status=final_status, + planner_status=planner_status, + execution_status=execution_status, + worker_total=0, + worker_completed=0, + worker_failed=0, + summary=str(final_result.get("summary") or "").strip(), + planner_diagnostics=planner_diagnostics, + result_payload=dict(final_result), + created_by_user_id=operator_user_id, + completed_at=_utc_now_naive(), + ) + if registry_result.get("registry_id") is not None: + registry_row = await self._load_signal_source_registry( + tenant_id=tenant_id, + registry_id=int(registry_result["registry_id"]), + ) + if registry_row is not None: + registry_row.last_signal_at = _utc_now_naive() + registry_row.last_worker_run_at = _utc_now_naive() + registry_row.status = registry_status + registry_row.onboarding_status = onboarding_status + await self.db.commit() + if real_signal_id: + persisted_signal = await self._load_signal(tenant_id=tenant_id, signal_id=int(real_signal_id)) + if persisted_signal is not None: + await self._persist_signal_orchestration_result(signal=persisted_signal, orchestration_result=final_result) + return final_result + + async def _run_workers_background_or_wait( + self, + *, + tenant_id: int, + project_id: int, + persisted_signal_id: Optional[int], + operator_user_id: Optional[int], + trace_id: str, + response_mode: str, + planner_status: str, + planner_diagnostics: Optional[dict[str, Any]], + summary: str, + project_ref: SimpleNamespace, + signal_ref: SimpleNamespace, + registry_result: dict[str, Any], + execution_result: dict[str, Any], + execution_status: str, + worker_role_keys: list[str], + operator_ref: dict[str, Any], + base_result: dict[str, Any], + run_service: Optional[AIOrchestrationRunService], + ) -> dict[str, Any]: + if run_service is not None: + await run_service.create_run( + tenant_id=tenant_id, + trace_id=trace_id, + run_type="signal_source_onboarding", + project_id=project_id, + signal_id=persisted_signal_id, + response_mode=response_mode, + status="running", + planner_status=planner_status, + execution_status=execution_status, + worker_total=len(worker_role_keys), + worker_completed=0, + worker_failed=0, + summary=str(base_result.get("summary") or "").strip(), + planner_diagnostics=planner_diagnostics, + result_payload=dict(base_result), + created_by_user_id=operator_user_id, + ) + await self.db.commit() + + task = asyncio.create_task( + type(self)._background_finalize_with_guard( + tenant_id=tenant_id, + trace_id=trace_id, + project_payload=self._serialize_snapshot(project_ref), + signal_payload=self._serialize_snapshot(signal_ref), + registry_result=dict(registry_result), + openclaw_result=dict(execution_result), + worker_role_keys=list(worker_role_keys), + operator_user_payload=self._serialize_snapshot(operator_ref), + planner_status=planner_status, + planner_diagnostics=planner_diagnostics, + summary=summary, + signal_id=persisted_signal_id, + ), + name=f"signal-source-workers-{trace_id}", + ) + if response_mode == "fast": + return base_result + try: + return await asyncio.wait_for(asyncio.shield(task), timeout=COMPLETE_WORKER_WAIT_TIMEOUT_SECONDS) + except asyncio.TimeoutError: + if run_service is None: + return base_result + run_row = await run_service.get_run(tenant_id=tenant_id, trace_id=trace_id) + if run_row is None: + return base_result + snapshot = AIOrchestrationRunService.serialize_run(run_row) + payload = dict(run_row.result_payload or base_result) + payload["run_status"] = str(snapshot.get("run_status") or "running") + payload["status"] = str(payload.get("status") or "partial") + payload["pending_workers"] = snapshot.get("pending_workers") or payload.get("pending_workers") or [] + return payload + diff --git a/backend/app/services/signal_source_orchestration/mixin_execute_isolation.py b/backend/app/services/signal_source_orchestration/mixin_execute_isolation.py new file mode 100644 index 00000000..581e2700 --- /dev/null +++ b/backend/app/services/signal_source_orchestration/mixin_execute_isolation.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +from .shared import * + +class SignalSourceExecuteIsolationMixin: + @classmethod + async def _execute_single_worker_role_isolated( + cls, + *, + tenant_id: int, + project_payload: dict[str, Any], + signal_payload: dict[str, Any], + registry_result: dict[str, Any], + openclaw_result: dict[str, Any], + role: dict[str, Any], + trace_id: str, + operator_user_payload: Optional[dict[str, Any]], + response_mode: str, + ) -> dict[str, Any]: + session_factory = getattr(cls, "session_factory", AsyncSessionLocal) + async with session_factory() as db: + service = cls(db) + return await service._execute_single_worker_role( + tenant_id=tenant_id, + project=SimpleNamespace(**project_payload), + signal=SimpleNamespace(**signal_payload), + registry_result=registry_result, + execution_result=openclaw_result, + role=role, + trace_id=trace_id, + operator_user=SimpleNamespace(**operator_user_payload) if isinstance(operator_user_payload, dict) else None, + dry_run=False, + response_mode=response_mode, + ) + + @classmethod + async def _background_finalize_with_guard( + cls, + *, + tenant_id: int, + trace_id: str, + project_payload: dict[str, Any], + signal_payload: dict[str, Any], + registry_result: dict[str, Any], + openclaw_result: dict[str, Any], + worker_role_keys: list[str], + operator_user_payload: Optional[dict[str, Any]], + planner_status: str, + planner_diagnostics: Optional[dict[str, Any]], + summary: str, + signal_id: Optional[int], + ) -> dict[str, Any]: + try: + return await cls._execute_workers_and_finalize( + tenant_id=tenant_id, + trace_id=trace_id, + project_payload=project_payload, + signal_payload=signal_payload, + registry_result=registry_result, + execution_result=openclaw_result, + worker_role_keys=worker_role_keys, + operator_user_payload=operator_user_payload, + planner_status=planner_status, + planner_diagnostics=planner_diagnostics, + summary=summary, + signal_id=signal_id, + ) + except Exception as exc: + session_factory = getattr(cls, "session_factory", AsyncSessionLocal) + async with session_factory() as db: + service = cls(db) + run_service = AIOrchestrationRunService(db) + row = await run_service.get_run(tenant_id=tenant_id, trace_id=trace_id) + response_mode = str(row.response_mode or "fast") if row is not None else "fast" + existing_payload = dict(row.result_payload or {}) if row is not None and isinstance(row.result_payload, dict) else {} + source_payload = existing_payload.get("signal_source") if isinstance(existing_payload.get("signal_source"), dict) else {} + worker_results = ( + existing_payload.get("worker_executions") + if isinstance(existing_payload.get("worker_executions"), list) + else [] + ) + completed_keys = { + str(item.get("worker_role_key") or "").strip() + for item in worker_results + if isinstance(item, dict) and str(item.get("worker_role_key") or "").strip() + } + pending_workers = [key for key in worker_role_keys if key not in completed_keys] or list(worker_role_keys) + failed_result = service._build_orchestration_result( + trace_id=trace_id, + response_mode=response_mode, + planner_status=planner_status, + planner_diagnostics=planner_diagnostics, + summary=summary, + registry_result={ + "registry_id": source_payload.get("registry_id"), + "status": source_payload.get("status"), + }, + source_payload={ + "source_kind": source_payload.get("source_kind"), + "source_name": source_payload.get("source_name"), + "source_identifier": source_payload.get("source_identifier"), + }, + execution_result=openclaw_result, + worker_results=worker_results, + pending_workers=pending_workers, + status="failed", + run_status="failed", + ) + failed_result["background_error"] = str(exc)[:500] + if row is not None: + await run_service.update_run( + tenant_id=tenant_id, + trace_id=trace_id, + status="failed", + planner_status=planner_status, + execution_status=str(openclaw_result.get("status") or row.openclaw_status or ""), + worker_total=max(len(worker_role_keys), int(row.worker_total or 0)), + worker_completed=int(row.worker_completed or 0), + worker_failed=int(row.worker_failed or 0), + summary=summary, + planner_diagnostics=planner_diagnostics or {}, + result_payload=failed_result, + completed_at=_utc_now_naive(), + ) + await db.commit() + return failed_result + + @classmethod + async def _execute_workers_and_finalize( + cls, + *, + tenant_id: int, + trace_id: str, + project_payload: dict[str, Any], + signal_payload: dict[str, Any], + registry_result: dict[str, Any], + openclaw_result: dict[str, Any], + worker_role_keys: list[str], + operator_user_payload: Optional[dict[str, Any]], + planner_status: str, + planner_diagnostics: Optional[dict[str, Any]], + summary: str, + signal_id: Optional[int], + ) -> dict[str, Any]: + session_factory = getattr(cls, "session_factory", AsyncSessionLocal) + async with session_factory() as db: + service = cls(db) + bundle = await prompt_profile_service.get_profiles(db, tenant_id=tenant_id) + roles = bundle.get("roles") if isinstance(bundle.get("roles"), list) else [] + role_map = { + str(item.get("role_key") or "").strip(): item + for item in roles + if isinstance(item, dict) and str(item.get("role_key") or "").strip() + } + semaphore = asyncio.Semaphore(WORKER_MAX_CONCURRENCY) + + async def _run_role(role_key: str) -> dict[str, Any]: + role = role_map.get(role_key) + if not isinstance(role, dict): + return { + "worker_role_key": role_key, + "worker_role_name": role_key, + "status": "failed", + "reason": "role_not_configured", + } + try: + async with semaphore: + return await cls._execute_single_worker_role_isolated( + tenant_id=tenant_id, + project_payload=project_payload, + signal_payload=signal_payload, + registry_result=registry_result, + execution_result=openclaw_result, + role=dict(role), + trace_id=trace_id, + operator_user_payload=operator_user_payload, + response_mode="complete", + ) + except Exception as exc: + return { + "worker_role_key": role_key, + "worker_role_name": str(role.get("role_name") or role_key).strip()[:64] or role_key, + "status": "failed", + "reason": str(exc)[:300], + } + + tasks = [asyncio.create_task(_run_role(role_key)) for role_key in worker_role_keys] + worker_results: list[dict[str, Any]] = [] + for task in asyncio.as_completed(tasks): + result = await task + worker_results.append(result) + await service._update_run_after_worker( + tenant_id=tenant_id, + trace_id=trace_id, + summary=summary, + planner_status=planner_status, + planner_diagnostics=planner_diagnostics, + registry_result=registry_result, + openclaw_result=openclaw_result, + worker_role_keys=worker_role_keys, + worker_results=worker_results, + ) + + final_result = await service._finalize_orchestration_run( + tenant_id=tenant_id, + trace_id=trace_id, + signal_id=signal_id, + summary=summary, + planner_status=planner_status, + planner_diagnostics=planner_diagnostics, + registry_result=registry_result, + openclaw_result=openclaw_result, + worker_role_keys=worker_role_keys, + worker_results=worker_results, + ) + return final_result + diff --git a/backend/app/services/signal_source_orchestration/mixin_lookup_parse.py b/backend/app/services/signal_source_orchestration/mixin_lookup_parse.py new file mode 100644 index 00000000..df6cd1c9 --- /dev/null +++ b/backend/app/services/signal_source_orchestration/mixin_lookup_parse.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +from .shared import * + +class SignalSourceLookupParseMixin: + async def _resolve_operator_user( + self, + *, + tenant_id: int, + project: Project, + operator_user_id: Optional[int], + ) -> Optional[User]: + preferred_ids: list[int] = [] + if operator_user_id is not None: + preferred_ids.append(int(operator_user_id)) + if project.owner_id: + preferred_ids.append(int(project.owner_id)) + + for user_id in preferred_ids: + row = ( + await self.db.execute( + select(User).where( + and_( + User.tenant_id == tenant_id, + User.id == user_id, + User.is_active.is_(True), + ) + ) + ) + ).scalar_one_or_none() + if row is not None and str(row.role or "").strip().upper() in {"BOSS", "ADMIN"}: + return row + + return ( + await self.db.execute( + select(User).where( + and_( + User.tenant_id == tenant_id, + User.is_active.is_(True), + User.role.in_(["BOSS", "ADMIN"]), + ) + ).order_by(User.id.asc()).limit(1) + ) + ).scalar_one_or_none() + + @staticmethod + def _build_user_snapshot(user: Optional[User]) -> Optional[SimpleNamespace]: + if user is None: + return None + user_id = getattr(user, "id", None) + if user_id is None: + return None + return SimpleNamespace( + id=int(user_id), + name=str(getattr(user, "name", "") or "").strip(), + username=str(getattr(user, "username", "") or "").strip(), + role=str(getattr(user, "role", "") or "").strip(), + ) + + async def _load_project(self, *, tenant_id: int, project_id: int) -> Optional[Project]: + return ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == tenant_id, + Project.id == project_id, + ) + ) + ) + ).scalar_one_or_none() + + async def _load_signal(self, *, tenant_id: int, signal_id: int) -> Optional[Signal]: + return ( + await self.db.execute( + select(Signal).where( + and_( + Signal.tenant_id == tenant_id, + Signal.id == signal_id, + ) + ) + ) + ).scalar_one_or_none() + + async def _load_signal_source_registry( + self, + *, + tenant_id: int, + registry_id: int, + ) -> Optional[SignalSourceRegistry]: + return ( + await self.db.execute( + select(SignalSourceRegistry).where( + and_( + SignalSourceRegistry.tenant_id == tenant_id, + SignalSourceRegistry.id == registry_id, + ) + ) + ) + ).scalar_one_or_none() + + @staticmethod + def _build_ephemeral_signal(*, initiating_text: str) -> Signal: + signal = Signal( + tenant_id=1, + source="PMFlow", + content=initiating_text[:2000], + impact_score=0.7, + priority="medium", + processed=False, + title=initiating_text[:80] or "新增信号源", + signal_type="新需求", + summary=initiating_text[:300], + recommended_action="enter_only", + status="待处理", + ) + signal.id = 0 # type: ignore[assignment] + return signal + + @staticmethod + def _parse_json_object(raw: str) -> dict[str, Any]: + text = str(raw or "").strip() + if "```" in text: + match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S) + if match: + text = match.group(1) + if not text.startswith("{"): + match = re.search(r"(\{.*\})", text, re.S) + if match: + text = match.group(1) + parsed = json.loads(text) + if not isinstance(parsed, dict): + raise ValueError("json_object_required") + return parsed + diff --git a/backend/app/services/signal_source_orchestration/mixin_planning_locator_roles.py b/backend/app/services/signal_source_orchestration/mixin_planning_locator_roles.py new file mode 100644 index 00000000..1a346fbc --- /dev/null +++ b/backend/app/services/signal_source_orchestration/mixin_planning_locator_roles.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from .shared import * + +class SignalSourcePlanningLocatorRolesMixin: + @classmethod + def _extract_explicit_source_locator(cls, *, source_payload: dict[str, Any], text: str) -> str: + config = source_payload.get("config") if isinstance(source_payload.get("config"), dict) else {} + direct_candidates = [ + source_payload.get("repo"), + source_payload.get("repository"), + source_payload.get("url"), + source_payload.get("webhook_url"), + source_payload.get("channel_id"), + source_payload.get("chat_id"), + config.get("repo"), + config.get("repository"), + config.get("url"), + config.get("webhook_url"), + config.get("channel_id"), + config.get("chat_id"), + ] + for candidate in direct_candidates: + normalized = cls._normalize_locator_fragment(candidate) + if normalized: + return normalized + + url_match = re.search(r"https?://[^\s,。,;;))】\\]]+", str(text or ""), re.IGNORECASE) + if url_match: + return cls._normalize_locator_fragment(url_match.group(0)) + + repo_match = re.search(r"\b([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)\b", str(text or "")) + if repo_match: + return f"repo:{repo_match.group(1).lower()}" + + chat_match = re.search( + r"\b(?:chat_id|channel_id|群聊id|频道id)[::=\s]+([A-Za-z0-9_-]{6,})\b", + str(text or ""), + re.IGNORECASE, + ) + if chat_match: + return f"chat:{chat_match.group(1).lower()}" + return "" + + @staticmethod + def _normalize_locator_fragment(value: Any) -> str: + text = str(value or "").strip().strip("\"'“”") + if not text: + return "" + text = re.sub(r"\?.*$", "", text).rstrip("/") + return text[:140].lower() + + @classmethod + def _source_name_is_generic(cls, source_name: str, *, source_kind: str) -> bool: + normalized = re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "", str(source_name or "")).lower() + if not normalized: + return True + generic_tokens = { + re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "", source_kind).lower(), + re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "", f"{source_kind}信号源").lower(), + "项目id", + "给项目id", + "复合", + "复合信号源", + } + for kind, hints in SOURCE_KIND_HINTS: + if kind != source_kind: + continue + generic_tokens.update( + re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "", str(token)).lower() + for token in hints + ) + break + return normalized in generic_tokens + + @staticmethod + def _normalize_worker_roles(raw_roles: Any) -> list[str]: + roles: list[str] = [] + if isinstance(raw_roles, list): + for item in raw_roles: + key = str(item or "").strip() + if key in DEFAULT_WORKER_ROLE_KEYS and key not in roles: + roles.append(key) + if roles: + return roles[: len(DEFAULT_WORKER_ROLE_KEYS)] + return list(DEFAULT_WORKER_ROLE_KEYS) + + @staticmethod + def _build_execution_instruction(source: dict[str, Any], initiating_text: str) -> str: + return ( + f"围绕新增信号源“{source.get('source_name') or '未命名信号源'}”完成接入准备。" + "请创建必要任务、分配合适负责人,并通知相关成员。" + f"补充上下文:{str(initiating_text or '').strip()[:240]}" + ) + diff --git a/backend/app/services/signal_source_orchestration/mixin_planning_onboard.py b/backend/app/services/signal_source_orchestration/mixin_planning_onboard.py new file mode 100644 index 00000000..6597f163 --- /dev/null +++ b/backend/app/services/signal_source_orchestration/mixin_planning_onboard.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +from .shared import * + +class SignalSourcePlanningOnboardMixin: + async def list_project_sources( + self, + *, + tenant_id: int, + project_id: int, + limit: int = 20, + ) -> list[dict[str, Any]]: + rows = ( + await self.db.execute( + select(SignalSourceRegistry).where( + and_( + SignalSourceRegistry.tenant_id == tenant_id, + SignalSourceRegistry.project_id == project_id, + ) + ).order_by(SignalSourceRegistry.updated_at.desc()).limit(max(1, min(limit, 100))) + ) + ).scalars().all() + data: list[dict[str, Any]] = [] + for row in rows: + executions = ( + await self.db.execute( + select(AIWorkerExecution).where( + and_( + AIWorkerExecution.tenant_id == tenant_id, + AIWorkerExecution.signal_source_registry_id == int(row.id), + ) + ).order_by(AIWorkerExecution.created_at.desc()).limit(6) + ) + ).scalars().all() + data.append( + { + "id": int(row.id), + "project_id": int(row.project_id), + "signal_id": int(row.signal_id) if row.signal_id else None, + "source_kind": str(row.source_kind or ""), + "source_name": str(row.source_name or ""), + "source_identifier": str(row.source_identifier or ""), + "requested_via": str(row.requested_via or ""), + "status": str(row.status or ""), + "onboarding_status": str(row.onboarding_status or ""), + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + "worker_executions": [ + { + "id": int(item.id), + "worker_role_key": str(item.worker_role_key or ""), + "worker_role_name": str(item.worker_role_name or ""), + "status": str(item.status or ""), + "summary": str(item.summary or ""), + "completed_at": item.completed_at.isoformat() if item.completed_at else None, + } + for item in executions + ], + } + ) + return data + + async def _plan_onboarding( + self, + *, + tenant_id: int, + project: Project, + signal: Signal, + initiating_text: str, + requested_via: str, + response_mode: str, + ) -> tuple[dict[str, Any], str, dict[str, Any]]: + request_payload = { + "requested_via": requested_via, + "initiating_text": initiating_text[:600], + } + project_payload = { + "project_id": int(project.id), + "title": str(project.title or project.name or "").strip(), + "description": str(project.description or "").strip()[:400], + } + signal_payload = { + "signal_id": int(signal.id) if getattr(signal, "id", None) is not None else None, + "title": str(signal.title or "").strip(), + "summary": str(signal.summary or "").strip()[:400], + "signal_type": str(signal.signal_type or "").strip(), + "recommended_action": str(signal.recommended_action or "").strip(), + } + prompt = PROMPT_SIGNAL_SOURCE_ONBOARDING.format( + request_json=json.dumps(request_payload, ensure_ascii=False), + project_json=json.dumps(project_payload, ensure_ascii=False), + signal_json=json.dumps(signal_payload, ensure_ascii=False), + ) + try: + extraction = await llm_service.generate_structured_object( + prompt, + db=self.db, + tenant_id=tenant_id, + function_name="signal_source_onboarding_plan", + use_prompt_profile=False, + max_tokens=1800, + response_mode="complete" if response_mode == "fast" else response_mode, + ) + diagnostics = self._build_planner_diagnostics(extraction) + if extraction.payload is None: + raise ValueError(f"signal_source_onboarding_plan_failed:{str(extraction.error_code or 'structured_empty')}") + plan = extraction.payload + normalized = self._normalize_onboarding_plan( + plan=plan, + initiating_text=initiating_text, + requested_via=requested_via, + ) + return normalized, "planned", diagnostics + except Exception as exc: + raise RuntimeError(f"signal_source_onboarding_plan_failed:{str(exc)}") from exc + + def _normalize_onboarding_plan( + self, + *, + plan: dict[str, Any], + initiating_text: str, + requested_via: str, + ) -> dict[str, Any]: + should_onboard = bool(plan.get("should_onboard")) + summary = str(plan.get("summary") or "").strip()[:300] + source = plan.get("source") if isinstance(plan.get("source"), dict) else {} + worker_roles = self._normalize_worker_roles(plan.get("worker_roles")) + if not should_onboard: + return { + "should_onboard": False, + "summary": summary or "未识别为新增信号源请求。", + "source": None, + "worker_roles": [], + "execution_instruction": "", + } + normalized_source = self._normalize_source_payload( + source_payload=source, + fallback_text=initiating_text, + requested_via=requested_via, + ) + return { + "should_onboard": True, + "summary": summary or f"准备接入信号源 {normalized_source['source_name']}。", + "source": normalized_source, + "worker_roles": worker_roles, + "execution_instruction": ( + str(plan.get("execution_instruction") or "").strip() + or self._build_execution_instruction(normalized_source, initiating_text) + )[:600], + } + + def _normalize_source_payload( + self, + *, + source_payload: dict[str, Any], + fallback_text: str, + requested_via: str, + ) -> dict[str, Any]: + source_kind = self._normalize_source_kind(source_payload.get("source_kind"), text=fallback_text, requested_via=requested_via) + source_name = self._clean_source_name_candidate(str(source_payload.get("source_name") or "").strip()) + if not source_name: + source_name = self._extract_source_name(fallback_text, default=f"{source_kind}信号源") + source_name = source_name[:MAX_SOURCE_NAME_LEN] or f"{source_kind}信号源" + source_identifier = str(source_payload.get("source_identifier") or "").strip() + if source_identifier: + existing_kind = source_identifier.split(":", 1)[0].strip().lower() + if existing_kind and existing_kind != source_kind: + source_identifier = "" + if not source_identifier: + source_identifier = self._build_source_identifier( + source_kind=source_kind, + source_name=source_name, + text=fallback_text, + source_payload=source_payload, + ) + description = str(source_payload.get("description") or "").strip()[:1000] or fallback_text[:300] + config = source_payload.get("config") if isinstance(source_payload.get("config"), dict) else {} + return { + "source_kind": source_kind, + "source_name": source_name, + "source_identifier": source_identifier[:MAX_SOURCE_IDENTIFIER_LEN], + "description": description, + "config": config, + } + + @staticmethod + def _normalize_source_kind(raw_kind: Any, *, text: str, requested_via: str) -> str: + cleaned = str(raw_kind or "").strip().lower() + for kind, hints in SOURCE_KIND_HINTS: + if cleaned == kind: + return kind + if any(token in cleaned for token in hints): + return kind + text_lower = str(text or "").lower() + for kind, hints in SOURCE_KIND_HINTS: + if any(token in text_lower for token in hints): + return kind + via = str(requested_via or "").strip().lower() + if via in {"feishu", "pmflow", "github", "browser"}: + return via + return "custom" + + @classmethod + def _extract_source_name(cls, text: str, *, default: str) -> str: + cleaned = re.sub(r"\s+", " ", str(text or "")).strip() + if not cleaned: + return default + repo_match = re.search(r"\b([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)\b", cleaned) + if repo_match: + return repo_match.group(1)[:MAX_SOURCE_NAME_LEN] + quoted_match = re.search(r"[\"'“](.+?)[\"'”]", cleaned) + if quoted_match: + candidate = cls._clean_source_name_candidate(quoted_match.group(1)) + if candidate: + return candidate[:MAX_SOURCE_NAME_LEN] + for pattern in ( + r"(?:新增|增加|添加|接入|配置|注册|建立)(?:一个|1个|一条|一组|新的|一个新的)?\s*(.+?)(?:信号源|来源|监控源)", + r"(?:信号源|来源|监控源)(?:为|是|叫|命名为)?\s*(.+)", + ): + match = re.search(pattern, cleaned, re.IGNORECASE) + if match: + value = cls._clean_source_name_candidate(match.group(1)) + if value: + return value[:MAX_SOURCE_NAME_LEN] + return cls._clean_source_name_candidate(cleaned)[:MAX_SOURCE_NAME_LEN] or default + + @classmethod + def _build_source_identifier( + cls, + *, + source_kind: str, + source_name: str, + text: str, + source_payload: dict[str, Any], + ) -> str: + normalized_text = re.sub(r"\s+", " ", str(text or "")).strip() + explicit_locator = cls._extract_explicit_source_locator(source_payload=source_payload, text=text) + if explicit_locator: + basis = re.sub(r"[^a-zA-Z0-9:/._-]+", "-", explicit_locator).strip("-").lower() + return f"{source_kind}:{basis[:120]}" + if cls._source_name_is_generic(source_name, source_kind=source_kind): + digest = hashlib.sha1(f"{source_kind}:{normalized_text}".encode("utf-8")).hexdigest()[:16] + return f"{source_kind}:req-{digest}" + basis = re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "-", source_name).strip("-").lower() + if basis: + return f"{source_kind}:{basis[:120]}" + digest = hashlib.sha1(f"{source_kind}:{normalized_text}".encode("utf-8")).hexdigest()[:16] + return f"{source_kind}:{digest}" + + @staticmethod + def _clean_source_name_candidate(value: str) -> str: + cleaned = re.sub(r"\s+", " ", str(value or "")).strip() + cleaned = re.sub(r"^(?:给)?项目id[::]?\s*\d+\s*", "", cleaned, flags=re.IGNORECASE) + cleaned = re.sub(r"[,。,.;;::].*$", "", cleaned) + cleaned = cleaned.strip(" []【】()()::\"'“”") + cleaned = re.sub(r"^(?:请|帮我|帮忙|把|将|需要|想要|用于|用来)\s*", "", cleaned, flags=re.IGNORECASE) + cleaned = re.sub( + r"^(?:一个|1个|一条|一组|一个新的|新的|新增的)\s+", + "", + cleaned, + flags=re.IGNORECASE, + ) + cleaned = re.sub(r"(?:信号源|来源|监控源|source|channel)$", "", cleaned, flags=re.IGNORECASE).strip() + if cleaned.lower() == "github": + return "GitHub" + if cleaned.lower() == "feishu": + return "Feishu" + normalized = re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "", cleaned).lower() + if normalized in {"", "项目id", "给项目id", "复合", "复合信号源", "信号源", "来源", "监控源"}: + return "" + return cleaned + diff --git a/backend/app/services/signal_source_orchestration/mixin_registry_execute.py b/backend/app/services/signal_source_orchestration/mixin_registry_execute.py new file mode 100644 index 00000000..314fd684 --- /dev/null +++ b/backend/app/services/signal_source_orchestration/mixin_registry_execute.py @@ -0,0 +1,297 @@ +from __future__ import annotations + +from .shared import * + +class SignalSourceRegistryExecuteMixin: + async def _create_or_reuse_registry( + self, + *, + tenant_id: int, + project_id: int, + signal_id: Optional[int], + operator_user_id: Optional[int], + requested_via: str, + source_payload: dict[str, Any], + initiating_text: str, + dry_run: bool, + ) -> dict[str, Any]: + normalized_source = self._normalize_source_payload( + source_payload=source_payload, + fallback_text=initiating_text, + requested_via=requested_via, + ) + if dry_run: + return { + "status": "planned", + "registry_id": None, + "source_kind": normalized_source.get("source_kind"), + "source_name": normalized_source.get("source_name"), + "source_identifier": normalized_source.get("source_identifier"), + "description": str(normalized_source.get("description") or "").strip(), + } + + row = SignalSourceRegistry( + tenant_id=tenant_id, + project_id=project_id, + signal_id=signal_id, + created_by_user_id=operator_user_id, + source_kind=str(normalized_source["source_kind"]), + source_name=str(normalized_source["source_name"]), + source_identifier=str(normalized_source["source_identifier"]), + description=str(normalized_source.get("description") or "").strip() or None, + requested_via=str(requested_via or "system").strip()[:40] or "system", + status="pending", + onboarding_status="pending", + config=normalized_source.get("config") if isinstance(normalized_source.get("config"), dict) else {}, + raw_context={ + "initiating_text": str(initiating_text or "")[:2000], + "requested_via": requested_via, + }, + last_signal_at=_utc_now_naive(), + ) + try: + # 只回滚本次插入尝试,避免误回滚外层事务里已创建的 signal 记录。 + async with self.db.begin_nested(): + self.db.add(row) + await self.db.flush() + await self.db.refresh(row) + return { + "status": "created", + "registry_id": int(row.id), + "source_kind": str(row.source_kind or ""), + "source_name": str(row.source_name or ""), + "source_identifier": str(row.source_identifier or ""), + "description": str(row.description or ""), + } + except IntegrityError: + if row in self.db: + self.db.expunge(row) + existing = ( + await self.db.execute( + select(SignalSourceRegistry).where( + and_( + SignalSourceRegistry.tenant_id == tenant_id, + SignalSourceRegistry.project_id == project_id, + SignalSourceRegistry.source_kind == str(normalized_source["source_kind"]), + SignalSourceRegistry.source_identifier == str(normalized_source["source_identifier"]), + ) + ) + ) + ).scalar_one_or_none() + if existing is None: + raise + existing.signal_id = signal_id + if str(normalized_source.get("source_name") or "").strip(): + existing.source_name = str(normalized_source.get("source_name") or "").strip()[:MAX_SOURCE_NAME_LEN] + existing.description = str(normalized_source.get("description") or existing.description or "").strip() or None + existing.config = normalized_source.get("config") if isinstance(normalized_source.get("config"), dict) else existing.config + existing.raw_context = { + "initiating_text": str(initiating_text or "")[:2000], + "requested_via": requested_via, + } + if not isinstance(existing.config, dict) or not existing.config: + existing.status = "pending" + existing.onboarding_status = "pending" + existing.last_signal_at = _utc_now_naive() + await self.db.flush() + return { + "status": "reused", + "registry_id": int(existing.id), + "source_kind": str(existing.source_kind or ""), + "source_name": str(existing.source_name or ""), + "source_identifier": str(existing.source_identifier or ""), + "description": str(existing.description or ""), + } + + async def _execute_planned_operations( + self, + *, + tenant_id: int, + operator_user: Optional[User], + project: Project, + instruction: str, + dry_run: bool, + response_mode: str, + ) -> dict[str, Any]: + _ = tenant_id, operator_user, project, instruction, dry_run, response_mode + return { + "status": "skipped", + "reason": "execution_removed", + } + + async def _execute_worker_roles( + self, + *, + tenant_id: int, + project: Project, + signal: Signal, + registry_result: dict[str, Any], + openclaw_result: dict[str, Any], + worker_role_keys: list[str], + trace_id: str, + operator_user: Optional[User], + dry_run: bool, + response_mode: str, + ) -> list[dict[str, Any]]: + bundle = await prompt_profile_service.get_profiles(self.db, tenant_id=tenant_id) + roles = bundle.get("roles") if isinstance(bundle.get("roles"), list) else [] + role_map = { + str(item.get("role_key") or "").strip(): item + for item in roles + if isinstance(item, dict) and str(item.get("role_key") or "").strip() + } + + if dry_run: + return self._build_planned_worker_results(worker_role_keys, role_map=role_map) + + semaphore = asyncio.Semaphore(WORKER_MAX_CONCURRENCY) + + async def _run_role(role_key: str) -> dict[str, Any]: + role = role_map.get(role_key) + if not isinstance(role, dict): + return { + "worker_role_key": role_key, + "worker_role_name": role_key, + "status": "failed", + "reason": "role_not_configured", + } + try: + async with semaphore: + return await type(self)._execute_single_worker_role_isolated( + tenant_id=tenant_id, + project_payload=self._serialize_snapshot(project), + signal_payload=self._serialize_snapshot(signal), + registry_result=dict(registry_result), + execution_result=dict(openclaw_result), + role=dict(role), + trace_id=trace_id, + operator_user_payload=self._serialize_snapshot(operator_user), + response_mode=response_mode, + ) + except Exception as exc: + return { + "worker_role_key": role_key, + "worker_role_name": str(role.get("role_name") or role_key).strip()[:64] or role_key, + "status": "failed", + "reason": str(exc)[:300], + } + + tasks = [asyncio.create_task(_run_role(role_key)) for role_key in worker_role_keys] + return list(await asyncio.gather(*tasks, return_exceptions=False)) + + async def _execute_single_worker_role( + self, + *, + tenant_id: int, + project: Project, + signal: Signal, + registry_result: dict[str, Any], + openclaw_result: dict[str, Any], + role: dict[str, Any], + trace_id: str, + operator_user: Optional[User], + dry_run: bool, + response_mode: str, + ) -> dict[str, Any]: + role_key = str(role.get("role_key") or "").strip() + role_name = str(role.get("role_name") or role_key).strip()[:64] or role_key + registry_id = registry_result.get("registry_id") + source_payload = { + "registry_id": int(registry_id) if registry_id is not None else None, + "source_kind": str(registry_result.get("source_kind") or ""), + "source_name": str(registry_result.get("source_name") or ""), + "source_identifier": str(registry_result.get("source_identifier") or ""), + "description": str(registry_result.get("description") or ""), + } + + execution_row: Optional[AIWorkerExecution] = None + if not dry_run: + execution_row = AIWorkerExecution( + tenant_id=tenant_id, + project_id=int(project.id), + signal_id=int(signal.id) if getattr(signal, "id", None) is not None else None, + signal_source_registry_id=int(registry_id) if registry_id is not None else None, + operator_user_id=int(operator_user.id) if operator_user is not None else None, + trace_id=trace_id, + operation_trace_id=( + str((openclaw_result.get("result") or {}).get("trace_id") or "").strip() + if isinstance(openclaw_result.get("result"), dict) + else None + ), + execution_type="signal_source_onboarding", + worker_role_key=role_key, + worker_role_name=role_name, + status="running", + input_payload=source_payload, + started_at=_utc_now_naive(), + ) + self.db.add(execution_row) + await self.db.flush() + # 先提交 running 状态,避免事务跨 LLM 调用长期持有写锁。 + await self.db.commit() + + prompt = self._build_worker_prompt( + role=role, + project=project, + signal=signal, + source_payload=source_payload, + execution_result=openclaw_result, + ) + if dry_run: + return { + "worker_role_key": role_key, + "worker_role_name": role_name, + "status": "planned", + } + try: + extraction = await llm_service.generate_structured_object( + prompt, + db=self.db, + tenant_id=tenant_id, + function_name="signal_source_ai_worker_execution", + use_prompt_profile=False, + max_tokens=420, + response_mode="complete" if response_mode == "fast" else response_mode, + ) + if extraction.payload is None: + raise ValueError(str(extraction.error_code or "structured_empty")) + parsed = self._normalize_worker_output( + extraction.payload, + role_name=role_name, + source_name=source_payload.get("source_name"), + ) + if execution_row is not None: + execution_row.status = "completed" + execution_row.summary = str(parsed.get("summary") or "").strip()[:1000] + execution_row.output_payload = parsed + execution_row.completed_at = _utc_now_naive() + await self.db.commit() + return { + "worker_role_key": role_key, + "worker_role_name": role_name, + "status": "completed", + "summary": str(parsed.get("summary") or "").strip(), + "execution_id": int(execution_row.id) if execution_row is not None else None, + "used_fallback": False, + } + except Exception as exc: + if execution_row is not None: + execution_row.status = "failed" + execution_row.summary = str(exc)[:1000] + execution_row.output_payload = { + "error": str(exc)[:1000], + "used_fallback": False, + } + execution_row.error_message = str(exc)[:1000] + execution_row.completed_at = _utc_now_naive() + await self.db.commit() + return { + "worker_role_key": role_key, + "worker_role_name": role_name, + "status": "failed", + "reason": str(exc), + "summary": str(exc)[:200], + "used_fallback": False, + "execution_id": int(execution_row.id) if execution_row is not None else None, + } + diff --git a/backend/app/services/signal_source_orchestration/mixin_result_resolution.py b/backend/app/services/signal_source_orchestration/mixin_result_resolution.py new file mode 100644 index 00000000..3872df84 --- /dev/null +++ b/backend/app/services/signal_source_orchestration/mixin_result_resolution.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +from .shared import * + +class SignalSourceResultResolutionMixin: + @staticmethod + def _serialize_snapshot(value: Any) -> Optional[dict[str, Any]]: + if value is None: + return None + if isinstance(value, dict): + return dict(value) + data = getattr(value, "__dict__", None) + if isinstance(data, dict): + return {str(key): data[key] for key in data} + return None + + @staticmethod + def _build_planner_diagnostics(extraction: Any) -> dict[str, Any]: + return { + "used_fallback": False, + "error_code": extraction.error_code, + "source_field": extraction.source_field or None, + "provider": str(extraction.provider or ""), + "model": str(extraction.model or ""), + } + + def _build_planned_worker_results( + self, + worker_role_keys: list[str], + *, + role_map: Optional[dict[str, dict[str, Any]]] = None, + ) -> list[dict[str, Any]]: + results: list[dict[str, Any]] = [] + for role_key in worker_role_keys: + role = role_map.get(role_key) if isinstance(role_map, dict) else None + role_name = str((role or {}).get("role_name") or role_key).strip()[:64] or role_key + results.append( + { + "worker_role_key": role_key, + "worker_role_name": role_name, + "status": "planned", + } + ) + return results + + @staticmethod + def _build_signal_source_snapshot(registry_result: dict[str, Any], source_payload: dict[str, Any]) -> dict[str, Any]: + return { + "registry_id": registry_result.get("registry_id"), + "status": registry_result.get("status"), + "onboarding_status": registry_result.get("onboarding_status"), + "source_kind": source_payload.get("source_kind"), + "source_name": source_payload.get("source_name"), + "source_identifier": source_payload.get("source_identifier"), + "description": source_payload.get("description"), + "config": source_payload.get("config") if isinstance(source_payload.get("config"), dict) else {}, + } + + def _build_orchestration_result( + self, + *, + trace_id: str, + response_mode: str, + planner_status: str, + planner_diagnostics: Optional[dict[str, Any]], + summary: str, + registry_result: dict[str, Any], + source_payload: dict[str, Any], + execution_result: dict[str, Any], + worker_results: list[dict[str, Any]], + pending_workers: list[str], + status: str, + run_status: str, + ) -> dict[str, Any]: + return { + "trace_id": trace_id, + "status": status, + "run_status": run_status, + "response_mode": response_mode, + "planner_status": planner_status, + "planner_diagnostics": planner_diagnostics, + "summary": summary, + "pending_workers": list(pending_workers), + "signal_source": self._build_signal_source_snapshot(registry_result, source_payload), + "execution": execution_result, + "worker_executions": list(worker_results), + } + + @classmethod + def _has_actionable_source_config(cls, *, source_payload: dict[str, Any]) -> bool: + if not isinstance(source_payload, dict): + return False + config = source_payload.get("config") if isinstance(source_payload.get("config"), dict) else {} + if config: + return True + locator = cls._extract_explicit_source_locator( + source_payload=source_payload, + text=str(source_payload.get("description") or ""), + ) + if locator: + return True + source_kind = str(source_payload.get("source_kind") or "").strip().lower() + source_identifier = str(source_payload.get("source_identifier") or "").strip().lower() + if source_kind in {"github", "feishu", "webhook", "url"} and source_identifier and ":req-" not in source_identifier: + return True + if source_kind in {"pmflow", "custom", "browser"} and ( + not source_identifier or source_identifier.startswith(f"{source_kind}:req-") + ): + return False + return False + + @staticmethod + def _has_non_fallback_worker_result(worker_results: list[dict[str, Any]]) -> bool: + for item in worker_results: + if not isinstance(item, dict): + continue + status = str(item.get("status") or "").strip().lower() + if status != "completed": + continue + if bool(item.get("used_fallback")): + continue + return True + return False + + @staticmethod + def _extract_openclaw_execution_rows(openclaw_result: dict[str, Any]) -> list[dict[str, Any]]: + if not isinstance(openclaw_result, dict): + return [] + result = openclaw_result.get("result") if isinstance(openclaw_result.get("result"), dict) else openclaw_result + execution = result.get("execution") if isinstance(result, dict) and isinstance(result.get("execution"), dict) else {} + rows = execution.get("results") if isinstance(execution.get("results"), list) else [] + return [row for row in rows if isinstance(row, dict)] + + @classmethod + def _has_material_openclaw_execution(cls, openclaw_result: dict[str, Any]) -> bool: + for row in cls._extract_openclaw_execution_rows(openclaw_result): + status = str(row.get("status") or "").strip().lower() + if status not in {"applied", "skipped"}: + continue + operation_code = str(row.get("operation_code") or "").strip().lower() + if operation_code == "notification.create": + continue + return True + return False + + @classmethod + def _resolve_registry_activation( + cls, + *, + source_payload: dict[str, Any], + openclaw_result: dict[str, Any], + worker_results: list[dict[str, Any]], + ) -> tuple[str, str]: + actionable_config = cls._has_actionable_source_config(source_payload=source_payload) + has_real_worker = cls._has_non_fallback_worker_result(worker_results) + has_material_openclaw = cls._has_material_openclaw_execution(openclaw_result) + if actionable_config and has_real_worker and has_material_openclaw: + return "active", "completed" + return "pending", "pending_manual" + + @staticmethod + def _resolve_orchestration_status(*, openclaw_status: str, worker_results: list[dict[str, Any]]) -> str: + normalized_openclaw = str(openclaw_status or "").strip().lower() + worker_statuses = [str(item.get("status") or "").strip().lower() for item in worker_results] + has_worker_completed = any(status == "completed" for status in worker_statuses) + has_worker_failed = any(status == "failed" for status in worker_statuses) + if normalized_openclaw == "failed" and not has_worker_completed: + return "failed" + if has_worker_failed or normalized_openclaw == "failed": + return "partial" + if has_worker_completed or normalized_openclaw == "completed": + return "completed" + return "failed" + + @staticmethod + def _worker_counts(worker_results: list[dict[str, Any]]) -> tuple[int, int]: + completed = 0 + failed = 0 + for item in worker_results: + status = str(item.get("status") or "").strip().lower() + if status == "failed": + failed += 1 + continue + if status in {"completed", "planned", "skipped"}: + completed += 1 + return completed, failed + diff --git a/backend/app/services/signal_source_orchestration/mixin_result_run_updates.py b/backend/app/services/signal_source_orchestration/mixin_result_run_updates.py new file mode 100644 index 00000000..8f64b8c3 --- /dev/null +++ b/backend/app/services/signal_source_orchestration/mixin_result_run_updates.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +from .shared import * + +class SignalSourceResultRunUpdatesMixin: + async def _update_run_after_worker( + self, + *, + tenant_id: int, + trace_id: str, + summary: str, + planner_status: str, + planner_diagnostics: Optional[dict[str, Any]], + registry_result: dict[str, Any], + openclaw_result: dict[str, Any], + worker_role_keys: list[str], + worker_results: list[dict[str, Any]], + ) -> None: + run_service = AIOrchestrationRunService(self.db) + row = await run_service.get_run(tenant_id=tenant_id, trace_id=trace_id) + if row is None: + return + source_payload = row.result_payload.get("signal_source") if isinstance(row.result_payload, dict) else {} + completed_count, failed_count = self._worker_counts(worker_results) + completed_keys = { + str(item.get("worker_role_key") or "").strip() + for item in worker_results + if str(item.get("worker_role_key") or "").strip() + } + pending_workers = [key for key in worker_role_keys if key not in completed_keys] + status = "partial" if pending_workers else self._resolve_orchestration_status( + openclaw_status=str(openclaw_result.get("status") or ""), + worker_results=worker_results, + ) + result_payload = self._build_orchestration_result( + trace_id=trace_id, + response_mode=str(row.response_mode or "fast"), + planner_status=planner_status, + planner_diagnostics=planner_diagnostics, + summary=summary, + registry_result={ + "registry_id": (source_payload or {}).get("registry_id"), + "status": (source_payload or {}).get("status"), + }, + source_payload={ + "source_kind": (source_payload or {}).get("source_kind"), + "source_name": (source_payload or {}).get("source_name"), + "source_identifier": (source_payload or {}).get("source_identifier"), + }, + execution_result=openclaw_result, + worker_results=worker_results, + pending_workers=pending_workers, + status=status, + run_status="running" if pending_workers else status, + ) + await run_service.update_run( + tenant_id=tenant_id, + trace_id=trace_id, + status="running" if pending_workers else status, + planner_status=planner_status, + execution_status=str(openclaw_result.get("status") or ""), + worker_total=len(worker_role_keys), + worker_completed=completed_count, + worker_failed=failed_count, + summary=summary, + planner_diagnostics=planner_diagnostics or {}, + result_payload=result_payload, + completed_at=None if pending_workers else _utc_now_naive(), + ) + await self.db.commit() + + async def _finalize_orchestration_run( + self, + *, + tenant_id: int, + trace_id: str, + signal_id: Optional[int], + summary: str, + planner_status: str, + planner_diagnostics: Optional[dict[str, Any]], + registry_result: dict[str, Any], + openclaw_result: dict[str, Any], + worker_role_keys: list[str], + worker_results: list[dict[str, Any]], + ) -> dict[str, Any]: + run_service = AIOrchestrationRunService(self.db) + row = await run_service.get_run(tenant_id=tenant_id, trace_id=trace_id) + response_mode = str(row.response_mode or "fast") if row is not None else "fast" + source_payload = row.result_payload.get("signal_source") if row is not None and isinstance(row.result_payload, dict) else {} + final_status = self._resolve_orchestration_status( + openclaw_status=str(openclaw_result.get("status") or ""), + worker_results=worker_results, + ) + registry_status, onboarding_status = self._resolve_registry_activation( + source_payload=source_payload if isinstance(source_payload, dict) else {}, + openclaw_result=openclaw_result, + worker_results=worker_results, + ) + if final_status == "completed" and onboarding_status != "completed": + final_status = "pending_manual" + completed_count, failed_count = self._worker_counts(worker_results) + final_result = self._build_orchestration_result( + trace_id=trace_id, + response_mode=response_mode, + planner_status=planner_status, + planner_diagnostics=planner_diagnostics, + summary=summary, + registry_result={ + "registry_id": (source_payload or {}).get("registry_id"), + "status": registry_status, + "onboarding_status": onboarding_status, + }, + source_payload={ + "source_kind": (source_payload or {}).get("source_kind"), + "source_name": (source_payload or {}).get("source_name"), + "source_identifier": (source_payload or {}).get("source_identifier"), + }, + execution_result=openclaw_result, + worker_results=worker_results, + pending_workers=[], + status=final_status, + run_status=final_status, + ) + await run_service.update_run( + tenant_id=tenant_id, + trace_id=trace_id, + status=final_status, + planner_status=planner_status, + execution_status=str(openclaw_result.get("status") or ""), + worker_total=len(worker_role_keys), + worker_completed=completed_count, + worker_failed=failed_count, + summary=summary, + planner_diagnostics=planner_diagnostics or {}, + result_payload=final_result, + completed_at=_utc_now_naive(), + ) + if registry_result.get("registry_id") is not None: + registry_row = await self._load_signal_source_registry( + tenant_id=tenant_id, + registry_id=int(registry_result["registry_id"]), + ) + if registry_row is not None: + registry_row.last_signal_at = _utc_now_naive() + registry_row.last_worker_run_at = _utc_now_naive() + registry_row.status = registry_status + registry_row.onboarding_status = onboarding_status + if signal_id: + persisted_signal = await self._load_signal(tenant_id=tenant_id, signal_id=int(signal_id)) + if persisted_signal is not None: + await self._persist_signal_orchestration_result(signal=persisted_signal, orchestration_result=final_result) + await self.db.commit() + return final_result + diff --git a/backend/app/services/signal_source_orchestration/mixin_worker_prompt_persist.py b/backend/app/services/signal_source_orchestration/mixin_worker_prompt_persist.py new file mode 100644 index 00000000..9f2cb449 --- /dev/null +++ b/backend/app/services/signal_source_orchestration/mixin_worker_prompt_persist.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from .shared import * + +class SignalSourceWorkerPromptPersistMixin: + @staticmethod + def _build_worker_prompt( + *, + role: dict[str, Any], + project: Project, + signal: Signal, + source_payload: dict[str, Any], + openclaw_result: dict[str, Any], + ) -> str: + role_payload = { + "role_key": str(role.get("role_key") or "").strip(), + "role_name": str(role.get("role_name") or "").strip(), + "role_description": str(role.get("role_description") or "").strip(), + "system_prompt": str(role.get("system_prompt") or "").strip(), + } + project_payload = { + "project_id": int(project.id), + "title": str(project.title or project.name or "").strip(), + "description": str(project.description or "").strip()[:1000], + } + signal_payload = { + "signal_id": int(signal.id) if getattr(signal, "id", None) is not None else None, + "title": str(signal.title or "").strip(), + "summary": str(signal.summary or signal.content or "").strip()[:1200], + } + commander_payload = openclaw_result.get("result") if isinstance(openclaw_result.get("result"), dict) else openclaw_result + embedded_prompt = PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION.format( + role_json=json.dumps(role_payload, ensure_ascii=False), + project_json=json.dumps(project_payload, ensure_ascii=False), + source_json=json.dumps(source_payload, ensure_ascii=False), + signal_json=json.dumps(signal_payload, ensure_ascii=False), + commander_json=json.dumps(commander_payload or {}, ensure_ascii=False), + ) + system_prompt = str(role.get("system_prompt") or "").strip() + if system_prompt: + return f"[Role System Prompt]\n{system_prompt}\n\n{embedded_prompt}" + return embedded_prompt + + @staticmethod + def _normalize_worker_output(parsed: dict[str, Any], *, role_name: str, source_name: Any) -> dict[str, Any]: + deliverables = parsed.get("deliverables") if isinstance(parsed.get("deliverables"), list) else [] + risks = parsed.get("risks") if isinstance(parsed.get("risks"), list) else [] + next_actions = parsed.get("next_actions") if isinstance(parsed.get("next_actions"), list) else [] + return { + "summary": str(parsed.get("summary") or f"{role_name} 已完成 {source_name} 的接入分析。").strip()[:1000], + "deliverables": [str(item).strip()[:200] for item in deliverables if str(item).strip()][:4], + "risks": [str(item).strip()[:200] for item in risks if str(item).strip()][:4], + "next_actions": [str(item).strip()[:200] for item in next_actions if str(item).strip()][:4], + "used_fallback": False, + } + + async def _persist_signal_orchestration_result( + self, + *, + signal: Signal, + orchestration_result: dict[str, Any], + ) -> None: + strategic_payload: dict[str, Any] = {} + raw_strategic = signal.strategic_analysis + if isinstance(raw_strategic, dict): + strategic_payload = dict(raw_strategic) + elif isinstance(raw_strategic, str) and raw_strategic.strip(): + try: + parsed = json.loads(raw_strategic) + except Exception: + parsed = {} + if isinstance(parsed, dict): + strategic_payload = parsed + strategic_payload["signal_source_orchestration"] = orchestration_result + signal.strategic_analysis = json.dumps(strategic_payload, ensure_ascii=False) + orchestration_status = str(orchestration_result.get("status") or "").lower() + signal.processed = orchestration_status in {"completed", "partial"} + signal.status = self._map_orchestration_status_to_signal_status(orchestration_status) + signal.tasks_generated = json.dumps( + self._extract_task_operations(orchestration_result.get("execution")), + ensure_ascii=False, + ) + await self.db.commit() + + @staticmethod + def _extract_task_operations(openclaw_payload: Any) -> list[dict[str, Any]]: + if not isinstance(openclaw_payload, dict): + return [] + result = openclaw_payload.get("result") if isinstance(openclaw_payload.get("result"), dict) else openclaw_payload + execution = result.get("execution") if isinstance(result, dict) and isinstance(result.get("execution"), dict) else {} + rows = execution.get("results") if isinstance(execution.get("results"), list) else [] + tasks: list[dict[str, Any]] = [] + for row in rows: + if not isinstance(row, dict): + continue + operation_code = str(row.get("operation_code") or "").strip() + payload = row.get("payload") if isinstance(row.get("payload"), dict) else {} + if operation_code not in {"task.create", "task.assign", "task.prioritize", "task.status_transition"}: + continue + tasks.append( + { + "operation_code": operation_code, + "status": str(row.get("status") or "").strip(), + "entity_id": row.get("entity_id"), + "task_id": payload.get("task_id"), + "title": payload.get("title"), + "summary": str(row.get("message") or "").strip(), + } + ) + return tasks + + @staticmethod + def _map_orchestration_status_to_signal_status(status: str) -> str: + normalized = str(status or "").strip().lower() + if normalized == "completed": + return "已编排" + if normalized == "pending_manual": + return "待确认接入" + if normalized == "partial": + return "部分编排" + if normalized == "failed": + return "编排失败" + if normalized == "planned": + return "已规划" + if normalized == "skipped": + return "已跳过" + return "待处理" + diff --git a/backend/app/services/signal_source_orchestration/service.py b/backend/app/services/signal_source_orchestration/service.py new file mode 100644 index 00000000..6e7f12d7 --- /dev/null +++ b/backend/app/services/signal_source_orchestration/service.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from .mixin_entry_orchestrate import SignalSourceEntryOrchestrateMixin +from .mixin_entry_terminal_a import SignalSourceEntryTerminalAMixin +from .mixin_entry_terminal_b import SignalSourceEntryTerminalBMixin +from .mixin_execute_isolation import SignalSourceExecuteIsolationMixin +from .mixin_lookup_parse import SignalSourceLookupParseMixin +from .mixin_planning_locator_roles import SignalSourcePlanningLocatorRolesMixin +from .mixin_planning_onboard import SignalSourcePlanningOnboardMixin +from .mixin_registry_execute import SignalSourceRegistryExecuteMixin +from .mixin_result_resolution import SignalSourceResultResolutionMixin +from .mixin_result_run_updates import SignalSourceResultRunUpdatesMixin +from .mixin_worker_prompt_persist import SignalSourceWorkerPromptPersistMixin + + +class SignalSourceOrchestrationService( + SignalSourceEntryOrchestrateMixin, + SignalSourceEntryTerminalAMixin, + SignalSourceEntryTerminalBMixin, + SignalSourcePlanningOnboardMixin, + SignalSourcePlanningLocatorRolesMixin, + SignalSourceRegistryExecuteMixin, + SignalSourceExecuteIsolationMixin, + SignalSourceResultResolutionMixin, + SignalSourceResultRunUpdatesMixin, + SignalSourceWorkerPromptPersistMixin, + SignalSourceLookupParseMixin, +): + """信号源接入编排服务。""" + + +__all__ = ["SignalSourceOrchestrationService"] diff --git a/backend/app/services/signal_source_orchestration/shared.py b/backend/app/services/signal_source_orchestration/shared.py new file mode 100644 index 00000000..cf2c4ae1 --- /dev/null +++ b/backend/app/services/signal_source_orchestration/shared.py @@ -0,0 +1,57 @@ +"""信号源接入编排服务。""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import re +import time +from datetime import datetime, timezone +from types import SimpleNamespace +from typing import Any, Optional +from uuid import uuid4 + +from sqlalchemy import and_, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from ...config.ai_prompts import ( + PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION, + PROMPT_SIGNAL_SOURCE_ONBOARDING, +) +from ...models import ( + AIWorkerExecution, + Project, + Signal, + SignalSourceRegistry, + User, +) +from ...database import AsyncSessionLocal +from ..ai_orchestration_run_service import AIOrchestrationRunService +from ..prompt_profile_service import prompt_profile_service +from ..llm import llm_service + +from ...utils.timezone import now_cn_naive +SOURCE_ONBOARDING_ACTION_RE = re.compile( + r"(新增|增加|添加|接入|配置|注册|建立|加个|加一个|加).{0,8}(信号源|来源|监控源|channel|source|webhook|飞书|github|url|rss)", + re.IGNORECASE, +) +SOURCE_KIND_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("feishu", ("飞书", "lark", "群聊", "chat")), + ("github", ("github", "repo", "仓库", "pull request", "issue")), + ("webhook", ("webhook", "回调", "回传")), + ("url", ("url", "链接", "网页", "站点", "rss")), + ("browser", ("浏览器", "标签页", "页面")), + ("pmflow", ("pmflow", "内置ai", "ai管家", "steward")), +) +DEFAULT_WORKER_ROLE_KEYS = ("pm_dispatcher", "tech_architect", "delivery_coach") +MAX_SOURCE_NAME_LEN = 120 +MAX_SOURCE_IDENTIFIER_LEN = 180 +WORKER_MAX_CONCURRENCY = 3 +COMPLETE_WORKER_WAIT_TIMEOUT_SECONDS = 6.0 + + +def _utc_now_naive() -> datetime: + return now_cn_naive() + diff --git a/backend/app/services/signal_source_orchestration_service.py b/backend/app/services/signal_source_orchestration_service.py index 40978f7c..a03fa1ff 100644 --- a/backend/app/services/signal_source_orchestration_service.py +++ b/backend/app/services/signal_source_orchestration_service.py @@ -1,1853 +1,5 @@ -"""信号源接入编排服务。""" - from __future__ import annotations -import asyncio -import hashlib -import json -import re -import time -from datetime import datetime, timezone -from types import SimpleNamespace -from typing import Any, Optional -from uuid import uuid4 +from .signal_source_orchestration import SignalSourceOrchestrationService -from sqlalchemy import and_, select -from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession - -from ..config.ai_prompts import ( - PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION, - PROMPT_SIGNAL_SOURCE_ONBOARDING, -) -from ..models import ( - AIWorkerExecution, - Project, - Signal, - SignalSourceRegistry, - User, -) -from ..database import AsyncSessionLocal -from .ai_orchestration_run_service import AIOrchestrationRunService -from .prompt_profile_service import prompt_profile_service -from .llm import llm_service - -from ..utils.timezone import now_cn_naive -SOURCE_ONBOARDING_ACTION_RE = re.compile( - r"(新增|增加|添加|接入|配置|注册|建立|加个|加一个|加).{0,8}(信号源|来源|监控源|channel|source|webhook|飞书|github|url|rss)", - re.IGNORECASE, -) -SOURCE_KIND_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = ( - ("feishu", ("飞书", "lark", "群聊", "chat")), - ("github", ("github", "repo", "仓库", "pull request", "issue")), - ("webhook", ("webhook", "回调", "回传")), - ("url", ("url", "链接", "网页", "站点", "rss")), - ("browser", ("浏览器", "标签页", "页面")), - ("pmflow", ("pmflow", "内置ai", "ai管家", "steward")), -) -DEFAULT_WORKER_ROLE_KEYS = ("pm_dispatcher", "tech_architect", "delivery_coach") -MAX_SOURCE_NAME_LEN = 120 -MAX_SOURCE_IDENTIFIER_LEN = 180 -WORKER_MAX_CONCURRENCY = 3 -COMPLETE_WORKER_WAIT_TIMEOUT_SECONDS = 6.0 - - -def _utc_now_naive() -> datetime: - return now_cn_naive() - - -class SignalSourceOrchestrationService: - """把“新增信号源”转换为 GLM 规划与显式服务执行链。""" - - session_factory = AsyncSessionLocal - - def __init__(self, db: AsyncSession) -> None: - self.db = db - - @classmethod - def looks_like_signal_source_request(cls, text: str) -> bool: - normalized = re.sub(r"\s+", "", str(text or "")) - if len(normalized) < 3: - return False - if "信号源" in normalized and any( - token in normalized - for token in ("新增", "增加", "添加", "接入", "配置", "注册", "建立", "加个", "加一个", "加") - ): - return True - return bool(SOURCE_ONBOARDING_ACTION_RE.search(normalized)) - - async def orchestrate_from_signal( - self, - *, - tenant_id: int, - project_id: int, - signal_id: Optional[int], - initiating_text: str, - requested_via: str, - operator_user_id: Optional[int] = None, - dry_run: bool = False, - response_mode: str = "fast", - ) -> dict[str, Any]: - response_mode = "complete" if str(response_mode or "").strip().lower() == "complete" else "fast" - trace_id = f"source-onboard-{tenant_id}-{project_id}-{int(time.time() * 1000)}-{uuid4().hex[:8]}" - project = await self._load_project(tenant_id=tenant_id, project_id=project_id) - signal = await self._load_signal(tenant_id=tenant_id, signal_id=signal_id) if signal_id is not None else None - run_service = AIOrchestrationRunService(self.db) if self.db is not None else None - if project is None: - return { - "trace_id": trace_id, - "status": "skipped", - "reason": "project_or_signal_not_found", - } - if signal is None: - if dry_run: - signal = self._build_ephemeral_signal(initiating_text=initiating_text) - else: - return { - "trace_id": trace_id, - "status": "skipped", - "reason": "project_or_signal_not_found", - } - project_ref = SimpleNamespace( - id=int(project.id), - title=str(project.title or "").strip(), - name=str(project.name or "").strip(), - description=str(project.description or "").strip(), - owner_id=int(project.owner_id) if getattr(project, "owner_id", None) else None, - ) - signal_ref = SimpleNamespace( - id=int(signal.id) if getattr(signal, "id", None) is not None else None, - title=str(signal.title or "").strip(), - summary=str(signal.summary or "").strip(), - signal_type=str(signal.signal_type or "").strip(), - recommended_action=str(signal.recommended_action or "").strip(), - content=str(signal.content or "").strip(), - ) - real_signal_id = signal_ref.id - persisted_signal_id = int(real_signal_id) if real_signal_id and int(real_signal_id) > 0 else None - - if not self.looks_like_signal_source_request(initiating_text): - result = { - "trace_id": trace_id, - "status": "skipped", - "run_status": "completed", - "response_mode": response_mode, - "planner_status": "skipped", - "planner_diagnostics": None, - "summary": "当前输入不属于信号源接入请求。", - "pending_workers": [], - "worker_executions": [], - "execution": {"status": "skipped", "reason": "execution_removed"}, - "reason": "not_signal_source_request", - } - if run_service is not None: - await run_service.create_run( - tenant_id=tenant_id, - trace_id=trace_id, - run_type="signal_source_onboarding", - project_id=project_id, - signal_id=persisted_signal_id, - response_mode=response_mode, - status="completed", - planner_status="skipped", - execution_status="skipped", - worker_total=0, - worker_completed=0, - worker_failed=0, - summary=str(result.get("summary") or "").strip(), - planner_diagnostics=None, - result_payload=dict(result), - created_by_user_id=operator_user_id, - completed_at=_utc_now_naive(), - ) - await self.db.commit() - return result - - plan, planner_status, planner_diagnostics = await self._plan_onboarding( - tenant_id=tenant_id, - project=project_ref, - signal=signal_ref, - initiating_text=initiating_text, - requested_via=requested_via, - response_mode=response_mode, - ) - if not bool(plan.get("should_onboard")): - result = { - "trace_id": trace_id, - "status": "skipped", - "run_status": "completed", - "response_mode": response_mode, - "reason": "planner_rejected", - "planner_status": planner_status, - "planner_diagnostics": planner_diagnostics, - "summary": str(plan.get("summary") or "").strip(), - "pending_workers": [], - "worker_executions": [], - "execution": {"status": "skipped", "reason": "planner_rejected"}, - } - if run_service is not None: - await run_service.create_run( - tenant_id=tenant_id, - trace_id=trace_id, - run_type="signal_source_onboarding", - project_id=project_id, - signal_id=persisted_signal_id, - response_mode=response_mode, - status="completed", - planner_status=planner_status, - execution_status="skipped", - worker_total=0, - worker_completed=0, - worker_failed=0, - summary=str(result.get("summary") or "").strip(), - planner_diagnostics=planner_diagnostics, - result_payload=dict(result), - created_by_user_id=operator_user_id, - completed_at=_utc_now_naive(), - ) - await self.db.commit() - return result - - source_payload = plan.get("source") if isinstance(plan.get("source"), dict) else {} - worker_role_keys = self._normalize_worker_roles(plan.get("worker_roles")) - registry_result = await self._create_or_reuse_registry( - tenant_id=tenant_id, - project_id=project_id, - signal_id=signal_id, - operator_user_id=operator_user_id, - requested_via=requested_via, - source_payload=source_payload, - initiating_text=initiating_text, - dry_run=dry_run, - ) - - operator_user = await self._resolve_operator_user( - tenant_id=tenant_id, - project=project_ref, - operator_user_id=operator_user_id, - ) - operator_ref = self._build_user_snapshot(operator_user) - execution_result = await self._execute_planned_operations( - tenant_id=tenant_id, - operator_user=operator_ref, - project=project_ref, - instruction=str(plan.get("execution_instruction") or "").strip(), - dry_run=dry_run, - response_mode=response_mode, - ) - execution_status = str(execution_result.get("status") or "").strip().lower() - base_result = self._build_orchestration_result( - trace_id=trace_id, - response_mode=response_mode, - planner_status=planner_status, - planner_diagnostics=planner_diagnostics, - summary=str(plan.get("summary") or "").strip(), - registry_result=registry_result, - source_payload=source_payload, - execution_result=execution_result, - worker_results=[], - pending_workers=list(worker_role_keys), - status="planned" if dry_run else "partial", - run_status="completed" if dry_run or not worker_role_keys else "running", - ) - - if dry_run: - planned_results = self._build_planned_worker_results(worker_role_keys) - final_result = self._build_orchestration_result( - trace_id=trace_id, - response_mode=response_mode, - planner_status=planner_status, - planner_diagnostics=planner_diagnostics, - summary=str(plan.get("summary") or "").strip(), - registry_result=registry_result, - source_payload=source_payload, - execution_result=execution_result, - worker_results=planned_results, - pending_workers=[], - status="planned", - run_status="completed", - ) - if run_service is not None: - await run_service.create_run( - tenant_id=tenant_id, - trace_id=trace_id, - run_type="signal_source_onboarding", - project_id=project_id, - signal_id=persisted_signal_id, - response_mode=response_mode, - status="completed", - planner_status=planner_status, - execution_status=execution_status, - worker_total=len(worker_role_keys), - worker_completed=len(planned_results), - worker_failed=0, - summary=str(final_result.get("summary") or "").strip(), - planner_diagnostics=planner_diagnostics, - result_payload=dict(final_result), - created_by_user_id=operator_user_id, - completed_at=_utc_now_naive(), - ) - await self.db.commit() - return final_result - - if not worker_role_keys: - registry_status, onboarding_status = self._resolve_registry_activation( - source_payload=source_payload, - execution_result=execution_result, - worker_results=[], - ) - final_status = self._resolve_orchestration_status(openclaw_status=execution_status, worker_results=[]) - if final_status == "completed" and onboarding_status != "completed": - final_status = "pending_manual" - final_result = self._build_orchestration_result( - trace_id=trace_id, - response_mode=response_mode, - planner_status=planner_status, - planner_diagnostics=planner_diagnostics, - summary=str(plan.get("summary") or "").strip(), - registry_result={ - "registry_id": registry_result.get("registry_id"), - "status": registry_status, - "onboarding_status": onboarding_status, - }, - source_payload=source_payload, - execution_result=execution_result, - worker_results=[], - pending_workers=[], - status=final_status, - run_status="completed", - ) - if run_service is not None: - await run_service.create_run( - tenant_id=tenant_id, - trace_id=trace_id, - run_type="signal_source_onboarding", - project_id=project_id, - signal_id=persisted_signal_id, - response_mode=response_mode, - status=final_status, - planner_status=planner_status, - execution_status=execution_status, - worker_total=0, - worker_completed=0, - worker_failed=0, - summary=str(final_result.get("summary") or "").strip(), - planner_diagnostics=planner_diagnostics, - result_payload=dict(final_result), - created_by_user_id=operator_user_id, - completed_at=_utc_now_naive(), - ) - if registry_result.get("registry_id") is not None: - registry_row = await self._load_signal_source_registry( - tenant_id=tenant_id, - registry_id=int(registry_result["registry_id"]), - ) - if registry_row is not None: - registry_row.last_signal_at = _utc_now_naive() - registry_row.last_worker_run_at = _utc_now_naive() - registry_row.status = registry_status - registry_row.onboarding_status = onboarding_status - await self.db.commit() - if real_signal_id: - persisted_signal = await self._load_signal(tenant_id=tenant_id, signal_id=int(real_signal_id)) - if persisted_signal is not None: - await self._persist_signal_orchestration_result(signal=persisted_signal, orchestration_result=final_result) - return final_result - - if run_service is not None: - await run_service.create_run( - tenant_id=tenant_id, - trace_id=trace_id, - run_type="signal_source_onboarding", - project_id=project_id, - signal_id=persisted_signal_id, - response_mode=response_mode, - status="running", - planner_status=planner_status, - execution_status=execution_status, - worker_total=len(worker_role_keys), - worker_completed=0, - worker_failed=0, - summary=str(base_result.get("summary") or "").strip(), - planner_diagnostics=planner_diagnostics, - result_payload=dict(base_result), - created_by_user_id=operator_user_id, - ) - await self.db.commit() - - task = asyncio.create_task( - type(self)._background_finalize_with_guard( - tenant_id=tenant_id, - trace_id=trace_id, - project_payload=self._serialize_snapshot(project_ref), - signal_payload=self._serialize_snapshot(signal_ref), - registry_result=dict(registry_result), - openclaw_result=dict(execution_result), - worker_role_keys=list(worker_role_keys), - operator_user_payload=self._serialize_snapshot(operator_ref), - planner_status=planner_status, - planner_diagnostics=planner_diagnostics, - summary=str(plan.get("summary") or "").strip(), - signal_id=persisted_signal_id, - ), - name=f"signal-source-workers-{trace_id}", - ) - if response_mode == "fast": - return base_result - try: - return await asyncio.wait_for(asyncio.shield(task), timeout=COMPLETE_WORKER_WAIT_TIMEOUT_SECONDS) - except asyncio.TimeoutError: - if run_service is None: - return base_result - run_row = await run_service.get_run(tenant_id=tenant_id, trace_id=trace_id) - if run_row is None: - return base_result - snapshot = AIOrchestrationRunService.serialize_run(run_row) - payload = dict(run_row.result_payload or base_result) - payload["run_status"] = str(snapshot.get("run_status") or "running") - payload["status"] = str(payload.get("status") or "partial") - payload["pending_workers"] = snapshot.get("pending_workers") or payload.get("pending_workers") or [] - return payload - - async def list_project_sources( - self, - *, - tenant_id: int, - project_id: int, - limit: int = 20, - ) -> list[dict[str, Any]]: - rows = ( - await self.db.execute( - select(SignalSourceRegistry).where( - and_( - SignalSourceRegistry.tenant_id == tenant_id, - SignalSourceRegistry.project_id == project_id, - ) - ).order_by(SignalSourceRegistry.updated_at.desc()).limit(max(1, min(limit, 100))) - ) - ).scalars().all() - data: list[dict[str, Any]] = [] - for row in rows: - executions = ( - await self.db.execute( - select(AIWorkerExecution).where( - and_( - AIWorkerExecution.tenant_id == tenant_id, - AIWorkerExecution.signal_source_registry_id == int(row.id), - ) - ).order_by(AIWorkerExecution.created_at.desc()).limit(6) - ) - ).scalars().all() - data.append( - { - "id": int(row.id), - "project_id": int(row.project_id), - "signal_id": int(row.signal_id) if row.signal_id else None, - "source_kind": str(row.source_kind or ""), - "source_name": str(row.source_name or ""), - "source_identifier": str(row.source_identifier or ""), - "requested_via": str(row.requested_via or ""), - "status": str(row.status or ""), - "onboarding_status": str(row.onboarding_status or ""), - "updated_at": row.updated_at.isoformat() if row.updated_at else None, - "worker_executions": [ - { - "id": int(item.id), - "worker_role_key": str(item.worker_role_key or ""), - "worker_role_name": str(item.worker_role_name or ""), - "status": str(item.status or ""), - "summary": str(item.summary or ""), - "completed_at": item.completed_at.isoformat() if item.completed_at else None, - } - for item in executions - ], - } - ) - return data - - async def _plan_onboarding( - self, - *, - tenant_id: int, - project: Project, - signal: Signal, - initiating_text: str, - requested_via: str, - response_mode: str, - ) -> tuple[dict[str, Any], str, dict[str, Any]]: - request_payload = { - "requested_via": requested_via, - "initiating_text": initiating_text[:600], - } - project_payload = { - "project_id": int(project.id), - "title": str(project.title or project.name or "").strip(), - "description": str(project.description or "").strip()[:400], - } - signal_payload = { - "signal_id": int(signal.id) if getattr(signal, "id", None) is not None else None, - "title": str(signal.title or "").strip(), - "summary": str(signal.summary or "").strip()[:400], - "signal_type": str(signal.signal_type or "").strip(), - "recommended_action": str(signal.recommended_action or "").strip(), - } - prompt = PROMPT_SIGNAL_SOURCE_ONBOARDING.format( - request_json=json.dumps(request_payload, ensure_ascii=False), - project_json=json.dumps(project_payload, ensure_ascii=False), - signal_json=json.dumps(signal_payload, ensure_ascii=False), - ) - try: - extraction = await llm_service.generate_structured_object( - prompt, - db=self.db, - tenant_id=tenant_id, - function_name="signal_source_onboarding_plan", - use_prompt_profile=False, - max_tokens=1800, - response_mode="complete" if response_mode == "fast" else response_mode, - ) - diagnostics = self._build_planner_diagnostics(extraction) - if extraction.payload is None: - raise ValueError(f"signal_source_onboarding_plan_failed:{str(extraction.error_code or 'structured_empty')}") - plan = extraction.payload - normalized = self._normalize_onboarding_plan( - plan=plan, - initiating_text=initiating_text, - requested_via=requested_via, - ) - return normalized, "planned", diagnostics - except Exception as exc: - raise RuntimeError(f"signal_source_onboarding_plan_failed:{str(exc)}") from exc - - def _normalize_onboarding_plan( - self, - *, - plan: dict[str, Any], - initiating_text: str, - requested_via: str, - ) -> dict[str, Any]: - should_onboard = bool(plan.get("should_onboard")) - summary = str(plan.get("summary") or "").strip()[:300] - source = plan.get("source") if isinstance(plan.get("source"), dict) else {} - worker_roles = self._normalize_worker_roles(plan.get("worker_roles")) - if not should_onboard: - return { - "should_onboard": False, - "summary": summary or "未识别为新增信号源请求。", - "source": None, - "worker_roles": [], - "execution_instruction": "", - } - normalized_source = self._normalize_source_payload( - source_payload=source, - fallback_text=initiating_text, - requested_via=requested_via, - ) - return { - "should_onboard": True, - "summary": summary or f"准备接入信号源 {normalized_source['source_name']}。", - "source": normalized_source, - "worker_roles": worker_roles, - "execution_instruction": ( - str(plan.get("execution_instruction") or "").strip() - or self._build_execution_instruction(normalized_source, initiating_text) - )[:600], - } - - def _normalize_source_payload( - self, - *, - source_payload: dict[str, Any], - fallback_text: str, - requested_via: str, - ) -> dict[str, Any]: - source_kind = self._normalize_source_kind(source_payload.get("source_kind"), text=fallback_text, requested_via=requested_via) - source_name = self._clean_source_name_candidate(str(source_payload.get("source_name") or "").strip()) - if not source_name: - source_name = self._extract_source_name(fallback_text, default=f"{source_kind}信号源") - source_name = source_name[:MAX_SOURCE_NAME_LEN] or f"{source_kind}信号源" - source_identifier = str(source_payload.get("source_identifier") or "").strip() - if source_identifier: - existing_kind = source_identifier.split(":", 1)[0].strip().lower() - if existing_kind and existing_kind != source_kind: - source_identifier = "" - if not source_identifier: - source_identifier = self._build_source_identifier( - source_kind=source_kind, - source_name=source_name, - text=fallback_text, - source_payload=source_payload, - ) - description = str(source_payload.get("description") or "").strip()[:1000] or fallback_text[:300] - config = source_payload.get("config") if isinstance(source_payload.get("config"), dict) else {} - return { - "source_kind": source_kind, - "source_name": source_name, - "source_identifier": source_identifier[:MAX_SOURCE_IDENTIFIER_LEN], - "description": description, - "config": config, - } - - @staticmethod - def _normalize_source_kind(raw_kind: Any, *, text: str, requested_via: str) -> str: - cleaned = str(raw_kind or "").strip().lower() - for kind, hints in SOURCE_KIND_HINTS: - if cleaned == kind: - return kind - if any(token in cleaned for token in hints): - return kind - text_lower = str(text or "").lower() - for kind, hints in SOURCE_KIND_HINTS: - if any(token in text_lower for token in hints): - return kind - via = str(requested_via or "").strip().lower() - if via in {"feishu", "pmflow", "github", "browser"}: - return via - return "custom" - - @classmethod - def _extract_source_name(cls, text: str, *, default: str) -> str: - cleaned = re.sub(r"\s+", " ", str(text or "")).strip() - if not cleaned: - return default - repo_match = re.search(r"\b([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)\b", cleaned) - if repo_match: - return repo_match.group(1)[:MAX_SOURCE_NAME_LEN] - quoted_match = re.search(r"[\"'“](.+?)[\"'”]", cleaned) - if quoted_match: - candidate = cls._clean_source_name_candidate(quoted_match.group(1)) - if candidate: - return candidate[:MAX_SOURCE_NAME_LEN] - for pattern in ( - r"(?:新增|增加|添加|接入|配置|注册|建立)(?:一个|1个|一条|一组|新的|一个新的)?\s*(.+?)(?:信号源|来源|监控源)", - r"(?:信号源|来源|监控源)(?:为|是|叫|命名为)?\s*(.+)", - ): - match = re.search(pattern, cleaned, re.IGNORECASE) - if match: - value = cls._clean_source_name_candidate(match.group(1)) - if value: - return value[:MAX_SOURCE_NAME_LEN] - return cls._clean_source_name_candidate(cleaned)[:MAX_SOURCE_NAME_LEN] or default - - @classmethod - def _build_source_identifier( - cls, - *, - source_kind: str, - source_name: str, - text: str, - source_payload: dict[str, Any], - ) -> str: - normalized_text = re.sub(r"\s+", " ", str(text or "")).strip() - explicit_locator = cls._extract_explicit_source_locator(source_payload=source_payload, text=text) - if explicit_locator: - basis = re.sub(r"[^a-zA-Z0-9:/._-]+", "-", explicit_locator).strip("-").lower() - return f"{source_kind}:{basis[:120]}" - if cls._source_name_is_generic(source_name, source_kind=source_kind): - digest = hashlib.sha1(f"{source_kind}:{normalized_text}".encode("utf-8")).hexdigest()[:16] - return f"{source_kind}:req-{digest}" - basis = re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "-", source_name).strip("-").lower() - if basis: - return f"{source_kind}:{basis[:120]}" - digest = hashlib.sha1(f"{source_kind}:{normalized_text}".encode("utf-8")).hexdigest()[:16] - return f"{source_kind}:{digest}" - - @staticmethod - def _clean_source_name_candidate(value: str) -> str: - cleaned = re.sub(r"\s+", " ", str(value or "")).strip() - cleaned = re.sub(r"^(?:给)?项目id[::]?\s*\d+\s*", "", cleaned, flags=re.IGNORECASE) - cleaned = re.sub(r"[,。,.;;::].*$", "", cleaned) - cleaned = cleaned.strip(" []【】()()::\"'“”") - cleaned = re.sub(r"^(?:请|帮我|帮忙|把|将|需要|想要|用于|用来)\s*", "", cleaned, flags=re.IGNORECASE) - cleaned = re.sub( - r"^(?:一个|1个|一条|一组|一个新的|新的|新增的)\s+", - "", - cleaned, - flags=re.IGNORECASE, - ) - cleaned = re.sub(r"(?:信号源|来源|监控源|source|channel)$", "", cleaned, flags=re.IGNORECASE).strip() - if cleaned.lower() == "github": - return "GitHub" - if cleaned.lower() == "feishu": - return "Feishu" - normalized = re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "", cleaned).lower() - if normalized in {"", "项目id", "给项目id", "复合", "复合信号源", "信号源", "来源", "监控源"}: - return "" - return cleaned - - @classmethod - def _extract_explicit_source_locator(cls, *, source_payload: dict[str, Any], text: str) -> str: - config = source_payload.get("config") if isinstance(source_payload.get("config"), dict) else {} - direct_candidates = [ - source_payload.get("repo"), - source_payload.get("repository"), - source_payload.get("url"), - source_payload.get("webhook_url"), - source_payload.get("channel_id"), - source_payload.get("chat_id"), - config.get("repo"), - config.get("repository"), - config.get("url"), - config.get("webhook_url"), - config.get("channel_id"), - config.get("chat_id"), - ] - for candidate in direct_candidates: - normalized = cls._normalize_locator_fragment(candidate) - if normalized: - return normalized - - url_match = re.search(r"https?://[^\s,。,;;))】\\]]+", str(text or ""), re.IGNORECASE) - if url_match: - return cls._normalize_locator_fragment(url_match.group(0)) - - repo_match = re.search(r"\b([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)\b", str(text or "")) - if repo_match: - return f"repo:{repo_match.group(1).lower()}" - - chat_match = re.search( - r"\b(?:chat_id|channel_id|群聊id|频道id)[::=\s]+([A-Za-z0-9_-]{6,})\b", - str(text or ""), - re.IGNORECASE, - ) - if chat_match: - return f"chat:{chat_match.group(1).lower()}" - return "" - - @staticmethod - def _normalize_locator_fragment(value: Any) -> str: - text = str(value or "").strip().strip("\"'“”") - if not text: - return "" - text = re.sub(r"\?.*$", "", text).rstrip("/") - return text[:140].lower() - - @classmethod - def _source_name_is_generic(cls, source_name: str, *, source_kind: str) -> bool: - normalized = re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "", str(source_name or "")).lower() - if not normalized: - return True - generic_tokens = { - re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "", source_kind).lower(), - re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "", f"{source_kind}信号源").lower(), - "项目id", - "给项目id", - "复合", - "复合信号源", - } - for kind, hints in SOURCE_KIND_HINTS: - if kind != source_kind: - continue - generic_tokens.update( - re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "", str(token)).lower() - for token in hints - ) - break - return normalized in generic_tokens - - @staticmethod - def _normalize_worker_roles(raw_roles: Any) -> list[str]: - roles: list[str] = [] - if isinstance(raw_roles, list): - for item in raw_roles: - key = str(item or "").strip() - if key in DEFAULT_WORKER_ROLE_KEYS and key not in roles: - roles.append(key) - if roles: - return roles[: len(DEFAULT_WORKER_ROLE_KEYS)] - return list(DEFAULT_WORKER_ROLE_KEYS) - - @staticmethod - def _build_execution_instruction(source: dict[str, Any], initiating_text: str) -> str: - return ( - f"围绕新增信号源“{source.get('source_name') or '未命名信号源'}”完成接入准备。" - "请创建必要任务、分配合适负责人,并通知相关成员。" - f"补充上下文:{str(initiating_text or '').strip()[:240]}" - ) - - async def _create_or_reuse_registry( - self, - *, - tenant_id: int, - project_id: int, - signal_id: Optional[int], - operator_user_id: Optional[int], - requested_via: str, - source_payload: dict[str, Any], - initiating_text: str, - dry_run: bool, - ) -> dict[str, Any]: - normalized_source = self._normalize_source_payload( - source_payload=source_payload, - fallback_text=initiating_text, - requested_via=requested_via, - ) - if dry_run: - return { - "status": "planned", - "registry_id": None, - "source_kind": normalized_source.get("source_kind"), - "source_name": normalized_source.get("source_name"), - "source_identifier": normalized_source.get("source_identifier"), - "description": str(normalized_source.get("description") or "").strip(), - } - - row = SignalSourceRegistry( - tenant_id=tenant_id, - project_id=project_id, - signal_id=signal_id, - created_by_user_id=operator_user_id, - source_kind=str(normalized_source["source_kind"]), - source_name=str(normalized_source["source_name"]), - source_identifier=str(normalized_source["source_identifier"]), - description=str(normalized_source.get("description") or "").strip() or None, - requested_via=str(requested_via or "system").strip()[:40] or "system", - status="pending", - onboarding_status="pending", - config=normalized_source.get("config") if isinstance(normalized_source.get("config"), dict) else {}, - raw_context={ - "initiating_text": str(initiating_text or "")[:2000], - "requested_via": requested_via, - }, - last_signal_at=_utc_now_naive(), - ) - try: - # 只回滚本次插入尝试,避免误回滚外层事务里已创建的 signal 记录。 - async with self.db.begin_nested(): - self.db.add(row) - await self.db.flush() - await self.db.refresh(row) - return { - "status": "created", - "registry_id": int(row.id), - "source_kind": str(row.source_kind or ""), - "source_name": str(row.source_name or ""), - "source_identifier": str(row.source_identifier or ""), - "description": str(row.description or ""), - } - except IntegrityError: - if row in self.db: - self.db.expunge(row) - existing = ( - await self.db.execute( - select(SignalSourceRegistry).where( - and_( - SignalSourceRegistry.tenant_id == tenant_id, - SignalSourceRegistry.project_id == project_id, - SignalSourceRegistry.source_kind == str(normalized_source["source_kind"]), - SignalSourceRegistry.source_identifier == str(normalized_source["source_identifier"]), - ) - ) - ) - ).scalar_one_or_none() - if existing is None: - raise - existing.signal_id = signal_id - if str(normalized_source.get("source_name") or "").strip(): - existing.source_name = str(normalized_source.get("source_name") or "").strip()[:MAX_SOURCE_NAME_LEN] - existing.description = str(normalized_source.get("description") or existing.description or "").strip() or None - existing.config = normalized_source.get("config") if isinstance(normalized_source.get("config"), dict) else existing.config - existing.raw_context = { - "initiating_text": str(initiating_text or "")[:2000], - "requested_via": requested_via, - } - if not isinstance(existing.config, dict) or not existing.config: - existing.status = "pending" - existing.onboarding_status = "pending" - existing.last_signal_at = _utc_now_naive() - await self.db.flush() - return { - "status": "reused", - "registry_id": int(existing.id), - "source_kind": str(existing.source_kind or ""), - "source_name": str(existing.source_name or ""), - "source_identifier": str(existing.source_identifier or ""), - "description": str(existing.description or ""), - } - - async def _execute_planned_operations( - self, - *, - tenant_id: int, - operator_user: Optional[User], - project: Project, - instruction: str, - dry_run: bool, - response_mode: str, - ) -> dict[str, Any]: - _ = tenant_id, operator_user, project, instruction, dry_run, response_mode - return { - "status": "skipped", - "reason": "execution_removed", - } - - async def _execute_worker_roles( - self, - *, - tenant_id: int, - project: Project, - signal: Signal, - registry_result: dict[str, Any], - openclaw_result: dict[str, Any], - worker_role_keys: list[str], - trace_id: str, - operator_user: Optional[User], - dry_run: bool, - response_mode: str, - ) -> list[dict[str, Any]]: - bundle = await prompt_profile_service.get_profiles(self.db, tenant_id=tenant_id) - roles = bundle.get("roles") if isinstance(bundle.get("roles"), list) else [] - role_map = { - str(item.get("role_key") or "").strip(): item - for item in roles - if isinstance(item, dict) and str(item.get("role_key") or "").strip() - } - - if dry_run: - return self._build_planned_worker_results(worker_role_keys, role_map=role_map) - - semaphore = asyncio.Semaphore(WORKER_MAX_CONCURRENCY) - - async def _run_role(role_key: str) -> dict[str, Any]: - role = role_map.get(role_key) - if not isinstance(role, dict): - return { - "worker_role_key": role_key, - "worker_role_name": role_key, - "status": "failed", - "reason": "role_not_configured", - } - try: - async with semaphore: - return await type(self)._execute_single_worker_role_isolated( - tenant_id=tenant_id, - project_payload=self._serialize_snapshot(project), - signal_payload=self._serialize_snapshot(signal), - registry_result=dict(registry_result), - execution_result=dict(openclaw_result), - role=dict(role), - trace_id=trace_id, - operator_user_payload=self._serialize_snapshot(operator_user), - response_mode=response_mode, - ) - except Exception as exc: - return { - "worker_role_key": role_key, - "worker_role_name": str(role.get("role_name") or role_key).strip()[:64] or role_key, - "status": "failed", - "reason": str(exc)[:300], - } - - tasks = [asyncio.create_task(_run_role(role_key)) for role_key in worker_role_keys] - return list(await asyncio.gather(*tasks, return_exceptions=False)) - - async def _execute_single_worker_role( - self, - *, - tenant_id: int, - project: Project, - signal: Signal, - registry_result: dict[str, Any], - openclaw_result: dict[str, Any], - role: dict[str, Any], - trace_id: str, - operator_user: Optional[User], - dry_run: bool, - response_mode: str, - ) -> dict[str, Any]: - role_key = str(role.get("role_key") or "").strip() - role_name = str(role.get("role_name") or role_key).strip()[:64] or role_key - registry_id = registry_result.get("registry_id") - source_payload = { - "registry_id": int(registry_id) if registry_id is not None else None, - "source_kind": str(registry_result.get("source_kind") or ""), - "source_name": str(registry_result.get("source_name") or ""), - "source_identifier": str(registry_result.get("source_identifier") or ""), - "description": str(registry_result.get("description") or ""), - } - - execution_row: Optional[AIWorkerExecution] = None - if not dry_run: - execution_row = AIWorkerExecution( - tenant_id=tenant_id, - project_id=int(project.id), - signal_id=int(signal.id) if getattr(signal, "id", None) is not None else None, - signal_source_registry_id=int(registry_id) if registry_id is not None else None, - operator_user_id=int(operator_user.id) if operator_user is not None else None, - trace_id=trace_id, - operation_trace_id=( - str((openclaw_result.get("result") or {}).get("trace_id") or "").strip() - if isinstance(openclaw_result.get("result"), dict) - else None - ), - execution_type="signal_source_onboarding", - worker_role_key=role_key, - worker_role_name=role_name, - status="running", - input_payload=source_payload, - started_at=_utc_now_naive(), - ) - self.db.add(execution_row) - await self.db.flush() - # 先提交 running 状态,避免事务跨 LLM 调用长期持有写锁。 - await self.db.commit() - - prompt = self._build_worker_prompt( - role=role, - project=project, - signal=signal, - source_payload=source_payload, - execution_result=openclaw_result, - ) - if dry_run: - return { - "worker_role_key": role_key, - "worker_role_name": role_name, - "status": "planned", - } - try: - extraction = await llm_service.generate_structured_object( - prompt, - db=self.db, - tenant_id=tenant_id, - function_name="signal_source_ai_worker_execution", - use_prompt_profile=False, - max_tokens=420, - response_mode="complete" if response_mode == "fast" else response_mode, - ) - if extraction.payload is None: - raise ValueError(str(extraction.error_code or "structured_empty")) - parsed = self._normalize_worker_output( - extraction.payload, - role_name=role_name, - source_name=source_payload.get("source_name"), - ) - if execution_row is not None: - execution_row.status = "completed" - execution_row.summary = str(parsed.get("summary") or "").strip()[:1000] - execution_row.output_payload = parsed - execution_row.completed_at = _utc_now_naive() - await self.db.commit() - return { - "worker_role_key": role_key, - "worker_role_name": role_name, - "status": "completed", - "summary": str(parsed.get("summary") or "").strip(), - "execution_id": int(execution_row.id) if execution_row is not None else None, - "used_fallback": False, - } - except Exception as exc: - if execution_row is not None: - execution_row.status = "failed" - execution_row.summary = str(exc)[:1000] - execution_row.output_payload = { - "error": str(exc)[:1000], - "used_fallback": False, - } - execution_row.error_message = str(exc)[:1000] - execution_row.completed_at = _utc_now_naive() - await self.db.commit() - return { - "worker_role_key": role_key, - "worker_role_name": role_name, - "status": "failed", - "reason": str(exc), - "summary": str(exc)[:200], - "used_fallback": False, - "execution_id": int(execution_row.id) if execution_row is not None else None, - } - - @classmethod - async def _execute_single_worker_role_isolated( - cls, - *, - tenant_id: int, - project_payload: dict[str, Any], - signal_payload: dict[str, Any], - registry_result: dict[str, Any], - openclaw_result: dict[str, Any], - role: dict[str, Any], - trace_id: str, - operator_user_payload: Optional[dict[str, Any]], - response_mode: str, - ) -> dict[str, Any]: - session_factory = getattr(cls, "session_factory", AsyncSessionLocal) - async with session_factory() as db: - service = cls(db) - return await service._execute_single_worker_role( - tenant_id=tenant_id, - project=SimpleNamespace(**project_payload), - signal=SimpleNamespace(**signal_payload), - registry_result=registry_result, - execution_result=openclaw_result, - role=role, - trace_id=trace_id, - operator_user=SimpleNamespace(**operator_user_payload) if isinstance(operator_user_payload, dict) else None, - dry_run=False, - response_mode=response_mode, - ) - - @classmethod - async def _background_finalize_with_guard( - cls, - *, - tenant_id: int, - trace_id: str, - project_payload: dict[str, Any], - signal_payload: dict[str, Any], - registry_result: dict[str, Any], - openclaw_result: dict[str, Any], - worker_role_keys: list[str], - operator_user_payload: Optional[dict[str, Any]], - planner_status: str, - planner_diagnostics: Optional[dict[str, Any]], - summary: str, - signal_id: Optional[int], - ) -> dict[str, Any]: - try: - return await cls._execute_workers_and_finalize( - tenant_id=tenant_id, - trace_id=trace_id, - project_payload=project_payload, - signal_payload=signal_payload, - registry_result=registry_result, - execution_result=openclaw_result, - worker_role_keys=worker_role_keys, - operator_user_payload=operator_user_payload, - planner_status=planner_status, - planner_diagnostics=planner_diagnostics, - summary=summary, - signal_id=signal_id, - ) - except Exception as exc: - session_factory = getattr(cls, "session_factory", AsyncSessionLocal) - async with session_factory() as db: - service = cls(db) - run_service = AIOrchestrationRunService(db) - row = await run_service.get_run(tenant_id=tenant_id, trace_id=trace_id) - response_mode = str(row.response_mode or "fast") if row is not None else "fast" - existing_payload = dict(row.result_payload or {}) if row is not None and isinstance(row.result_payload, dict) else {} - source_payload = existing_payload.get("signal_source") if isinstance(existing_payload.get("signal_source"), dict) else {} - worker_results = ( - existing_payload.get("worker_executions") - if isinstance(existing_payload.get("worker_executions"), list) - else [] - ) - completed_keys = { - str(item.get("worker_role_key") or "").strip() - for item in worker_results - if isinstance(item, dict) and str(item.get("worker_role_key") or "").strip() - } - pending_workers = [key for key in worker_role_keys if key not in completed_keys] or list(worker_role_keys) - failed_result = service._build_orchestration_result( - trace_id=trace_id, - response_mode=response_mode, - planner_status=planner_status, - planner_diagnostics=planner_diagnostics, - summary=summary, - registry_result={ - "registry_id": source_payload.get("registry_id"), - "status": source_payload.get("status"), - }, - source_payload={ - "source_kind": source_payload.get("source_kind"), - "source_name": source_payload.get("source_name"), - "source_identifier": source_payload.get("source_identifier"), - }, - execution_result=openclaw_result, - worker_results=worker_results, - pending_workers=pending_workers, - status="failed", - run_status="failed", - ) - failed_result["background_error"] = str(exc)[:500] - if row is not None: - await run_service.update_run( - tenant_id=tenant_id, - trace_id=trace_id, - status="failed", - planner_status=planner_status, - execution_status=str(openclaw_result.get("status") or row.openclaw_status or ""), - worker_total=max(len(worker_role_keys), int(row.worker_total or 0)), - worker_completed=int(row.worker_completed or 0), - worker_failed=int(row.worker_failed or 0), - summary=summary, - planner_diagnostics=planner_diagnostics or {}, - result_payload=failed_result, - completed_at=_utc_now_naive(), - ) - await db.commit() - return failed_result - - @classmethod - async def _execute_workers_and_finalize( - cls, - *, - tenant_id: int, - trace_id: str, - project_payload: dict[str, Any], - signal_payload: dict[str, Any], - registry_result: dict[str, Any], - openclaw_result: dict[str, Any], - worker_role_keys: list[str], - operator_user_payload: Optional[dict[str, Any]], - planner_status: str, - planner_diagnostics: Optional[dict[str, Any]], - summary: str, - signal_id: Optional[int], - ) -> dict[str, Any]: - session_factory = getattr(cls, "session_factory", AsyncSessionLocal) - async with session_factory() as db: - service = cls(db) - bundle = await prompt_profile_service.get_profiles(db, tenant_id=tenant_id) - roles = bundle.get("roles") if isinstance(bundle.get("roles"), list) else [] - role_map = { - str(item.get("role_key") or "").strip(): item - for item in roles - if isinstance(item, dict) and str(item.get("role_key") or "").strip() - } - semaphore = asyncio.Semaphore(WORKER_MAX_CONCURRENCY) - - async def _run_role(role_key: str) -> dict[str, Any]: - role = role_map.get(role_key) - if not isinstance(role, dict): - return { - "worker_role_key": role_key, - "worker_role_name": role_key, - "status": "failed", - "reason": "role_not_configured", - } - try: - async with semaphore: - return await cls._execute_single_worker_role_isolated( - tenant_id=tenant_id, - project_payload=project_payload, - signal_payload=signal_payload, - registry_result=registry_result, - execution_result=openclaw_result, - role=dict(role), - trace_id=trace_id, - operator_user_payload=operator_user_payload, - response_mode="complete", - ) - except Exception as exc: - return { - "worker_role_key": role_key, - "worker_role_name": str(role.get("role_name") or role_key).strip()[:64] or role_key, - "status": "failed", - "reason": str(exc)[:300], - } - - tasks = [asyncio.create_task(_run_role(role_key)) for role_key in worker_role_keys] - worker_results: list[dict[str, Any]] = [] - for task in asyncio.as_completed(tasks): - result = await task - worker_results.append(result) - await service._update_run_after_worker( - tenant_id=tenant_id, - trace_id=trace_id, - summary=summary, - planner_status=planner_status, - planner_diagnostics=planner_diagnostics, - registry_result=registry_result, - openclaw_result=openclaw_result, - worker_role_keys=worker_role_keys, - worker_results=worker_results, - ) - - final_result = await service._finalize_orchestration_run( - tenant_id=tenant_id, - trace_id=trace_id, - signal_id=signal_id, - summary=summary, - planner_status=planner_status, - planner_diagnostics=planner_diagnostics, - registry_result=registry_result, - openclaw_result=openclaw_result, - worker_role_keys=worker_role_keys, - worker_results=worker_results, - ) - return final_result - - @staticmethod - def _serialize_snapshot(value: Any) -> Optional[dict[str, Any]]: - if value is None: - return None - if isinstance(value, dict): - return dict(value) - data = getattr(value, "__dict__", None) - if isinstance(data, dict): - return {str(key): data[key] for key in data} - return None - - @staticmethod - def _build_planner_diagnostics(extraction: Any) -> dict[str, Any]: - return { - "used_fallback": False, - "error_code": extraction.error_code, - "source_field": extraction.source_field or None, - "provider": str(extraction.provider or ""), - "model": str(extraction.model or ""), - } - - def _build_planned_worker_results( - self, - worker_role_keys: list[str], - *, - role_map: Optional[dict[str, dict[str, Any]]] = None, - ) -> list[dict[str, Any]]: - results: list[dict[str, Any]] = [] - for role_key in worker_role_keys: - role = role_map.get(role_key) if isinstance(role_map, dict) else None - role_name = str((role or {}).get("role_name") or role_key).strip()[:64] or role_key - results.append( - { - "worker_role_key": role_key, - "worker_role_name": role_name, - "status": "planned", - } - ) - return results - - @staticmethod - def _build_signal_source_snapshot(registry_result: dict[str, Any], source_payload: dict[str, Any]) -> dict[str, Any]: - return { - "registry_id": registry_result.get("registry_id"), - "status": registry_result.get("status"), - "onboarding_status": registry_result.get("onboarding_status"), - "source_kind": source_payload.get("source_kind"), - "source_name": source_payload.get("source_name"), - "source_identifier": source_payload.get("source_identifier"), - "description": source_payload.get("description"), - "config": source_payload.get("config") if isinstance(source_payload.get("config"), dict) else {}, - } - - def _build_orchestration_result( - self, - *, - trace_id: str, - response_mode: str, - planner_status: str, - planner_diagnostics: Optional[dict[str, Any]], - summary: str, - registry_result: dict[str, Any], - source_payload: dict[str, Any], - execution_result: dict[str, Any], - worker_results: list[dict[str, Any]], - pending_workers: list[str], - status: str, - run_status: str, - ) -> dict[str, Any]: - return { - "trace_id": trace_id, - "status": status, - "run_status": run_status, - "response_mode": response_mode, - "planner_status": planner_status, - "planner_diagnostics": planner_diagnostics, - "summary": summary, - "pending_workers": list(pending_workers), - "signal_source": self._build_signal_source_snapshot(registry_result, source_payload), - "execution": execution_result, - "worker_executions": list(worker_results), - } - - @classmethod - def _has_actionable_source_config(cls, *, source_payload: dict[str, Any]) -> bool: - if not isinstance(source_payload, dict): - return False - config = source_payload.get("config") if isinstance(source_payload.get("config"), dict) else {} - if config: - return True - locator = cls._extract_explicit_source_locator( - source_payload=source_payload, - text=str(source_payload.get("description") or ""), - ) - if locator: - return True - source_kind = str(source_payload.get("source_kind") or "").strip().lower() - source_identifier = str(source_payload.get("source_identifier") or "").strip().lower() - if source_kind in {"github", "feishu", "webhook", "url"} and source_identifier and ":req-" not in source_identifier: - return True - if source_kind in {"pmflow", "custom", "browser"} and ( - not source_identifier or source_identifier.startswith(f"{source_kind}:req-") - ): - return False - return False - - @staticmethod - def _has_non_fallback_worker_result(worker_results: list[dict[str, Any]]) -> bool: - for item in worker_results: - if not isinstance(item, dict): - continue - status = str(item.get("status") or "").strip().lower() - if status != "completed": - continue - if bool(item.get("used_fallback")): - continue - return True - return False - - @staticmethod - def _extract_openclaw_execution_rows(openclaw_result: dict[str, Any]) -> list[dict[str, Any]]: - if not isinstance(openclaw_result, dict): - return [] - result = openclaw_result.get("result") if isinstance(openclaw_result.get("result"), dict) else openclaw_result - execution = result.get("execution") if isinstance(result, dict) and isinstance(result.get("execution"), dict) else {} - rows = execution.get("results") if isinstance(execution.get("results"), list) else [] - return [row for row in rows if isinstance(row, dict)] - - @classmethod - def _has_material_openclaw_execution(cls, openclaw_result: dict[str, Any]) -> bool: - for row in cls._extract_openclaw_execution_rows(openclaw_result): - status = str(row.get("status") or "").strip().lower() - if status not in {"applied", "skipped"}: - continue - operation_code = str(row.get("operation_code") or "").strip().lower() - if operation_code == "notification.create": - continue - return True - return False - - @classmethod - def _resolve_registry_activation( - cls, - *, - source_payload: dict[str, Any], - openclaw_result: dict[str, Any], - worker_results: list[dict[str, Any]], - ) -> tuple[str, str]: - actionable_config = cls._has_actionable_source_config(source_payload=source_payload) - has_real_worker = cls._has_non_fallback_worker_result(worker_results) - has_material_openclaw = cls._has_material_openclaw_execution(openclaw_result) - if actionable_config and has_real_worker and has_material_openclaw: - return "active", "completed" - return "pending", "pending_manual" - - @staticmethod - def _resolve_orchestration_status(*, openclaw_status: str, worker_results: list[dict[str, Any]]) -> str: - normalized_openclaw = str(openclaw_status or "").strip().lower() - worker_statuses = [str(item.get("status") or "").strip().lower() for item in worker_results] - has_worker_completed = any(status == "completed" for status in worker_statuses) - has_worker_failed = any(status == "failed" for status in worker_statuses) - if normalized_openclaw == "failed" and not has_worker_completed: - return "failed" - if has_worker_failed or normalized_openclaw == "failed": - return "partial" - if has_worker_completed or normalized_openclaw == "completed": - return "completed" - return "failed" - - @staticmethod - def _worker_counts(worker_results: list[dict[str, Any]]) -> tuple[int, int]: - completed = 0 - failed = 0 - for item in worker_results: - status = str(item.get("status") or "").strip().lower() - if status == "failed": - failed += 1 - continue - if status in {"completed", "planned", "skipped"}: - completed += 1 - return completed, failed - - async def _update_run_after_worker( - self, - *, - tenant_id: int, - trace_id: str, - summary: str, - planner_status: str, - planner_diagnostics: Optional[dict[str, Any]], - registry_result: dict[str, Any], - openclaw_result: dict[str, Any], - worker_role_keys: list[str], - worker_results: list[dict[str, Any]], - ) -> None: - run_service = AIOrchestrationRunService(self.db) - row = await run_service.get_run(tenant_id=tenant_id, trace_id=trace_id) - if row is None: - return - source_payload = row.result_payload.get("signal_source") if isinstance(row.result_payload, dict) else {} - completed_count, failed_count = self._worker_counts(worker_results) - completed_keys = { - str(item.get("worker_role_key") or "").strip() - for item in worker_results - if str(item.get("worker_role_key") or "").strip() - } - pending_workers = [key for key in worker_role_keys if key not in completed_keys] - status = "partial" if pending_workers else self._resolve_orchestration_status( - openclaw_status=str(openclaw_result.get("status") or ""), - worker_results=worker_results, - ) - result_payload = self._build_orchestration_result( - trace_id=trace_id, - response_mode=str(row.response_mode or "fast"), - planner_status=planner_status, - planner_diagnostics=planner_diagnostics, - summary=summary, - registry_result={ - "registry_id": (source_payload or {}).get("registry_id"), - "status": (source_payload or {}).get("status"), - }, - source_payload={ - "source_kind": (source_payload or {}).get("source_kind"), - "source_name": (source_payload or {}).get("source_name"), - "source_identifier": (source_payload or {}).get("source_identifier"), - }, - execution_result=openclaw_result, - worker_results=worker_results, - pending_workers=pending_workers, - status=status, - run_status="running" if pending_workers else status, - ) - await run_service.update_run( - tenant_id=tenant_id, - trace_id=trace_id, - status="running" if pending_workers else status, - planner_status=planner_status, - execution_status=str(openclaw_result.get("status") or ""), - worker_total=len(worker_role_keys), - worker_completed=completed_count, - worker_failed=failed_count, - summary=summary, - planner_diagnostics=planner_diagnostics or {}, - result_payload=result_payload, - completed_at=None if pending_workers else _utc_now_naive(), - ) - await self.db.commit() - - async def _finalize_orchestration_run( - self, - *, - tenant_id: int, - trace_id: str, - signal_id: Optional[int], - summary: str, - planner_status: str, - planner_diagnostics: Optional[dict[str, Any]], - registry_result: dict[str, Any], - openclaw_result: dict[str, Any], - worker_role_keys: list[str], - worker_results: list[dict[str, Any]], - ) -> dict[str, Any]: - run_service = AIOrchestrationRunService(self.db) - row = await run_service.get_run(tenant_id=tenant_id, trace_id=trace_id) - response_mode = str(row.response_mode or "fast") if row is not None else "fast" - source_payload = row.result_payload.get("signal_source") if row is not None and isinstance(row.result_payload, dict) else {} - final_status = self._resolve_orchestration_status( - openclaw_status=str(openclaw_result.get("status") or ""), - worker_results=worker_results, - ) - registry_status, onboarding_status = self._resolve_registry_activation( - source_payload=source_payload if isinstance(source_payload, dict) else {}, - openclaw_result=openclaw_result, - worker_results=worker_results, - ) - if final_status == "completed" and onboarding_status != "completed": - final_status = "pending_manual" - completed_count, failed_count = self._worker_counts(worker_results) - final_result = self._build_orchestration_result( - trace_id=trace_id, - response_mode=response_mode, - planner_status=planner_status, - planner_diagnostics=planner_diagnostics, - summary=summary, - registry_result={ - "registry_id": (source_payload or {}).get("registry_id"), - "status": registry_status, - "onboarding_status": onboarding_status, - }, - source_payload={ - "source_kind": (source_payload or {}).get("source_kind"), - "source_name": (source_payload or {}).get("source_name"), - "source_identifier": (source_payload or {}).get("source_identifier"), - }, - execution_result=openclaw_result, - worker_results=worker_results, - pending_workers=[], - status=final_status, - run_status=final_status, - ) - await run_service.update_run( - tenant_id=tenant_id, - trace_id=trace_id, - status=final_status, - planner_status=planner_status, - execution_status=str(openclaw_result.get("status") or ""), - worker_total=len(worker_role_keys), - worker_completed=completed_count, - worker_failed=failed_count, - summary=summary, - planner_diagnostics=planner_diagnostics or {}, - result_payload=final_result, - completed_at=_utc_now_naive(), - ) - if registry_result.get("registry_id") is not None: - registry_row = await self._load_signal_source_registry( - tenant_id=tenant_id, - registry_id=int(registry_result["registry_id"]), - ) - if registry_row is not None: - registry_row.last_signal_at = _utc_now_naive() - registry_row.last_worker_run_at = _utc_now_naive() - registry_row.status = registry_status - registry_row.onboarding_status = onboarding_status - if signal_id: - persisted_signal = await self._load_signal(tenant_id=tenant_id, signal_id=int(signal_id)) - if persisted_signal is not None: - await self._persist_signal_orchestration_result(signal=persisted_signal, orchestration_result=final_result) - await self.db.commit() - return final_result - - @staticmethod - def _build_worker_prompt( - *, - role: dict[str, Any], - project: Project, - signal: Signal, - source_payload: dict[str, Any], - openclaw_result: dict[str, Any], - ) -> str: - role_payload = { - "role_key": str(role.get("role_key") or "").strip(), - "role_name": str(role.get("role_name") or "").strip(), - "role_description": str(role.get("role_description") or "").strip(), - "system_prompt": str(role.get("system_prompt") or "").strip(), - } - project_payload = { - "project_id": int(project.id), - "title": str(project.title or project.name or "").strip(), - "description": str(project.description or "").strip()[:1000], - } - signal_payload = { - "signal_id": int(signal.id) if getattr(signal, "id", None) is not None else None, - "title": str(signal.title or "").strip(), - "summary": str(signal.summary or signal.content or "").strip()[:1200], - } - commander_payload = openclaw_result.get("result") if isinstance(openclaw_result.get("result"), dict) else openclaw_result - embedded_prompt = PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION.format( - role_json=json.dumps(role_payload, ensure_ascii=False), - project_json=json.dumps(project_payload, ensure_ascii=False), - source_json=json.dumps(source_payload, ensure_ascii=False), - signal_json=json.dumps(signal_payload, ensure_ascii=False), - commander_json=json.dumps(commander_payload or {}, ensure_ascii=False), - ) - system_prompt = str(role.get("system_prompt") or "").strip() - if system_prompt: - return f"[Role System Prompt]\n{system_prompt}\n\n{embedded_prompt}" - return embedded_prompt - - @staticmethod - def _normalize_worker_output(parsed: dict[str, Any], *, role_name: str, source_name: Any) -> dict[str, Any]: - deliverables = parsed.get("deliverables") if isinstance(parsed.get("deliverables"), list) else [] - risks = parsed.get("risks") if isinstance(parsed.get("risks"), list) else [] - next_actions = parsed.get("next_actions") if isinstance(parsed.get("next_actions"), list) else [] - return { - "summary": str(parsed.get("summary") or f"{role_name} 已完成 {source_name} 的接入分析。").strip()[:1000], - "deliverables": [str(item).strip()[:200] for item in deliverables if str(item).strip()][:4], - "risks": [str(item).strip()[:200] for item in risks if str(item).strip()][:4], - "next_actions": [str(item).strip()[:200] for item in next_actions if str(item).strip()][:4], - "used_fallback": False, - } - - async def _persist_signal_orchestration_result( - self, - *, - signal: Signal, - orchestration_result: dict[str, Any], - ) -> None: - strategic_payload: dict[str, Any] = {} - raw_strategic = signal.strategic_analysis - if isinstance(raw_strategic, dict): - strategic_payload = dict(raw_strategic) - elif isinstance(raw_strategic, str) and raw_strategic.strip(): - try: - parsed = json.loads(raw_strategic) - except Exception: - parsed = {} - if isinstance(parsed, dict): - strategic_payload = parsed - strategic_payload["signal_source_orchestration"] = orchestration_result - signal.strategic_analysis = json.dumps(strategic_payload, ensure_ascii=False) - orchestration_status = str(orchestration_result.get("status") or "").lower() - signal.processed = orchestration_status in {"completed", "partial"} - signal.status = self._map_orchestration_status_to_signal_status(orchestration_status) - signal.tasks_generated = json.dumps( - self._extract_task_operations(orchestration_result.get("execution")), - ensure_ascii=False, - ) - await self.db.commit() - - @staticmethod - def _extract_task_operations(openclaw_payload: Any) -> list[dict[str, Any]]: - if not isinstance(openclaw_payload, dict): - return [] - result = openclaw_payload.get("result") if isinstance(openclaw_payload.get("result"), dict) else openclaw_payload - execution = result.get("execution") if isinstance(result, dict) and isinstance(result.get("execution"), dict) else {} - rows = execution.get("results") if isinstance(execution.get("results"), list) else [] - tasks: list[dict[str, Any]] = [] - for row in rows: - if not isinstance(row, dict): - continue - operation_code = str(row.get("operation_code") or "").strip() - payload = row.get("payload") if isinstance(row.get("payload"), dict) else {} - if operation_code not in {"task.create", "task.assign", "task.prioritize", "task.status_transition"}: - continue - tasks.append( - { - "operation_code": operation_code, - "status": str(row.get("status") or "").strip(), - "entity_id": row.get("entity_id"), - "task_id": payload.get("task_id"), - "title": payload.get("title"), - "summary": str(row.get("message") or "").strip(), - } - ) - return tasks - - @staticmethod - def _map_orchestration_status_to_signal_status(status: str) -> str: - normalized = str(status or "").strip().lower() - if normalized == "completed": - return "已编排" - if normalized == "pending_manual": - return "待确认接入" - if normalized == "partial": - return "部分编排" - if normalized == "failed": - return "编排失败" - if normalized == "planned": - return "已规划" - if normalized == "skipped": - return "已跳过" - return "待处理" - - async def _resolve_operator_user( - self, - *, - tenant_id: int, - project: Project, - operator_user_id: Optional[int], - ) -> Optional[User]: - preferred_ids: list[int] = [] - if operator_user_id is not None: - preferred_ids.append(int(operator_user_id)) - if project.owner_id: - preferred_ids.append(int(project.owner_id)) - - for user_id in preferred_ids: - row = ( - await self.db.execute( - select(User).where( - and_( - User.tenant_id == tenant_id, - User.id == user_id, - User.is_active.is_(True), - ) - ) - ) - ).scalar_one_or_none() - if row is not None and str(row.role or "").strip().upper() in {"BOSS", "ADMIN"}: - return row - - return ( - await self.db.execute( - select(User).where( - and_( - User.tenant_id == tenant_id, - User.is_active.is_(True), - User.role.in_(["BOSS", "ADMIN"]), - ) - ).order_by(User.id.asc()).limit(1) - ) - ).scalar_one_or_none() - - @staticmethod - def _build_user_snapshot(user: Optional[User]) -> Optional[SimpleNamespace]: - if user is None: - return None - user_id = getattr(user, "id", None) - if user_id is None: - return None - return SimpleNamespace( - id=int(user_id), - name=str(getattr(user, "name", "") or "").strip(), - username=str(getattr(user, "username", "") or "").strip(), - role=str(getattr(user, "role", "") or "").strip(), - ) - - async def _load_project(self, *, tenant_id: int, project_id: int) -> Optional[Project]: - return ( - await self.db.execute( - select(Project).where( - and_( - Project.tenant_id == tenant_id, - Project.id == project_id, - ) - ) - ) - ).scalar_one_or_none() - - async def _load_signal(self, *, tenant_id: int, signal_id: int) -> Optional[Signal]: - return ( - await self.db.execute( - select(Signal).where( - and_( - Signal.tenant_id == tenant_id, - Signal.id == signal_id, - ) - ) - ) - ).scalar_one_or_none() - - async def _load_signal_source_registry( - self, - *, - tenant_id: int, - registry_id: int, - ) -> Optional[SignalSourceRegistry]: - return ( - await self.db.execute( - select(SignalSourceRegistry).where( - and_( - SignalSourceRegistry.tenant_id == tenant_id, - SignalSourceRegistry.id == registry_id, - ) - ) - ) - ).scalar_one_or_none() - - @staticmethod - def _build_ephemeral_signal(*, initiating_text: str) -> Signal: - signal = Signal( - tenant_id=1, - source="PMFlow", - content=initiating_text[:2000], - impact_score=0.7, - priority="medium", - processed=False, - title=initiating_text[:80] or "新增信号源", - signal_type="新需求", - summary=initiating_text[:300], - recommended_action="enter_only", - status="待处理", - ) - signal.id = 0 # type: ignore[assignment] - return signal - - @staticmethod - def _parse_json_object(raw: str) -> dict[str, Any]: - text = str(raw or "").strip() - if "```" in text: - match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S) - if match: - text = match.group(1) - if not text.startswith("{"): - match = re.search(r"(\{.*\})", text, re.S) - if match: - text = match.group(1) - parsed = json.loads(text) - if not isinstance(parsed, dict): - raise ValueError("json_object_required") - return parsed +__all__ = ["SignalSourceOrchestrationService"] diff --git a/backend/app/services/signal_source_repair/__init__.py b/backend/app/services/signal_source_repair/__init__.py new file mode 100644 index 00000000..1e1060fc --- /dev/null +++ b/backend/app/services/signal_source_repair/__init__.py @@ -0,0 +1,15 @@ +"""Signal source repair service exports.""" + +from .errors import ( + SignalSourceRepairError, + SignalSourceRepairMissingSignalError, + SignalSourceRepairTimeoutError, +) +from .service import SignalSourceRepairService + +__all__ = [ + "SignalSourceRepairError", + "SignalSourceRepairTimeoutError", + "SignalSourceRepairMissingSignalError", + "SignalSourceRepairService", +] diff --git a/backend/app/services/signal_source_repair/errors.py b/backend/app/services/signal_source_repair/errors.py new file mode 100644 index 00000000..e4793ea1 --- /dev/null +++ b/backend/app/services/signal_source_repair/errors.py @@ -0,0 +1,33 @@ +"""Error types for signal source repair service.""" + +from __future__ import annotations + + +class SignalSourceRepairError(RuntimeError): + """信号源修复失败。""" + + +class SignalSourceRepairTimeoutError(SignalSourceRepairError): + """信号源修复超时。""" + + def __init__(self, *, registry_id: int, stage: str, timeout_seconds: int) -> None: + super().__init__( + f"signal source repair timeout: registry_id={int(registry_id)}, " + f"stage={stage}, timeout_seconds={int(timeout_seconds)}" + ) + self.registry_id = int(registry_id) + self.stage = str(stage) + self.timeout_seconds = int(timeout_seconds) + + +class SignalSourceRepairMissingSignalError(SignalSourceRepairError): + """修复路径未拿到 signal_id。""" + + def __init__(self, *, registry_id: int, project_id: int, status: str) -> None: + super().__init__( + f"signal source repair failed: registry_id={int(registry_id)}, " + f"project_id={int(project_id)}, capture_status={status or 'unknown'}" + ) + self.registry_id = int(registry_id) + self.project_id = int(project_id) + self.status = str(status or "") diff --git a/backend/app/services/signal_source_repair/mixin_capture.py b/backend/app/services/signal_source_repair/mixin_capture.py new file mode 100644 index 00000000..f143413c --- /dev/null +++ b/backend/app/services/signal_source_repair/mixin_capture.py @@ -0,0 +1,50 @@ +"""Capture execution helper for signal source repair service.""" + +from __future__ import annotations + +from .errors import SignalSourceRepairTimeoutError +from .shared import ProactiveSignalCaptureService, SignalSourceRegistry, asyncio + + +class SignalSourceRepairCaptureMixin: + """Runs capture-feishu calls with timeout protections.""" + + async def _capture_registry( + self, + *, + capture_service: ProactiveSignalCaptureService, + tenant_id: int, + project_id: int, + sender: str, + row: SignalSourceRegistry, + stage: str, + timeout_seconds: int, + ) -> dict[str, object]: + message = ( + self._build_explicit_repair_message(project_id=project_id, row=row) + if stage == "explicit" + else self._build_repair_message( + project_id=project_id, + registry_id=int(row.id), + source_name=str(row.source_name or "信号源"), + ) + ) + coroutine = capture_service.capture_from_feishu( + group_chat_id=self._build_group_chat_id(row), + message_content=message, + sender=sender, + tenant_id=int(tenant_id), + feishu_message_id=self._build_capture_message_id( + project_id=project_id, + registry_id=int(row.id), + stage=stage, + ), + ) + try: + return await asyncio.wait_for(coroutine, timeout=timeout_seconds) + except asyncio.TimeoutError as exc: + raise SignalSourceRepairTimeoutError( + registry_id=int(row.id), + stage=stage, + timeout_seconds=timeout_seconds, + ) from exc diff --git a/backend/app/services/signal_source_repair/mixin_data_access.py b/backend/app/services/signal_source_repair/mixin_data_access.py new file mode 100644 index 00000000..2e0497fe --- /dev/null +++ b/backend/app/services/signal_source_repair/mixin_data_access.py @@ -0,0 +1,81 @@ +"""Data-access helpers for signal source repair service.""" + +from __future__ import annotations + +from .shared import Optional, SignalSourceRegistry, User, and_, select + + +class SignalSourceRepairDataAccessMixin: + """Database read helpers for repair workflow.""" + + async def _load_registry( + self, + *, + tenant_id: int, + project_id: int, + registry_id: int, + ) -> Optional[SignalSourceRegistry]: + return ( + await self.db.execute( + select(SignalSourceRegistry) + .where( + and_( + SignalSourceRegistry.tenant_id == int(tenant_id), + SignalSourceRegistry.project_id == int(project_id), + SignalSourceRegistry.id == int(registry_id), + ) + ) + .limit(1) + ) + ).scalar_one_or_none() + + async def _find_reusable_signal_id( + self, + *, + tenant_id: int, + project_id: int, + row: SignalSourceRegistry, + ) -> Optional[int]: + target_key = self._normalize_identifier_key(row.source_identifier) + if not target_key: + return None + candidates = ( + await self.db.execute( + select(SignalSourceRegistry) + .where( + and_( + SignalSourceRegistry.tenant_id == int(tenant_id), + SignalSourceRegistry.project_id == int(project_id), + SignalSourceRegistry.id != int(row.id), + SignalSourceRegistry.signal_id.is_not(None), + ) + ) + .order_by(SignalSourceRegistry.created_at.desc(), SignalSourceRegistry.id.desc()) + .limit(50) + ) + ).scalars().all() + for candidate in candidates: + current_key = self._normalize_identifier_key(candidate.source_identifier) + if current_key and current_key == target_key: + return self._safe_int(candidate.signal_id) + for candidate in candidates: + current_key = self._normalize_identifier_key(candidate.source_identifier) + if current_key and ( + current_key.endswith(target_key) + or target_key.endswith(current_key) + ): + return self._safe_int(candidate.signal_id) + return None + + async def _active_user_ids(self, *, tenant_id: int) -> list[int]: + rows = ( + await self.db.execute( + select(User.id).where( + and_( + User.tenant_id == int(tenant_id), + User.is_active.is_(True), + ) + ) + ) + ).scalars().all() + return [int(item) for item in rows if item is not None] diff --git a/backend/app/services/signal_source_repair/mixin_persist.py b/backend/app/services/signal_source_repair/mixin_persist.py new file mode 100644 index 00000000..1e2a20ee --- /dev/null +++ b/backend/app/services/signal_source_repair/mixin_persist.py @@ -0,0 +1,105 @@ +"""Persistence and visibility logging helpers for repair workflow.""" + +from __future__ import annotations + +from .shared import ( + NotificationService, + SignalSourceRegistry, + get_audit_context, + log_operation, + now_cn_naive, +) + + +class SignalSourceRepairPersistMixin: + """Persist repaired links and emit visibility records.""" + + async def _emit_visibility_records( + self, + *, + tenant_id: int, + project_id: int, + row: SignalSourceRegistry, + signal_id: int, + capture_status: str, + ) -> None: + dedupe_key = f"signal-source-repair:{int(tenant_id)}:{int(row.id)}:{int(signal_id)}" + await NotificationService(self.db).create_notifications( + tenant_id=int(tenant_id), + user_ids=await self._active_user_ids(tenant_id=tenant_id), + category="system", + title="AI 已修复历史信号源关联", + content=( + f"项目ID: {int(project_id)}\n" + f"信号源ID: {int(row.id)}\n" + f"signal_id: {int(signal_id)}\n" + f"status: {capture_status or 'relinked'}" + ), + source="ai_steward_repair", + entity_type="signal_source_registry", + entity_id=int(row.id), + kind="info", + priority="medium", + status="notified", + recommended_action="view_result", + dedupe_key=dedupe_key, + payload={ + "project_id": int(project_id), + "registry_id": int(row.id), + "signal_id": int(signal_id), + "capture_status": capture_status or "relinked", + }, + commit=False, + ) + context = get_audit_context() + await log_operation( + self.db, + tenant_id=int(tenant_id), + user_id=int(context.user_id or 1), + project_id=int(project_id), + entity_type="signal_source_registry", + entity_id=str(int(row.id)), + action="signal_source_registry.repair_link", + after_data={ + "signal_id": int(signal_id), + "capture_status": capture_status or "relinked", + }, + remark="repair dirty signal source registry link", + ) + + async def _persist_registry_link( + self, + *, + tenant_id: int, + project_id: int, + row: SignalSourceRegistry, + signal_id: int, + capture_status: str, + ) -> None: + row.signal_id = int(signal_id) + row.last_signal_at = now_cn_naive() + await self._emit_visibility_records( + tenant_id=tenant_id, + project_id=project_id, + row=row, + signal_id=signal_id, + capture_status=capture_status, + ) + await self.db.flush() + + def _build_item_result( + self, + *, + row: SignalSourceRegistry, + signal_id: int | None, + capture_status: str, + work_item_id: int | None, + route_status: str, + ) -> dict[str, object]: + return { + "registry_id": int(row.id), + "signal_id": signal_id, + "capture_status": capture_status, + "work_item_id": work_item_id, + "route_status": route_status, + } diff --git a/backend/app/services/signal_source_repair/mixin_repair.py b/backend/app/services/signal_source_repair/mixin_repair.py new file mode 100644 index 00000000..ab61c34e --- /dev/null +++ b/backend/app/services/signal_source_repair/mixin_repair.py @@ -0,0 +1,136 @@ +"""Repair orchestration for signal source repair service.""" + +from __future__ import annotations + +from .errors import SignalSourceRepairMissingSignalError +from .shared import ProactiveSignalCaptureService + + +class SignalSourceRepairFlowMixin: + """Runs single/batch repair orchestration.""" + + async def _repair_single_registry( + self, + *, + capture_service: ProactiveSignalCaptureService, + tenant_id: int, + project_id: int, + registry_id: int, + sender: str, + timeout_seconds: int, + ) -> dict[str, object]: + row = await self._load_registry( + tenant_id=tenant_id, + project_id=project_id, + registry_id=registry_id, + ) + if row is None: + return {"registry_id": int(registry_id), "status": "not_found"} + if row.signal_id is not None: + return self._build_item_result( + row=row, + signal_id=self._safe_int(row.signal_id), + capture_status="already_linked", + work_item_id=None, + route_status="", + ) + + reusable_signal_id = await self._find_reusable_signal_id( + tenant_id=tenant_id, + project_id=project_id, + row=row, + ) + if reusable_signal_id is not None: + await self._persist_registry_link( + tenant_id=tenant_id, + project_id=project_id, + row=row, + signal_id=int(reusable_signal_id), + capture_status="relinked", + ) + return self._build_item_result( + row=row, + signal_id=int(reusable_signal_id), + capture_status="relinked", + work_item_id=None, + route_status="", + ) + + capture_result = await self._capture_registry( + capture_service=capture_service, + tenant_id=tenant_id, + project_id=project_id, + sender=sender, + row=row, + stage="base", + timeout_seconds=timeout_seconds, + ) + signal_id = self._extract_signal_id(capture_result) + if signal_id is None: + capture_result = await self._capture_registry( + capture_service=capture_service, + tenant_id=tenant_id, + project_id=project_id, + sender=sender, + row=row, + stage="explicit", + timeout_seconds=timeout_seconds, + ) + signal_id = self._extract_signal_id(capture_result) + + if signal_id is None: + signal_id = await self._find_reusable_signal_id( + tenant_id=tenant_id, + project_id=project_id, + row=row, + ) + if signal_id is None: + raise SignalSourceRepairMissingSignalError( + registry_id=int(row.id), + project_id=int(project_id), + status=str(capture_result.get("status") or ""), + ) + + capture_status = str(capture_result.get("status") or "captured") + await self._persist_registry_link( + tenant_id=tenant_id, + project_id=project_id, + row=row, + signal_id=int(signal_id), + capture_status=capture_status, + ) + return self._build_item_result( + row=row, + signal_id=int(signal_id), + capture_status=capture_status, + work_item_id=self._safe_int(capture_result.get("work_item_id")), + route_status=self._extract_route_status(capture_result), + ) + + async def repair_registries( + self, + *, + tenant_id: int, + project_id: int, + registry_ids: list[int], + sender: str, + ) -> list[dict[str, object]]: + capture_service = ProactiveSignalCaptureService(self.db) + timeout_seconds = self._resolve_timeout_seconds() + results: list[dict[str, object]] = [] + try: + for registry_id in registry_ids: + item = await self._repair_single_registry( + capture_service=capture_service, + tenant_id=tenant_id, + project_id=project_id, + registry_id=int(registry_id), + sender=sender, + timeout_seconds=timeout_seconds, + ) + results.append(item) + await self.db.commit() + except Exception: + await self.db.rollback() + raise + return results diff --git a/backend/app/services/signal_source_repair/mixin_utils.py b/backend/app/services/signal_source_repair/mixin_utils.py new file mode 100644 index 00000000..bdd57044 --- /dev/null +++ b/backend/app/services/signal_source_repair/mixin_utils.py @@ -0,0 +1,101 @@ +"""Utility helpers for signal source repair service.""" + +from __future__ import annotations + +from .shared import Any, Optional, SignalSourceRegistry, os, re + + +class SignalSourceRepairUtilsMixin: + """Common utility helpers for repair workflow.""" + + @staticmethod + def _safe_int(value: Any) -> Optional[int]: + try: + if value is None: + return None + return int(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _resolve_timeout_seconds() -> int: + raw = ( + os.getenv("SIGNAL_SOURCE_REPAIR_TIMEOUT_SECONDS") + or os.getenv("LLM_REQUEST_TIMEOUT") + or "60" + ) + try: + timeout = int(float(raw)) + except (TypeError, ValueError): + timeout = 60 + return max(10, min(timeout, 300)) + + @staticmethod + def _build_repair_message(*, project_id: int, registry_id: int, source_name: str) -> str: + return ( + f"给项目ID: {project_id} 补录历史接入记录 registry#{registry_id}({source_name})," + "请仅执行入库,不需要二次处理。" + ) + + @staticmethod + def _build_explicit_repair_message(*, project_id: int, row: SignalSourceRegistry) -> str: + source_kind = str(row.source_kind or "").strip()[:40] + source_name = str(row.source_name or "历史信号源").strip()[:80] + source_identifier = str(row.source_identifier or "").strip()[:180] + return ( + f"项目 {int(project_id)} 新增信号源:{source_name}。" + f"source_kind={source_kind}; source_identifier={source_identifier}。" + "这是历史脏数据补录,请执行 signal_source.onboard 入库并生成消息台记录。" + ) + + @staticmethod + def _build_capture_message_id(*, project_id: int, registry_id: int, stage: str) -> str: + phase = "explicit" if str(stage or "").strip().lower() == "explicit" else "base" + return f"om_registry_repair_{int(project_id)}_{int(registry_id)}_{phase}"[:120] + + @staticmethod + def _build_group_chat_id(row: SignalSourceRegistry) -> str: + raw = (row.config or {}).get("chat_id") or row.source_identifier or f"repair_registry_{int(row.id)}" + return str(raw)[:120] + + @staticmethod + def _extract_route_status(capture_result: dict[str, Any]) -> str: + rows = capture_result.get("results") + if not isinstance(rows, list) or not rows: + return "" + first = rows[0] if isinstance(rows[0], dict) else {} + return str(first.get("route_status") or "") + + @classmethod + def _extract_signal_id(cls, capture_result: dict[str, Any]) -> Optional[int]: + candidates: list[Any] = [capture_result.get("signal_id")] + for key in ("signal_source_workflow", "signal_source_orchestration", "ai_execution"): + payload = capture_result.get(key) + if isinstance(payload, dict): + candidates.append(payload.get("signal_id")) + rows = capture_result.get("results") + if isinstance(rows, list) and rows and isinstance(rows[0], dict): + candidates.append(rows[0].get("signal_id")) + for item in candidates: + value = cls._safe_int(item) + if value is not None: + return value + return None + + @staticmethod + def _normalize_identifier_key(value: Any) -> str: + key = re.sub(r"\s+", "", str(value or "").strip().lower()) + if not key: + return "" + for prefix in ( + "feishu:chat:", + "wecom:chat:", + "feishu_chat_", + "wecom_chat_", + "feishu:", + "wecom:", + ): + if key.startswith(prefix): + key = key[len(prefix) :] + break + return key[:180] diff --git a/backend/app/services/signal_source_repair/service.py b/backend/app/services/signal_source_repair/service.py new file mode 100644 index 00000000..899b92fe --- /dev/null +++ b/backend/app/services/signal_source_repair/service.py @@ -0,0 +1,23 @@ +"""Signal source repair service composition.""" + +from __future__ import annotations + +from .mixin_capture import SignalSourceRepairCaptureMixin +from .mixin_data_access import SignalSourceRepairDataAccessMixin +from .mixin_persist import SignalSourceRepairPersistMixin +from .mixin_repair import SignalSourceRepairFlowMixin +from .mixin_utils import SignalSourceRepairUtilsMixin +from .shared import AsyncSession + + +class SignalSourceRepairService( + SignalSourceRepairUtilsMixin, + SignalSourceRepairDataAccessMixin, + SignalSourceRepairCaptureMixin, + SignalSourceRepairPersistMixin, + SignalSourceRepairFlowMixin, +): + """通过 capture-feishu 回填历史 registry.signal_id。""" + + def __init__(self, db: AsyncSession) -> None: + self.db = db diff --git a/backend/app/services/signal_source_repair/shared.py b/backend/app/services/signal_source_repair/shared.py new file mode 100644 index 00000000..47f069a8 --- /dev/null +++ b/backend/app/services/signal_source_repair/shared.py @@ -0,0 +1,36 @@ +"""Shared imports for signal source repair service.""" + +from __future__ import annotations + +import asyncio +import os +import re +from typing import Any, Optional + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...models import SignalSourceRegistry, User +from ...utils.audited_write import get_audit_context +from ...utils.timezone import now_cn_naive +from ..notification_service import NotificationService +from ..operation_log_service import log_operation +from ..signal_capture import ProactiveSignalCaptureService + +__all__ = [ + "Any", + "AsyncSession", + "NotificationService", + "Optional", + "ProactiveSignalCaptureService", + "SignalSourceRegistry", + "User", + "and_", + "asyncio", + "get_audit_context", + "log_operation", + "now_cn_naive", + "os", + "re", + "select", +] diff --git a/backend/app/services/signal_source_repair_service.py b/backend/app/services/signal_source_repair_service.py index 987d66be..fdb63440 100644 --- a/backend/app/services/signal_source_repair_service.py +++ b/backend/app/services/signal_source_repair_service.py @@ -1,465 +1,15 @@ -"""信号源脏数据修复服务。""" +"""Compatibility exports for signal source repair service.""" -from __future__ import annotations +from .signal_source_repair import ( + SignalSourceRepairError, + SignalSourceRepairMissingSignalError, + SignalSourceRepairService, + SignalSourceRepairTimeoutError, +) -import asyncio -import os -import re -from typing import Any, Optional - -from sqlalchemy import and_, select -from sqlalchemy.ext.asyncio import AsyncSession - -from ..models import SignalSourceRegistry, User -from ..utils.audited_write import get_audit_context -from ..utils.timezone import now_cn_naive -from .notification_service import NotificationService -from .operation_log_service import log_operation -from .signal_capture import ProactiveSignalCaptureService - - -class SignalSourceRepairError(RuntimeError): - """信号源修复失败。""" - - -class SignalSourceRepairTimeoutError(SignalSourceRepairError): - """信号源修复超时。""" - - def __init__(self, *, registry_id: int, stage: str, timeout_seconds: int) -> None: - super().__init__( - f"signal source repair timeout: registry_id={int(registry_id)}, " - f"stage={stage}, timeout_seconds={int(timeout_seconds)}" - ) - self.registry_id = int(registry_id) - self.stage = str(stage) - self.timeout_seconds = int(timeout_seconds) - - -class SignalSourceRepairMissingSignalError(SignalSourceRepairError): - """修复路径未拿到 signal_id。""" - - def __init__(self, *, registry_id: int, project_id: int, status: str) -> None: - super().__init__( - f"signal source repair failed: registry_id={int(registry_id)}, " - f"project_id={int(project_id)}, capture_status={status or 'unknown'}" - ) - self.registry_id = int(registry_id) - self.project_id = int(project_id) - self.status = str(status or "") - - -class SignalSourceRepairService: - """通过 capture-feishu 接口补录信号并回填历史 registry.signal_id。""" - - def __init__(self, db: AsyncSession) -> None: - self.db = db - - @staticmethod - def _safe_int(value: Any) -> Optional[int]: - try: - if value is None: - return None - return int(value) - except (TypeError, ValueError): - return None - - @staticmethod - def _resolve_timeout_seconds() -> int: - raw = ( - os.getenv("SIGNAL_SOURCE_REPAIR_TIMEOUT_SECONDS") - or os.getenv("LLM_REQUEST_TIMEOUT") - or "60" - ) - try: - timeout = int(float(raw)) - except (TypeError, ValueError): - timeout = 60 - return max(10, min(timeout, 300)) - - @staticmethod - def _build_repair_message(*, project_id: int, registry_id: int, source_name: str) -> str: - return ( - f"给项目ID: {project_id} 补录历史接入记录 registry#{registry_id}({source_name})," - "请仅执行入库,不需要二次处理。" - ) - - @staticmethod - def _build_explicit_repair_message(*, project_id: int, row: SignalSourceRegistry) -> str: - source_kind = str(row.source_kind or "").strip()[:40] - source_name = str(row.source_name or "历史信号源").strip()[:80] - source_identifier = str(row.source_identifier or "").strip()[:180] - return ( - f"项目 {int(project_id)} 新增信号源:{source_name}。" - f"source_kind={source_kind}; source_identifier={source_identifier}。" - "这是历史脏数据补录,请执行 signal_source.onboard 入库并生成消息台记录。" - ) - - @staticmethod - def _build_capture_message_id(*, project_id: int, registry_id: int, stage: str) -> str: - phase = "explicit" if str(stage or "").strip().lower() == "explicit" else "base" - return f"om_registry_repair_{int(project_id)}_{int(registry_id)}_{phase}"[:120] - - async def _load_registry( - self, - *, - tenant_id: int, - project_id: int, - registry_id: int, - ) -> Optional[SignalSourceRegistry]: - return ( - await self.db.execute( - select(SignalSourceRegistry).where( - and_( - SignalSourceRegistry.tenant_id == int(tenant_id), - SignalSourceRegistry.project_id == int(project_id), - SignalSourceRegistry.id == int(registry_id), - ) - ).limit(1) - ) - ).scalar_one_or_none() - - @staticmethod - def _build_group_chat_id(row: SignalSourceRegistry) -> str: - raw = (row.config or {}).get("chat_id") or row.source_identifier or f"repair_registry_{int(row.id)}" - return str(raw)[:120] - - @staticmethod - def _extract_route_status(capture_result: dict[str, Any]) -> str: - rows = capture_result.get("results") - if not isinstance(rows, list) or not rows: - return "" - first = rows[0] if isinstance(rows[0], dict) else {} - return str(first.get("route_status") or "") - - @classmethod - def _extract_signal_id(cls, capture_result: dict[str, Any]) -> Optional[int]: - candidates: list[Any] = [capture_result.get("signal_id")] - for key in ("signal_source_workflow", "signal_source_orchestration", "ai_execution"): - payload = capture_result.get(key) - if isinstance(payload, dict): - candidates.append(payload.get("signal_id")) - rows = capture_result.get("results") - if isinstance(rows, list) and rows and isinstance(rows[0], dict): - candidates.append(rows[0].get("signal_id")) - for item in candidates: - value = cls._safe_int(item) - if value is not None: - return value - return None - - @staticmethod - def _normalize_identifier_key(value: Any) -> str: - key = re.sub(r"\s+", "", str(value or "").strip().lower()) - if not key: - return "" - for prefix in ( - "feishu:chat:", - "wecom:chat:", - "feishu_chat_", - "wecom_chat_", - "feishu:", - "wecom:", - ): - if key.startswith(prefix): - key = key[len(prefix):] - break - return key[:180] - - async def _find_reusable_signal_id( - self, - *, - tenant_id: int, - project_id: int, - row: SignalSourceRegistry, - ) -> Optional[int]: - target_key = self._normalize_identifier_key(row.source_identifier) - if not target_key: - return None - candidates = ( - await self.db.execute( - select(SignalSourceRegistry).where( - and_( - SignalSourceRegistry.tenant_id == int(tenant_id), - SignalSourceRegistry.project_id == int(project_id), - SignalSourceRegistry.id != int(row.id), - SignalSourceRegistry.signal_id.is_not(None), - ) - ) - .order_by(SignalSourceRegistry.created_at.desc(), SignalSourceRegistry.id.desc()) - .limit(50) - ) - ).scalars().all() - for candidate in candidates: - current_key = self._normalize_identifier_key(candidate.source_identifier) - if current_key and current_key == target_key: - return self._safe_int(candidate.signal_id) - for candidate in candidates: - current_key = self._normalize_identifier_key(candidate.source_identifier) - if current_key and (current_key.endswith(target_key) or target_key.endswith(current_key)): - return self._safe_int(candidate.signal_id) - return None - - async def _capture_registry( - self, - *, - capture_service: ProactiveSignalCaptureService, - tenant_id: int, - project_id: int, - sender: str, - row: SignalSourceRegistry, - stage: str, - timeout_seconds: int, - ) -> dict[str, Any]: - message = ( - self._build_explicit_repair_message(project_id=project_id, row=row) - if stage == "explicit" - else self._build_repair_message( - project_id=project_id, - registry_id=int(row.id), - source_name=str(row.source_name or "信号源"), - ) - ) - coroutine = capture_service.capture_from_feishu( - group_chat_id=self._build_group_chat_id(row), - message_content=message, - sender=sender, - tenant_id=int(tenant_id), - feishu_message_id=self._build_capture_message_id( - project_id=project_id, - registry_id=int(row.id), - stage=stage, - ), - ) - try: - return await asyncio.wait_for(coroutine, timeout=timeout_seconds) - except asyncio.TimeoutError as exc: - raise SignalSourceRepairTimeoutError( - registry_id=int(row.id), - stage=stage, - timeout_seconds=timeout_seconds, - ) from exc - - async def _active_user_ids(self, *, tenant_id: int) -> list[int]: - rows = ( - await self.db.execute( - select(User.id).where( - and_( - User.tenant_id == int(tenant_id), - User.is_active.is_(True), - ) - ) - ) - ).scalars().all() - return [int(item) for item in rows if item is not None] - - async def _emit_visibility_records( - self, - *, - tenant_id: int, - project_id: int, - row: SignalSourceRegistry, - signal_id: int, - capture_status: str, - ) -> None: - dedupe_key = f"signal-source-repair:{int(tenant_id)}:{int(row.id)}:{int(signal_id)}" - await NotificationService(self.db).create_notifications( - tenant_id=int(tenant_id), - user_ids=await self._active_user_ids(tenant_id=tenant_id), - category="system", - title="AI 已修复历史信号源关联", - content=( - f"项目ID: {int(project_id)}\n" - f"信号源ID: {int(row.id)}\n" - f"signal_id: {int(signal_id)}\n" - f"status: {capture_status or 'relinked'}" - ), - source="ai_steward_repair", - entity_type="signal_source_registry", - entity_id=int(row.id), - kind="info", - priority="medium", - status="notified", - recommended_action="view_result", - dedupe_key=dedupe_key, - payload={ - "project_id": int(project_id), - "registry_id": int(row.id), - "signal_id": int(signal_id), - "capture_status": capture_status or "relinked", - }, - commit=False, - ) - context = get_audit_context() - await log_operation( - self.db, - tenant_id=int(tenant_id), - user_id=int(context.user_id or 1), - project_id=int(project_id), - entity_type="signal_source_registry", - entity_id=str(int(row.id)), - action="signal_source_registry.repair_link", - after_data={ - "signal_id": int(signal_id), - "capture_status": capture_status or "relinked", - }, - remark="repair dirty signal source registry link", - ) - - async def _persist_registry_link( - self, - *, - tenant_id: int, - project_id: int, - row: SignalSourceRegistry, - signal_id: int, - capture_status: str, - ) -> None: - row.signal_id = int(signal_id) - row.last_signal_at = now_cn_naive() - await self._emit_visibility_records( - tenant_id=tenant_id, - project_id=project_id, - row=row, - signal_id=signal_id, - capture_status=capture_status, - ) - await self.db.flush() - - def _build_item_result( - self, - *, - row: SignalSourceRegistry, - signal_id: Optional[int], - capture_status: str, - work_item_id: Optional[int], - route_status: str, - ) -> dict[str, Any]: - return { - "registry_id": int(row.id), - "signal_id": signal_id, - "capture_status": capture_status, - "work_item_id": work_item_id, - "route_status": route_status, - } - - async def _repair_single_registry( - self, - *, - capture_service: ProactiveSignalCaptureService, - tenant_id: int, - project_id: int, - registry_id: int, - sender: str, - timeout_seconds: int, - ) -> dict[str, Any]: - row = await self._load_registry( - tenant_id=tenant_id, - project_id=project_id, - registry_id=registry_id, - ) - if row is None: - return {"registry_id": int(registry_id), "status": "not_found"} - if row.signal_id is not None: - return self._build_item_result( - row=row, - signal_id=self._safe_int(row.signal_id), - capture_status="already_linked", - work_item_id=None, - route_status="", - ) - reusable_signal_id = await self._find_reusable_signal_id( - tenant_id=tenant_id, - project_id=project_id, - row=row, - ) - if reusable_signal_id is not None: - await self._persist_registry_link( - tenant_id=tenant_id, - project_id=project_id, - row=row, - signal_id=int(reusable_signal_id), - capture_status="relinked", - ) - return self._build_item_result( - row=row, - signal_id=int(reusable_signal_id), - capture_status="relinked", - work_item_id=None, - route_status="", - ) - capture_result = await self._capture_registry( - capture_service=capture_service, - tenant_id=tenant_id, - project_id=project_id, - sender=sender, - row=row, - stage="base", - timeout_seconds=timeout_seconds, - ) - signal_id = self._extract_signal_id(capture_result) - if signal_id is None: - capture_result = await self._capture_registry( - capture_service=capture_service, - tenant_id=tenant_id, - project_id=project_id, - sender=sender, - row=row, - stage="explicit", - timeout_seconds=timeout_seconds, - ) - signal_id = self._extract_signal_id(capture_result) - if signal_id is None: - signal_id = await self._find_reusable_signal_id( - tenant_id=tenant_id, - project_id=project_id, - row=row, - ) - if signal_id is None: - raise SignalSourceRepairMissingSignalError( - registry_id=int(row.id), - project_id=int(project_id), - status=str(capture_result.get("status") or ""), - ) - capture_status = str(capture_result.get("status") or "captured") - await self._persist_registry_link( - tenant_id=tenant_id, - project_id=project_id, - row=row, - signal_id=int(signal_id), - capture_status=capture_status, - ) - return self._build_item_result( - row=row, - signal_id=int(signal_id), - capture_status=capture_status, - work_item_id=self._safe_int(capture_result.get("work_item_id")), - route_status=self._extract_route_status(capture_result), - ) - - async def repair_registries( - self, - *, - tenant_id: int, - project_id: int, - registry_ids: list[int], - sender: str, - ) -> list[dict[str, Any]]: - capture_service = ProactiveSignalCaptureService(self.db) - timeout_seconds = self._resolve_timeout_seconds() - results: list[dict[str, Any]] = [] - try: - for registry_id in registry_ids: - item = await self._repair_single_registry( - capture_service=capture_service, - tenant_id=tenant_id, - project_id=project_id, - registry_id=int(registry_id), - sender=sender, - timeout_seconds=timeout_seconds, - ) - results.append(item) - await self.db.commit() - except Exception: - await self.db.rollback() - raise - return results +__all__ = [ + "SignalSourceRepairError", + "SignalSourceRepairTimeoutError", + "SignalSourceRepairMissingSignalError", + "SignalSourceRepairService", +] diff --git a/backend/app/services/skill/__init__.py b/backend/app/services/skill/__init__.py new file mode 100644 index 00000000..5ac87446 --- /dev/null +++ b/backend/app/services/skill/__init__.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from .bindings import * +from .tag_crud import * +from .workload_recommend import * + +__all__ = [ + "SYSTEM_SKILL_TAGS", + "init_system_skill_tags", + "create_skill_tag", + "list_skill_tags", + "get_skill_tag", + "update_skill_tag", + "delete_skill_tag", + "init_user_skill_bindings", + "batch_update_user_skill_bindings", + "get_user_skill_bindings", + "get_skill_tag_user_bindings", + "get_user_skill_task_history", + "update_user_workload", + "recommend_personnel", +] diff --git a/backend/app/services/skill/bindings.py b/backend/app/services/skill/bindings.py new file mode 100644 index 00000000..11a6a9d7 --- /dev/null +++ b/backend/app/services/skill/bindings.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +from .shared import * + +async def init_user_skill_bindings( + db: AsyncSession, + tenant_id: int, + user_id: int, + *, + include_system_only: bool = True, + commit: bool = False, +) -> int: + """ + 初始化用户技能绑定(空基线)。 + + 为用户补齐未绑定的标签,默认 `beginner + proficiency=0`。 + """ + tags_query = select(SkillTag.id).where(SkillTag.tenant_id == tenant_id) + if include_system_only: + tags_query = tags_query.where(SkillTag.is_system == True) + tag_rows = await db.execute(tags_query) + tag_ids = [int(v) for v in tag_rows.scalars().all()] + if not tag_ids: + return 0 + + existing_rows = await db.execute( + select(UserSkillBinding.skill_tag_id).where( + and_( + UserSkillBinding.tenant_id == tenant_id, + UserSkillBinding.user_id == user_id, + UserSkillBinding.skill_tag_id.in_(tag_ids), + ) + ) + ) + existing_ids = {int(v) for v in existing_rows.scalars().all()} + + created = 0 + for skill_tag_id in tag_ids: + if skill_tag_id in existing_ids: + continue + db.add( + UserSkillBinding( + tenant_id=tenant_id, + user_id=user_id, + skill_tag_id=skill_tag_id, + proficiency_level="beginner", + level="beginner", + proficiency=0, + quality_score=0.0, + efficiency_score=0.0, + ) + ) + created += 1 + + if created: + await db.flush() + if commit: + await db.commit() + return created + +async def batch_update_user_skill_bindings( + db: AsyncSession, + tenant_id: int, + user_id: int, + bindings: List[dict], +) -> List[UserSkillBinding]: + """ + 批量更新用户技能绑定。 + 若 skill_tag_id 已存在则更新,否则创建。 + """ + result_bindings = [] + + for item in bindings: + skill_tag_id = item.get("skill_tag_id") + if skill_tag_id is None: + continue + if isinstance(skill_tag_id, str): + try: + skill_tag_id = int(skill_tag_id) + except ValueError: + continue + + proficiency = item.get("proficiency", 50) + level = item.get("level", "intermediate") + years = item.get("years_experience") + + existing = await db.execute( + select(UserSkillBinding).where( + and_( + UserSkillBinding.tenant_id == tenant_id, + UserSkillBinding.user_id == user_id, + UserSkillBinding.skill_tag_id == skill_tag_id, + ) + ) + ) + binding = existing.scalar_one_or_none() + + if binding: + binding.proficiency = proficiency + binding.level = level + binding.years_experience = years + binding.proficiency_level = level + else: + binding = UserSkillBinding( + tenant_id=tenant_id, + user_id=user_id, + skill_tag_id=skill_tag_id, + proficiency_level=level, + proficiency=proficiency, + level=level, + years_experience=years, + ) + db.add(binding) + + result_bindings.append(binding) + + await db.commit() + for b in result_bindings: + await db.refresh(b) + await AIPMOKernelService.invalidate_world_model_cache_for_user( + db=db, + tenant_id=tenant_id, + user_id=user_id, + ) + return result_bindings + +async def get_user_skill_bindings( + db: AsyncSession, + tenant_id: int, + user_id: int, +) -> List[UserSkillBinding]: + """按用户查询技能绑定""" + result = await db.execute( + select(UserSkillBinding) + .options(selectinload(UserSkillBinding.skill_tag)) + .where( + and_( + UserSkillBinding.tenant_id == tenant_id, + UserSkillBinding.user_id == user_id, + ) + ) + .order_by(UserSkillBinding.proficiency.desc()) + ) + return list(result.scalars().all()) + +async def get_skill_tag_user_bindings( + db: AsyncSession, + tenant_id: int, + skill_tag_id: int, +) -> List[UserSkillBinding]: + """按技能标签查询用户绑定""" + result = await db.execute( + select(UserSkillBinding) + .options( + selectinload(UserSkillBinding.user), + selectinload(UserSkillBinding.skill_tag), + ) + .where( + and_( + UserSkillBinding.tenant_id == tenant_id, + UserSkillBinding.skill_tag_id == skill_tag_id, + ) + ) + .order_by(UserSkillBinding.proficiency.desc()) + ) + return list(result.scalars().all()) + +async def get_user_skill_task_history( + db: AsyncSession, + tenant_id: int, + user_id: int, + skill_tag_id: int, + limit: int = 50, +) -> List[dict]: + """ + 查询用户某技能下的历史任务。 + + 口径: + - assignee_id == user_id + - required_skill_tag_ids 包含 skill_tag_id + """ + result = await db.execute( + select(Task) + .where( + and_( + Task.tenant_id == tenant_id, + Task.assignee_id == user_id, + Task.required_skill_tag_ids.contains([skill_tag_id]), + ) + ) + .order_by(Task.updated_at.desc()) + .limit(limit) + ) + filtered = list(result.scalars().all()) + + return [ + { + "id": str(t.id), + "title": t.title, + "description": t.description, + "status": t.status, + "priority": t.priority, + "estimated_hours": float(t.estimated_hours) if t.estimated_hours is not None else None, + "project_id": str(t.project_id) if t.project_id is not None else None, + "truth_node_id": str(t.truth_node_id) if t.truth_node_id is not None else None, + "updated_at": t.updated_at.isoformat() if t.updated_at else None, + } + for t in filtered + ] + diff --git a/backend/app/services/skill/shared.py b/backend/app/services/skill/shared.py new file mode 100644 index 00000000..babc81e5 --- /dev/null +++ b/backend/app/services/skill/shared.py @@ -0,0 +1,38 @@ +""" +PMFlow 人员能力图谱 - 技能标签与用户技能绑定服务 + +- 系统预设 AI 开发技能标签初始化 +- SkillTag CRUD(系统标签禁止删改) +- UserSkillBinding 批量更新与查询 +- UserWorkload 自动更新 +""" + +from datetime import datetime, timedelta +from typing import List, Optional + +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from ...models import SkillTag, UserSkillBinding, UserWorkload, User, UserStatus, Task +from ..ai_pmo_kernel_service import AIPMOKernelService + + +# 系统预设 AI 开发专属技能标签 +from ...utils.timezone import now_cn_naive +SYSTEM_SKILL_TAGS = [ + {"name": "Python", "slug": "python", "category": "language", "sort_order": 1}, + {"name": "TypeScript", "slug": "typescript", "category": "language", "sort_order": 2}, + {"name": "FastAPI", "slug": "fastapi", "category": "framework", "sort_order": 3}, + {"name": "React", "slug": "react", "category": "framework", "sort_order": 4}, + {"name": "SQLAlchemy", "slug": "sqlalchemy", "category": "framework", "sort_order": 5}, + {"name": "LangChain", "slug": "langchain", "category": "framework", "sort_order": 6}, + {"name": "LLM 调优", "slug": "llm-tuning", "category": "skill", "sort_order": 7}, + {"name": "RAG 构建", "slug": "rag", "category": "skill", "sort_order": 8}, + {"name": "向量数据库", "slug": "vector-db", "category": "tool", "sort_order": 9}, + {"name": "Ollama", "slug": "ollama", "category": "tool", "sort_order": 10}, + {"name": "Docker", "slug": "docker", "category": "tool", "sort_order": 11}, + {"name": "Git", "slug": "git", "category": "tool", "sort_order": 12}, +] + + diff --git a/backend/app/services/skill/tag_crud.py b/backend/app/services/skill/tag_crud.py new file mode 100644 index 00000000..c8dce8a8 --- /dev/null +++ b/backend/app/services/skill/tag_crud.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from .shared import * + +async def init_system_skill_tags(db: AsyncSession, tenant_id: int = 1) -> int: + """ + 初始化系统预设 AI 开发技能标签。 + 项目启动时自动执行,已存在的标签不会重复创建。 + PostgreSQL 统一使用 ORM 批量写入,避免分方言分支。 + """ + result = await db.execute( + select(SkillTag).where( + and_(SkillTag.tenant_id == tenant_id, SkillTag.is_system == True) + ) + ) + existing = result.scalars().all() + existing_slugs = {t.slug for t in existing} + + to_create = [item for item in SYSTEM_SKILL_TAGS if item["slug"] not in existing_slugs] + if not to_create: + return 0 + + created = 0 + for item in to_create: + tag = SkillTag( + tenant_id=tenant_id, + name=item["name"], + slug=item["slug"], + category=item.get("category"), + is_system=True, + sort_order=item.get("sort_order", 0), + ) + db.add(tag) + created += 1 + await db.commit() + return created + +async def create_skill_tag( + db: AsyncSession, + tenant_id: int, + name: str, + slug: str, + category: Optional[str] = None, + description: Optional[str] = None, + sort_order: int = 0, +) -> SkillTag: + """创建技能标签(仅自定义标签)""" + existing = await db.execute( + select(SkillTag).where( + and_(SkillTag.tenant_id == tenant_id, SkillTag.slug == slug) + ) + ) + if existing.scalar_one_or_none(): + raise ValueError(f"技能标签 slug 已存在: {slug}") + + tag = SkillTag( + tenant_id=tenant_id, + name=name, + slug=slug, + category=category, + description=description, + is_system=False, + sort_order=sort_order, + ) + db.add(tag) + await db.commit() + await db.refresh(tag) + return tag + +async def list_skill_tags( + db: AsyncSession, + tenant_id: int, + category: Optional[str] = None, + include_system: bool = True, +) -> List[SkillTag]: + """列出技能标签""" + query = select(SkillTag).where(SkillTag.tenant_id == tenant_id) + if category: + query = query.where(SkillTag.category == category) + if not include_system: + query = query.where(SkillTag.is_system == False) + query = query.order_by(SkillTag.sort_order, SkillTag.name) + result = await db.execute(query) + return list(result.scalars().all()) + +async def get_skill_tag( + db: AsyncSession, tag_id: int, tenant_id: int +) -> Optional[SkillTag]: + """获取单个技能标签""" + result = await db.execute( + select(SkillTag).where( + and_(SkillTag.id == tag_id, SkillTag.tenant_id == tenant_id) + ) + ) + return result.scalar_one_or_none() + +async def update_skill_tag( + db: AsyncSession, + tag_id: int, + tenant_id: int, + name: Optional[str] = None, + category: Optional[str] = None, + description: Optional[str] = None, + sort_order: Optional[int] = None, +) -> Optional[SkillTag]: + """更新技能标签(系统标签禁止修改)""" + tag = await get_skill_tag(db, tag_id, tenant_id) + if not tag: + return None + if tag.is_system: + raise ValueError("系统预设技能标签不可修改") + + if name is not None: + tag.name = name + if category is not None: + tag.category = category + if description is not None: + tag.description = description + if sort_order is not None: + tag.sort_order = sort_order + tag.updated_at = now_cn_naive() + await db.commit() + await db.refresh(tag) + return tag + +async def delete_skill_tag( + db: AsyncSession, tag_id: int, tenant_id: int +) -> bool: + """删除技能标签(系统标签禁止删除)""" + tag = await get_skill_tag(db, tag_id, tenant_id) + if not tag: + return False + if tag.is_system: + raise ValueError("系统预设技能标签不可删除") + + await db.delete(tag) + await db.commit() + return True + diff --git a/backend/app/services/skill/workload_recommend.py b/backend/app/services/skill/workload_recommend.py new file mode 100644 index 00000000..664ec73d --- /dev/null +++ b/backend/app/services/skill/workload_recommend.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +from .shared import * + +async def update_user_workload( + db: AsyncSession, + tenant_id: int, + user_id: int, + total_tasks_delta: int = 0, + completed_tasks_delta: int = 0, + in_progress_delta: int = 0, + estimated_hours_delta: float = 0, + completed_hours_delta: float = 0, +) -> Optional[UserWorkload]: + """ + 更新用户工作量。 + 新 schema: current_assigned_hours, workload_ratio + """ + result = await db.execute( + select(UserWorkload).where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id == user_id, + ) + ) + ) + workload = result.scalar_one_or_none() + + if not workload: + workload = UserWorkload( + tenant_id=tenant_id, + user_id=user_id, + current_assigned_hours=0.0, + max_available_hours_per_week=40.0, + workload_ratio=0.0, + ) + db.add(workload) + await db.flush() + + delta = estimated_hours_delta + completed_hours_delta + workload.current_assigned_hours = max( + 0, float(workload.current_assigned_hours or 0) + delta + ) + max_hours = float(workload.max_available_hours_per_week or 40) + workload.workload_ratio = min( + 100.0, (workload.current_assigned_hours / max_hours) * 100 + ) + + await db.commit() + await db.refresh(workload) + + user_result = await db.execute(select(User).where(User.id == user_id)) + user = user_result.scalar_one_or_none() + if user: + user.workload = int(workload.workload_ratio) + await db.commit() + + return workload + +async def recommend_personnel( + db: AsyncSession, + tenant_id: int, + required_skills: List[str], + task_description: Optional[str] = None, + task_id: Optional[str] = None, + limit: int = 5, +) -> dict: + """ + 根据所需技能推荐匹配人员。 + 返回匹配度评分、推荐理由等。 + """ + if not required_skills: + return { + "task_id": task_id, + "task_description": task_description, + "required_skills": [], + "recommendations": [], + "total_count": 0, + } + + # 通过 slug 或 name 匹配技能标签 + skill_tags_result = await db.execute( + select(SkillTag).where( + and_( + SkillTag.tenant_id == tenant_id, + (SkillTag.slug.in_(required_skills) | SkillTag.name.in_(required_skills)), + ) + ) + ) + tags = skill_tags_result.scalars().all() + tag_ids = [t.id for t in tags] + + if not tag_ids: + return { + "task_id": task_id, + "task_description": task_description, + "required_skills": required_skills, + "recommendations": [], + "total_count": 0, + } + + # 查询拥有这些技能的用户及熟练度 + bindings_result = await db.execute( + select(UserSkillBinding, SkillTag, User) + .join(SkillTag, UserSkillBinding.skill_tag_id == SkillTag.id) + .join(User, UserSkillBinding.user_id == User.id) + .where( + and_( + UserSkillBinding.tenant_id == tenant_id, + UserSkillBinding.skill_tag_id.in_(tag_ids), + User.status == UserStatus.ACTIVE, + ) + ) + ) + rows = bindings_result.all() + + # 按用户聚合 + user_scores: dict = {} + for b, st, u in rows: + uid = str(u.id) + if uid not in user_scores: + user_scores[uid] = { + "user": u, + "skills": [], + "proficiencies": [], + "match_count": 0, + } + user_scores[uid]["skills"].append(st.name) + user_scores[uid]["proficiencies"].append(b.proficiency) + user_scores[uid]["match_count"] = len(user_scores[uid]["skills"]) + + # 计算匹配度并排序 + recommendations = [] + for uid, data in user_scores.items(): + u = data["user"] + avg_prof = ( + sum(data["proficiencies"]) / len(data["proficiencies"]) + if data["proficiencies"] + else 0 + ) + match_ratio = len(data["skills"]) / len(required_skills) if required_skills else 0 + match_score = min(100, (match_ratio * 60 + avg_prof * 0.4)) + reason = f"匹配技能: {', '.join(data['skills'])}" + recommendations.append( + { + "user_id": uid, + "user_name": u.name, + "match_score": round(match_score, 1), + "reason": reason, + "skill_match_count": data["match_count"], + "avg_proficiency": round(avg_prof, 1), + "workload_rate": float(u.workload) if u.workload else 0, + "recommended_role": u.role.value if hasattr(u.role, "value") else str(u.role), + } + ) + + recommendations.sort(key=lambda x: (-x["match_score"], -x["skill_match_count"])) + recommendations = recommendations[:limit] + + return { + "task_id": task_id, + "task_description": task_description, + "required_skills": required_skills, + "recommendations": recommendations, + "total_count": len(recommendations), + } + diff --git a/backend/app/services/skill_service.py b/backend/app/services/skill_service.py index 981c4dfd..d14f3746 100644 --- a/backend/app/services/skill_service.py +++ b/backend/app/services/skill_service.py @@ -1,573 +1,3 @@ -""" -PMFlow 人员能力图谱 - 技能标签与用户技能绑定服务 +from __future__ import annotations -- 系统预设 AI 开发技能标签初始化 -- SkillTag CRUD(系统标签禁止删改) -- UserSkillBinding 批量更新与查询 -- UserWorkload 自动更新 -""" - -from datetime import datetime, timedelta -from typing import List, Optional - -from sqlalchemy import and_, func, select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload - -from ..models import SkillTag, UserSkillBinding, UserWorkload, User, UserStatus, Task -from .ai_pmo_kernel_service import AIPMOKernelService - - -# 系统预设 AI 开发专属技能标签 -from ..utils.timezone import now_cn_naive -SYSTEM_SKILL_TAGS = [ - {"name": "Python", "slug": "python", "category": "language", "sort_order": 1}, - {"name": "TypeScript", "slug": "typescript", "category": "language", "sort_order": 2}, - {"name": "FastAPI", "slug": "fastapi", "category": "framework", "sort_order": 3}, - {"name": "React", "slug": "react", "category": "framework", "sort_order": 4}, - {"name": "SQLAlchemy", "slug": "sqlalchemy", "category": "framework", "sort_order": 5}, - {"name": "LangChain", "slug": "langchain", "category": "framework", "sort_order": 6}, - {"name": "LLM 调优", "slug": "llm-tuning", "category": "skill", "sort_order": 7}, - {"name": "RAG 构建", "slug": "rag", "category": "skill", "sort_order": 8}, - {"name": "向量数据库", "slug": "vector-db", "category": "tool", "sort_order": 9}, - {"name": "Ollama", "slug": "ollama", "category": "tool", "sort_order": 10}, - {"name": "Docker", "slug": "docker", "category": "tool", "sort_order": 11}, - {"name": "Git", "slug": "git", "category": "tool", "sort_order": 12}, -] - - -async def init_system_skill_tags(db: AsyncSession, tenant_id: int = 1) -> int: - """ - 初始化系统预设 AI 开发技能标签。 - 项目启动时自动执行,已存在的标签不会重复创建。 - PostgreSQL 统一使用 ORM 批量写入,避免分方言分支。 - """ - result = await db.execute( - select(SkillTag).where( - and_(SkillTag.tenant_id == tenant_id, SkillTag.is_system == True) - ) - ) - existing = result.scalars().all() - existing_slugs = {t.slug for t in existing} - - to_create = [item for item in SYSTEM_SKILL_TAGS if item["slug"] not in existing_slugs] - if not to_create: - return 0 - - created = 0 - for item in to_create: - tag = SkillTag( - tenant_id=tenant_id, - name=item["name"], - slug=item["slug"], - category=item.get("category"), - is_system=True, - sort_order=item.get("sort_order", 0), - ) - db.add(tag) - created += 1 - await db.commit() - return created - - -# -------------------------------------------------------------------------- -# SkillTag CRUD -# -------------------------------------------------------------------------- - - -async def create_skill_tag( - db: AsyncSession, - tenant_id: int, - name: str, - slug: str, - category: Optional[str] = None, - description: Optional[str] = None, - sort_order: int = 0, -) -> SkillTag: - """创建技能标签(仅自定义标签)""" - existing = await db.execute( - select(SkillTag).where( - and_(SkillTag.tenant_id == tenant_id, SkillTag.slug == slug) - ) - ) - if existing.scalar_one_or_none(): - raise ValueError(f"技能标签 slug 已存在: {slug}") - - tag = SkillTag( - tenant_id=tenant_id, - name=name, - slug=slug, - category=category, - description=description, - is_system=False, - sort_order=sort_order, - ) - db.add(tag) - await db.commit() - await db.refresh(tag) - return tag - - -async def list_skill_tags( - db: AsyncSession, - tenant_id: int, - category: Optional[str] = None, - include_system: bool = True, -) -> List[SkillTag]: - """列出技能标签""" - query = select(SkillTag).where(SkillTag.tenant_id == tenant_id) - if category: - query = query.where(SkillTag.category == category) - if not include_system: - query = query.where(SkillTag.is_system == False) - query = query.order_by(SkillTag.sort_order, SkillTag.name) - result = await db.execute(query) - return list(result.scalars().all()) - - -async def get_skill_tag( - db: AsyncSession, tag_id: int, tenant_id: int -) -> Optional[SkillTag]: - """获取单个技能标签""" - result = await db.execute( - select(SkillTag).where( - and_(SkillTag.id == tag_id, SkillTag.tenant_id == tenant_id) - ) - ) - return result.scalar_one_or_none() - - -async def update_skill_tag( - db: AsyncSession, - tag_id: int, - tenant_id: int, - name: Optional[str] = None, - category: Optional[str] = None, - description: Optional[str] = None, - sort_order: Optional[int] = None, -) -> Optional[SkillTag]: - """更新技能标签(系统标签禁止修改)""" - tag = await get_skill_tag(db, tag_id, tenant_id) - if not tag: - return None - if tag.is_system: - raise ValueError("系统预设技能标签不可修改") - - if name is not None: - tag.name = name - if category is not None: - tag.category = category - if description is not None: - tag.description = description - if sort_order is not None: - tag.sort_order = sort_order - tag.updated_at = now_cn_naive() - await db.commit() - await db.refresh(tag) - return tag - - -async def delete_skill_tag( - db: AsyncSession, tag_id: int, tenant_id: int -) -> bool: - """删除技能标签(系统标签禁止删除)""" - tag = await get_skill_tag(db, tag_id, tenant_id) - if not tag: - return False - if tag.is_system: - raise ValueError("系统预设技能标签不可删除") - - await db.delete(tag) - await db.commit() - return True - - -# -------------------------------------------------------------------------- -# UserSkillBinding -# -------------------------------------------------------------------------- - - -async def init_user_skill_bindings( - db: AsyncSession, - tenant_id: int, - user_id: int, - *, - include_system_only: bool = True, - commit: bool = False, -) -> int: - """ - 初始化用户技能绑定(空基线)。 - - 为用户补齐未绑定的标签,默认 `beginner + proficiency=0`。 - """ - tags_query = select(SkillTag.id).where(SkillTag.tenant_id == tenant_id) - if include_system_only: - tags_query = tags_query.where(SkillTag.is_system == True) - tag_rows = await db.execute(tags_query) - tag_ids = [int(v) for v in tag_rows.scalars().all()] - if not tag_ids: - return 0 - - existing_rows = await db.execute( - select(UserSkillBinding.skill_tag_id).where( - and_( - UserSkillBinding.tenant_id == tenant_id, - UserSkillBinding.user_id == user_id, - UserSkillBinding.skill_tag_id.in_(tag_ids), - ) - ) - ) - existing_ids = {int(v) for v in existing_rows.scalars().all()} - - created = 0 - for skill_tag_id in tag_ids: - if skill_tag_id in existing_ids: - continue - db.add( - UserSkillBinding( - tenant_id=tenant_id, - user_id=user_id, - skill_tag_id=skill_tag_id, - proficiency_level="beginner", - level="beginner", - proficiency=0, - quality_score=0.0, - efficiency_score=0.0, - ) - ) - created += 1 - - if created: - await db.flush() - if commit: - await db.commit() - return created - - -async def batch_update_user_skill_bindings( - db: AsyncSession, - tenant_id: int, - user_id: int, - bindings: List[dict], -) -> List[UserSkillBinding]: - """ - 批量更新用户技能绑定。 - 若 skill_tag_id 已存在则更新,否则创建。 - """ - result_bindings = [] - - for item in bindings: - skill_tag_id = item.get("skill_tag_id") - if skill_tag_id is None: - continue - if isinstance(skill_tag_id, str): - try: - skill_tag_id = int(skill_tag_id) - except ValueError: - continue - - proficiency = item.get("proficiency", 50) - level = item.get("level", "intermediate") - years = item.get("years_experience") - - existing = await db.execute( - select(UserSkillBinding).where( - and_( - UserSkillBinding.tenant_id == tenant_id, - UserSkillBinding.user_id == user_id, - UserSkillBinding.skill_tag_id == skill_tag_id, - ) - ) - ) - binding = existing.scalar_one_or_none() - - if binding: - binding.proficiency = proficiency - binding.level = level - binding.years_experience = years - binding.proficiency_level = level - else: - binding = UserSkillBinding( - tenant_id=tenant_id, - user_id=user_id, - skill_tag_id=skill_tag_id, - proficiency_level=level, - proficiency=proficiency, - level=level, - years_experience=years, - ) - db.add(binding) - - result_bindings.append(binding) - - await db.commit() - for b in result_bindings: - await db.refresh(b) - await AIPMOKernelService.invalidate_world_model_cache_for_user( - db=db, - tenant_id=tenant_id, - user_id=user_id, - ) - return result_bindings - - -async def get_user_skill_bindings( - db: AsyncSession, - tenant_id: int, - user_id: int, -) -> List[UserSkillBinding]: - """按用户查询技能绑定""" - result = await db.execute( - select(UserSkillBinding) - .options(selectinload(UserSkillBinding.skill_tag)) - .where( - and_( - UserSkillBinding.tenant_id == tenant_id, - UserSkillBinding.user_id == user_id, - ) - ) - .order_by(UserSkillBinding.proficiency.desc()) - ) - return list(result.scalars().all()) - - -async def get_skill_tag_user_bindings( - db: AsyncSession, - tenant_id: int, - skill_tag_id: int, -) -> List[UserSkillBinding]: - """按技能标签查询用户绑定""" - result = await db.execute( - select(UserSkillBinding) - .options( - selectinload(UserSkillBinding.user), - selectinload(UserSkillBinding.skill_tag), - ) - .where( - and_( - UserSkillBinding.tenant_id == tenant_id, - UserSkillBinding.skill_tag_id == skill_tag_id, - ) - ) - .order_by(UserSkillBinding.proficiency.desc()) - ) - return list(result.scalars().all()) - - -async def get_user_skill_task_history( - db: AsyncSession, - tenant_id: int, - user_id: int, - skill_tag_id: int, - limit: int = 50, -) -> List[dict]: - """ - 查询用户某技能下的历史任务。 - - 口径: - - assignee_id == user_id - - required_skill_tag_ids 包含 skill_tag_id - """ - result = await db.execute( - select(Task) - .where( - and_( - Task.tenant_id == tenant_id, - Task.assignee_id == user_id, - Task.required_skill_tag_ids.contains([skill_tag_id]), - ) - ) - .order_by(Task.updated_at.desc()) - .limit(limit) - ) - filtered = list(result.scalars().all()) - - return [ - { - "id": str(t.id), - "title": t.title, - "description": t.description, - "status": t.status, - "priority": t.priority, - "estimated_hours": float(t.estimated_hours) if t.estimated_hours is not None else None, - "project_id": str(t.project_id) if t.project_id is not None else None, - "truth_node_id": str(t.truth_node_id) if t.truth_node_id is not None else None, - "updated_at": t.updated_at.isoformat() if t.updated_at else None, - } - for t in filtered - ] - - -# -------------------------------------------------------------------------- -# UserWorkload 自动更新 -# -------------------------------------------------------------------------- - - -async def update_user_workload( - db: AsyncSession, - tenant_id: int, - user_id: int, - total_tasks_delta: int = 0, - completed_tasks_delta: int = 0, - in_progress_delta: int = 0, - estimated_hours_delta: float = 0, - completed_hours_delta: float = 0, -) -> Optional[UserWorkload]: - """ - 更新用户工作量。 - 新 schema: current_assigned_hours, workload_ratio - """ - result = await db.execute( - select(UserWorkload).where( - and_( - UserWorkload.tenant_id == tenant_id, - UserWorkload.user_id == user_id, - ) - ) - ) - workload = result.scalar_one_or_none() - - if not workload: - workload = UserWorkload( - tenant_id=tenant_id, - user_id=user_id, - current_assigned_hours=0.0, - max_available_hours_per_week=40.0, - workload_ratio=0.0, - ) - db.add(workload) - await db.flush() - - delta = estimated_hours_delta + completed_hours_delta - workload.current_assigned_hours = max( - 0, float(workload.current_assigned_hours or 0) + delta - ) - max_hours = float(workload.max_available_hours_per_week or 40) - workload.workload_ratio = min( - 100.0, (workload.current_assigned_hours / max_hours) * 100 - ) - - await db.commit() - await db.refresh(workload) - - user_result = await db.execute(select(User).where(User.id == user_id)) - user = user_result.scalar_one_or_none() - if user: - user.workload = int(workload.workload_ratio) - await db.commit() - - return workload - - -# -------------------------------------------------------------------------- -# 人员推荐 -# -------------------------------------------------------------------------- - - -async def recommend_personnel( - db: AsyncSession, - tenant_id: int, - required_skills: List[str], - task_description: Optional[str] = None, - task_id: Optional[str] = None, - limit: int = 5, -) -> dict: - """ - 根据所需技能推荐匹配人员。 - 返回匹配度评分、推荐理由等。 - """ - if not required_skills: - return { - "task_id": task_id, - "task_description": task_description, - "required_skills": [], - "recommendations": [], - "total_count": 0, - } - - # 通过 slug 或 name 匹配技能标签 - skill_tags_result = await db.execute( - select(SkillTag).where( - and_( - SkillTag.tenant_id == tenant_id, - (SkillTag.slug.in_(required_skills) | SkillTag.name.in_(required_skills)), - ) - ) - ) - tags = skill_tags_result.scalars().all() - tag_ids = [t.id for t in tags] - - if not tag_ids: - return { - "task_id": task_id, - "task_description": task_description, - "required_skills": required_skills, - "recommendations": [], - "total_count": 0, - } - - # 查询拥有这些技能的用户及熟练度 - bindings_result = await db.execute( - select(UserSkillBinding, SkillTag, User) - .join(SkillTag, UserSkillBinding.skill_tag_id == SkillTag.id) - .join(User, UserSkillBinding.user_id == User.id) - .where( - and_( - UserSkillBinding.tenant_id == tenant_id, - UserSkillBinding.skill_tag_id.in_(tag_ids), - User.status == UserStatus.ACTIVE, - ) - ) - ) - rows = bindings_result.all() - - # 按用户聚合 - user_scores: dict = {} - for b, st, u in rows: - uid = str(u.id) - if uid not in user_scores: - user_scores[uid] = { - "user": u, - "skills": [], - "proficiencies": [], - "match_count": 0, - } - user_scores[uid]["skills"].append(st.name) - user_scores[uid]["proficiencies"].append(b.proficiency) - user_scores[uid]["match_count"] = len(user_scores[uid]["skills"]) - - # 计算匹配度并排序 - recommendations = [] - for uid, data in user_scores.items(): - u = data["user"] - avg_prof = ( - sum(data["proficiencies"]) / len(data["proficiencies"]) - if data["proficiencies"] - else 0 - ) - match_ratio = len(data["skills"]) / len(required_skills) if required_skills else 0 - match_score = min(100, (match_ratio * 60 + avg_prof * 0.4)) - reason = f"匹配技能: {', '.join(data['skills'])}" - recommendations.append( - { - "user_id": uid, - "user_name": u.name, - "match_score": round(match_score, 1), - "reason": reason, - "skill_match_count": data["match_count"], - "avg_proficiency": round(avg_prof, 1), - "workload_rate": float(u.workload) if u.workload else 0, - "recommended_role": u.role.value if hasattr(u.role, "value") else str(u.role), - } - ) - - recommendations.sort(key=lambda x: (-x["match_score"], -x["skill_match_count"])) - recommendations = recommendations[:limit] - - return { - "task_id": task_id, - "task_description": task_description, - "required_skills": required_skills, - "recommendations": recommendations, - "total_count": len(recommendations), - } +from .skill import * diff --git a/backend/app/services/sstc/__init__.py b/backend/app/services/sstc/__init__.py new file mode 100644 index 00000000..9d48db4f --- /dev/null +++ b/backend/app/services/sstc/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/app/services/sstc/charter_data.py b/backend/app/services/sstc/charter_data.py new file mode 100644 index 00000000..061d598a --- /dev/null +++ b/backend/app/services/sstc/charter_data.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from .shared import * + +from .signal_core import _as_dict, _extract_signal_attachments, _normalize_planner_status, _signal_display_type, _signal_project_scope, _signal_source_context +from .signal_filters import _signal_proposer_name, _signal_title_text + +async def _fetch_valid_project_signal_ids( + db: AsyncSession, + *, + tenant_id: int, + project_id: int, + signal_ids: list[int], +) -> set[int]: + link_result = await db.execute( + select(ProjectSignalLink.signal_id).where( + and_( + ProjectSignalLink.project_id == project_id, + ProjectSignalLink.signal_id.in_(signal_ids), + ProjectSignalLink.tenant_id == tenant_id, + ) + ) + ) + return {int(v) for v in link_result.scalars().all()} + +async def _load_signal_rows( + db: AsyncSession, + *, + tenant_id: int, + signal_ids: list[int], +) -> list[Signal]: + if not signal_ids: + return [] + return ( + await db.execute( + select(Signal).where( + and_( + Signal.tenant_id == tenant_id, + Signal.id.in_(signal_ids), + ) + ) + ) + ).scalars().all() + +def _build_charter_summary_payload(charter: TruthNode) -> dict[str, Any]: + content = _as_dict(charter.content) + return { + "id": str(charter.id), + "title": charter.title, + "content": content, + "signal_ids": content.get("signal_ids", []), + "status": charter.status, + "version": charter.version, + } + +def _serialize_charter_signal(signal: Signal) -> dict[str, Any]: + return { + "id": str(signal.id), + "source": signal.source, + "signal_type": _signal_display_type(signal), + "project_scope": _signal_project_scope(signal), + "proposer_name": _signal_proposer_name(signal), + "title": _signal_title_text(signal)[:80] or "未命名信号", + "content": signal.content, + "priority": signal.priority, + "impact_score": signal.impact_score, + "attachments": _extract_signal_attachments(signal), + "strategic_analysis": _as_dict(signal.strategic_analysis), + "planner_status": _normalize_planner_status(getattr(signal, "planner_status", None)), + "planner_analyzed_at": ( + signal.planner_analyzed_at.isoformat() if getattr(signal, "planner_analyzed_at", None) else None + ), + "planner_assignee_user_id": ( + str(signal.planner_assignee_user_id) if getattr(signal, "planner_assignee_user_id", None) is not None else None + ), + } + +def _serialize_source_signal(signal: Signal) -> dict[str, Any]: + return { + "id": str(signal.id), + "title": _signal_title_text(signal)[:80] or "未命名信号", + "signal_type": _signal_display_type(signal), + "priority": signal.priority, + "content": signal.content, + "attachments": _extract_signal_attachments(signal), + "strategic_analysis": _as_dict(signal.strategic_analysis), + "planner_status": _normalize_planner_status(getattr(signal, "planner_status", None)), + "planner_analyzed_at": ( + signal.planner_analyzed_at.isoformat() + if getattr(signal, "planner_analyzed_at", None) + else None + ), + "planner_assignee_user_id": ( + str(signal.planner_assignee_user_id) + if getattr(signal, "planner_assignee_user_id", None) is not None + else None + ), + } + +def _select_source_signal(charter: TruthNode, signal_rows: list[Signal]) -> Optional[Signal]: + if charter.signal_id: + for signal in signal_rows: + if int(signal.id) == int(charter.signal_id): + return signal + if signal_rows: + return signal_rows[0] + return None + +def _serialize_charter_task(task: Task) -> dict[str, Any]: + return { + "id": str(task.id), + "title": task.title, + "description": task.description, + "status": task.status, + "priority": task.priority, + "estimated_hours": float(task.estimated_hours) if task.estimated_hours else None, + "assignee_id": str(task.assignee_id) if task.assignee_id else None, + "due_date": task.due_date.isoformat() if task.due_date else None, + } + +def _serialize_alignment_record(record: AlignmentRecord) -> dict[str, Any]: + return { + "user_id": str(record.user_id), + "score": record.alignment_score, + "confirmed": bool(record.is_confirmed), + "comments": record.comments, + } + diff --git a/backend/app/services/sstc/charter_prefill_builder.py b/backend/app/services/sstc/charter_prefill_builder.py new file mode 100644 index 00000000..814800d8 --- /dev/null +++ b/backend/app/services/sstc/charter_prefill_builder.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +from .shared import * + +from .charter_seed_utils import _build_signal_context_text, _to_bullet_text, _to_string_list +from .charter_prefill_utils import _default_ai_validations, _default_escalation_trigger, _generate_charter_seed + +async def _build_ai_first_charter_content( + *, + db: AsyncSession, + tenant_id: int, + project_id: int, + signal_rows: list[Signal], + signal_ids: list[int], + raw_text: str = "", +) -> dict[str, Any]: + signal_context = _build_signal_context_text(signal_rows, raw_text=raw_text) + seed = await _generate_charter_seed( + db=db, + tenant_id=tenant_id, + project_id=project_id, + signal_context=signal_context, + ) + + summary = str(seed.get("summary") or "").strip() + background = str(seed.get("background") or "").strip() + objectives = _to_string_list(seed.get("objectives")) + scope = str(seed.get("scope") or "").strip() + in_scope_items = _to_string_list(seed.get("in_scope")) + out_of_scope_items = _to_string_list(seed.get("out_of_scope")) + success_metrics = _to_string_list(seed.get("success_metrics")) + risks = _to_string_list(seed.get("risks")) + dependencies = _to_string_list(seed.get("dependencies")) + primary_model = str(seed.get("primary_model") or "").strip() + fallback_model = str(seed.get("fallback_model") or "").strip() + + if not summary: + summary = "目标用户在当前流程中存在效率损耗,需要通过 AI 驱动的流程优化提升交付质量与速度。" + if not background: + background = signal_context[:500] or "基于已绑定信号源自动生成。" + if not objectives: + objectives = ["缩短端到端交付周期", "提升关键路径质量稳定性", "降低跨角色协同成本"] + if not success_metrics: + success_metrics = ["需求到上线周期缩短 >= 30%", "关键任务按期交付率 >= 90%", "线上回滚率 <= 3%"] + if not in_scope_items: + in_scope_items = ["需求澄清与宪章生成自动化", "任务拆解与负责人推荐", "对齐状态自动追踪与留痕"] + if not out_of_scope_items: + out_of_scope_items = ["替代团队组织架构决策", "绕过人工否决机制自动上线", "非项目相关流程改造"] + if not risks: + risks = ["需求波动导致范围蔓延", "模型能力边界与输入复杂度不匹配", "关键成员负载过高导致延期"] + if not dependencies: + dependencies = ["FastAPI", "React", "LLM 推理服务", "项目知识库/日志系统"] + if not primary_model: + primary_model = "待 AI 评估后绑定" + if not fallback_model: + fallback_model = "同供应商低成本模型" + + source_refs = [f"signal:{sid}" for sid in signal_ids] + now_iso = now_cn_aware().isoformat() + validations_m1 = _default_ai_validations("模块1:触发与目标") + validations_m2 = _default_ai_validations("模块2:范围与对齐") + validations_m3 = _default_ai_validations("模块3:模型与依赖") + validations_m4 = _default_ai_validations("模块4:计划与资源") + validations_m5 = _default_ai_validations("模块5:风险与兜底") + validations_m6 = _default_ai_validations("模块6:附件与治理") + escalation = _default_escalation_trigger() + + alignment_qa = _to_bullet_text( + [ + f"核心用户与痛点:{summary}", + "上线时间与硬约束:由里程碑计划自动回填并持续校验", + f"明确不做项:{'; '.join(out_of_scope_items[:3])}", + f"关键依赖:{'; '.join(dependencies[:4])}", + f"核心风险与缓解:{'; '.join(risks[:3])}", + ] + ) + + modules = { + "module_1": { + "problem_statement": summary, + "goal_statement": _to_bullet_text(objectives), + "kpis": _to_bullet_text(success_metrics), + "ai_validations": validations_m1, + "escalation_trigger": escalation, + }, + "module_2": { + "in_scope": _to_bullet_text(in_scope_items), + "out_of_scope": _to_bullet_text(out_of_scope_items), + "alignment_qa": alignment_qa, + "ai_validations": validations_m2, + "escalation_trigger": escalation, + }, + "module_3": { + "primary_model": primary_model, + "fallback_model": fallback_model, + "capability_table": _to_bullet_text( + [ + "上下文窗口/并发/延迟由模型能力中心实时读取", + "按 P50/P90 工作负载自动估算 token 与成本", + "超边界输入自动触发替代模型建议", + ] + ), + "dependency_map": _to_bullet_text(dependencies), + "business_view": "以业务术语展示能力上限、成本区间与上线风险。", + "engineering_view": "以工程参数展示 context window、QPS、成本与错误预算。", + "ai_validations": validations_m3, + "escalation_trigger": escalation, + }, + "module_4": { + "milestones": _to_bullet_text( + [ + "需求冻结 -> 技术评审 -> 开发完成 -> QA 验收 -> 发布上线", + "关键依赖阻塞超过阈值自动重排计划", + ] + ), + "role_effort_estimates": _to_bullet_text( + [ + "产品/PM:需求澄清与验收口径", + "前后端工程:核心功能开发与联调", + "AI 工程:模型评估、提示词与推理链路优化", + ] + ), + "capability_owner_assignment": "负责人由能力图谱自动推荐并可人工否决。", + "buffer_policy": "默认 15% 进度缓冲,关键路径超阈值触发自动预警。", + "ai_validations": validations_m4, + "escalation_trigger": escalation, + }, + "module_5": { + "risk_register": _to_bullet_text(risks), + "trigger_rules": _to_bullet_text( + [ + "进度偏差 > 15%", + "关键接口错误率连续超过阈值", + "模型调用成本超预算预警线", + ] + ), + "execution_rule": _to_bullet_text( + [ + "触发后优先执行预设 fallback", + "自动通知责任人并写入审计日志", + "连续失败时升级到人工决策", + ] + ), + "ai_validations": validations_m5, + "escalation_trigger": escalation, + }, + "module_6": { + "attachment_metadata": _to_bullet_text( + [ + "原始信号记录", + "需求讨论记录", + "设计与评审文档", + "发布与复盘材料", + ] + ), + "attachment_summary": "AI 自动提炼附件决策结论并关联到对应模块字段。", + "search_tags": _to_bullet_text(["需求宪章", "AI First", "SSTC", "对齐记录"]), + "ai_validations": validations_m6, + "governance_rule": "附件仅作证据引用,核心事实字段以模块1-5为准。", + }, + } + + return { + "schema_version": "charter_ai_first_v1", + "auto_generated_by_ai": True, + "metadata": { + "status": "draft", + "confidence_score": 0.82 if seed else 0.72, + "last_validated_at": now_iso, + "evidence_refs": source_refs, + "escalation_reason": None, + }, + "modules": modules, + "signal_ids": signal_ids, + "summary": summary, + "background": background, + "objectives": objectives, + "scope": scope, + "success_metrics": success_metrics, + "core_metrics": success_metrics, + "rationale": summary, + "decision": _to_bullet_text(objectives), + "in_scope": in_scope_items, + "out_of_scope": out_of_scope_items, + "risks": risks, + "raw_source_text": raw_text.strip() or None, + } + diff --git a/backend/app/services/sstc/charter_prefill_utils.py b/backend/app/services/sstc/charter_prefill_utils.py new file mode 100644 index 00000000..8504e550 --- /dev/null +++ b/backend/app/services/sstc/charter_prefill_utils.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from .shared import * + +from .charter_seed_utils import _localize_seed_to_zh, _needs_seed_localize_zh, _parse_json_object_text, _to_bullet_text + +def _has_non_empty_module_data(content: dict[str, Any]) -> bool: + modules = content.get("modules") + if not isinstance(modules, dict): + return False + for module in modules.values(): + if not isinstance(module, dict): + continue + for value in module.values(): + if str(value or "").strip(): + return True + return False + +def _has_complete_module_data(content: dict[str, Any]) -> bool: + modules = content.get("modules") + if not isinstance(modules, dict): + return False + for module_key, required_fields in _CHARTER_REQUIRED_FIELDS.items(): + module_payload = modules.get(module_key) + if not isinstance(module_payload, dict): + return False + for field_key in required_fields: + if not str(module_payload.get(field_key) or "").strip(): + return False + return True + +def _merge_prefill_content( + existing: dict[str, Any], incoming: dict[str, Any], *, overwrite_existing: bool +) -> dict[str, Any]: + merged: dict[str, Any] = dict(existing) + for key, value in incoming.items(): + if overwrite_existing or key not in merged: + merged[key] = value + continue + current = merged.get(key) + if isinstance(current, dict) and isinstance(value, dict): + merged[key] = _merge_prefill_content( + current, value, overwrite_existing=overwrite_existing + ) + continue + if isinstance(current, list): + if not current and value: + merged[key] = value + continue + if current is None or not str(current).strip(): + merged[key] = value + return merged + +def _default_ai_validations(module_name: str) -> str: + return _to_bullet_text( + [ + f"校验「{module_name}」与信号源证据链一致", + "校验字段可验证、可执行、可追踪", + "发现冲突时自动标注并等待人工否决/确认", + ] + ) + +def _default_escalation_trigger() -> str: + return _to_bullet_text( + [ + "置信度低于 0.75", + "关键字段缺失或来源证据冲突", + "超过模型能力边界或预算/时限约束", + ] + ) + +async def _generate_charter_seed( + *, + db: AsyncSession, + tenant_id: int, + project_id: int, + signal_context: str, +) -> dict[str, Any]: + if not signal_context.strip(): + return {} + + prompt = ( + "你是 AI 项目经理,请基于输入信号生成可落地的宪章草稿。" + "所有自然语言字段必须使用简体中文,禁止英文句子;模型名称或技术名词可保留英文。" + "只输出 JSON,不要 markdown。\n" + "字段:\n" + "{\n" + ' "summary": "一句话问题摘要",\n' + ' "background": "背景与动机",\n' + ' "objectives": ["目标1","目标2"],\n' + ' "scope": "范围说明",\n' + ' "in_scope": ["范围内事项1","范围内事项2"],\n' + ' "out_of_scope": ["范围外事项1","范围外事项2"],\n' + ' "success_metrics": ["指标1","指标2"],\n' + ' "risks": ["风险1","风险2"],\n' + ' "dependencies": ["依赖1","依赖2"],\n' + ' "primary_model": "模型名",\n' + ' "fallback_model": "备用模型名"\n' + "}\n\n" + f"信号输入:\n{signal_context[:6000]}" + ) + try: + raw = await llm_service.generate( + prompt, + function_name="sstc_charter_prefill", + db=db, + tenant_id=tenant_id, + project_id=project_id, + ) + parsed = _parse_json_object_text(raw) + if parsed: + if _needs_seed_localize_zh(parsed): + return await _localize_seed_to_zh( + db=db, tenant_id=tenant_id, project_id=project_id, seed=parsed + ) + return parsed + except Exception: + pass + + try: + ai_seed = await AIService().generate_charter_from_signal( + signal_context, + tenant_id=tenant_id, + db=db, + ) + if isinstance(ai_seed, dict): + if _needs_seed_localize_zh(ai_seed): + return await _localize_seed_to_zh( + db=db, tenant_id=tenant_id, project_id=project_id, seed=ai_seed + ) + return ai_seed + return {} + except Exception: + return {} + diff --git a/backend/app/services/sstc/charter_seed_utils.py b/backend/app/services/sstc/charter_seed_utils.py new file mode 100644 index 00000000..81ab7066 --- /dev/null +++ b/backend/app/services/sstc/charter_seed_utils.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from .shared import * + +from .signal_filters import _signal_title_text + +def _safe_threshold(value: Any, default: float) -> float: + try: + number = float(value) + except (TypeError, ValueError): + number = float(default) + return min(1.0, max(0.0, number)) + +def _parse_json_object_text(raw: str) -> dict[str, Any]: + text = str(raw or "").strip() + if not text: + return {} + if text.startswith("```"): + text = text.strip("`") + if text.lower().startswith("json"): + text = text[4:].strip() + try: + parsed = json.loads(text) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + +def _seed_text_snapshot(seed: dict[str, Any]) -> str: + pieces: list[str] = [] + for value in seed.values(): + if isinstance(value, str): + pieces.append(value) + elif isinstance(value, (list, tuple)): + pieces.extend([str(item) for item in value if item is not None]) + return "\n".join([part for part in pieces if str(part).strip()]).strip() + +def _needs_seed_localize_zh(seed: dict[str, Any]) -> bool: + text = _seed_text_snapshot(seed) + if not text: + return False + zh_chars = len(re.findall(r"[\u4e00-\u9fff]", text)) + latin_chars = len(re.findall(r"[A-Za-z]", text)) + return zh_chars < 20 and latin_chars > 40 + +async def _localize_seed_to_zh( + *, + db: AsyncSession, + tenant_id: int, + project_id: int, + seed: dict[str, Any], +) -> dict[str, Any]: + if not seed: + return seed + prompt = ( + "将下列 JSON 草稿的自然语言内容翻译为简体中文。" + "要求:仅返回 JSON;键名与结构保持不变;模型名/专有技术名词可保留英文。\n\n" + f"{json.dumps(seed, ensure_ascii=False)}" + ) + try: + raw = await llm_service.generate( + prompt, + function_name="sstc_charter_localize_zh", + db=db, + tenant_id=tenant_id, + project_id=project_id, + ) + localized = _parse_json_object_text(raw) + return localized if localized else seed + except Exception: + return seed + +def _to_string_list(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + lines = [line.strip(" -\t") for line in value.replace("\r", "").split("\n")] + return [line for line in lines if line] + if isinstance(value, (list, tuple)): + output: list[str] = [] + for item in value: + text = str(item or "").strip(" -\t") + if text: + output.append(text) + return output + text = str(value).strip() + return [text] if text else [] + +def _to_bullet_text(items: list[str]) -> str: + cleaned = [str(item or "").strip() for item in items if str(item or "").strip()] + if not cleaned: + return "" + return "\n".join([f"- {item}" for item in cleaned]) + +def _extract_charter_signal_ids(content: dict[str, Any], charter_signal_id: Optional[int] = None) -> list[int]: + signal_ids: list[int] = [] + raw_ids = content.get("signal_ids") + if isinstance(raw_ids, list): + for sid in raw_ids: + try: + signal_ids.append(int(sid)) + except Exception: + continue + if charter_signal_id: + try: + cid = int(charter_signal_id) + if cid not in signal_ids: + signal_ids.append(cid) + except Exception: + pass + return sorted(set(signal_ids)) + +def _build_signal_context_text( + signal_rows: list[Signal], + extra_text: str = "", + raw_text: str = "", +) -> str: + parts: list[str] = [] + for idx, signal in enumerate(signal_rows, start=1): + title = _signal_title_text(signal) or f"信号{idx}" + parts.append( + "\n".join( + [ + f"[信号{idx}] 标题:{title}", + f"来源:{signal.source or '-'}", + f"优先级:{signal.priority or '-'}", + f"内容:{str(signal.content or '').strip()}", + ] + ).strip() + ) + extra_blocks: list[str] = [] + for block in (extra_text, raw_text): + text = str(block or "").strip() + if text and text not in extra_blocks: + extra_blocks.append(text) + for text in extra_blocks: + parts.append(f"[补充上下文]\n{text}") + return "\n\n".join([part for part in parts if part]).strip() + diff --git a/backend/app/services/sstc/entity_lookup.py b/backend/app/services/sstc/entity_lookup.py new file mode 100644 index 00000000..a54cea82 --- /dev/null +++ b/backend/app/services/sstc/entity_lookup.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from .shared import * + +def _parse_due_date(value: Optional[str]) -> Optional[datetime]: + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid due_date") from exc + +async def _ensure_project( + svc: SSTCService, + project_id: int, +): + project = await svc.get_project(project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + return project + +async def _ensure_charter( + db: AsyncSession, + *, + tenant_id: int, + project_id: int, + charter_id: int, +) -> TruthNode: + result = await db.execute( + select(TruthNode).where( + and_( + TruthNode.id == charter_id, + TruthNode.project_id == project_id, + TruthNode.tenant_id == tenant_id, + TruthNode.node_type == "charter", + ) + ) + ) + charter = result.scalar_one_or_none() + if not charter: + raise HTTPException(status_code=404, detail="Charter not found") + return charter + +async def _ensure_task( + db: AsyncSession, + *, + tenant_id: int, + project_id: int, + task_id: int, +) -> Task: + result = await db.execute( + select(Task).where( + and_( + Task.id == task_id, + Task.project_id == project_id, + Task.tenant_id == tenant_id, + ) + ) + ) + task = result.scalar_one_or_none() + if not task: + raise HTTPException(status_code=404, detail="Task not found") + return task + +async def _ensure_release( + db: AsyncSession, + *, + tenant_id: int, + project_id: int, + release_node_id: int, +) -> TruthNode: + result = await db.execute( + select(TruthNode).where( + and_( + TruthNode.id == release_node_id, + TruthNode.project_id == project_id, + TruthNode.tenant_id == tenant_id, + TruthNode.node_type == "release", + ) + ) + ) + release_node = result.scalar_one_or_none() + if not release_node: + raise HTTPException(status_code=404, detail="Release plan not found") + return release_node + +async def _ensure_review( + db: AsyncSession, + *, + tenant_id: int, + project_id: int, + review_node_id: int, +) -> TruthNode: + result = await db.execute( + select(TruthNode).where( + and_( + TruthNode.id == review_node_id, + TruthNode.project_id == project_id, + TruthNode.tenant_id == tenant_id, + TruthNode.node_type == "review", + ) + ) + ) + review_node = result.scalar_one_or_none() + if not review_node: + raise HTTPException(status_code=404, detail="Review not found") + return review_node + diff --git a/backend/app/services/sstc/feishu_routing.py b/backend/app/services/sstc/feishu_routing.py new file mode 100644 index 00000000..cc518990 --- /dev/null +++ b/backend/app/services/sstc/feishu_routing.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from .shared import * + +from .charter_seed_utils import _safe_threshold +from .signal_core import _as_dict, _signal_source_context +from .signal_filters import _replace_signal_sender_in_content, _signal_proposer_name + +def _extract_feishu_routing_config(integration: Optional[Integration]) -> dict[str, Any]: + config: dict[str, Any] = {} + if integration is not None: + if isinstance(integration.config, dict): + config = integration.config + elif isinstance(integration.config, str): + try: + parsed = json.loads(integration.config) + if isinstance(parsed, dict): + config = parsed + except (TypeError, ValueError): + config = {} + + cold_start_enabled = bool(config.get("cold_start_enabled")) + signal_threshold = _safe_threshold( + config.get("signal_auto_enter_threshold"), + DEFAULT_SIGNAL_AUTO_ENTER_THRESHOLD, + ) + project_threshold = _safe_threshold( + config.get("project_auto_create_threshold"), + DEFAULT_PROJECT_AUTO_CREATE_THRESHOLD, + ) + uncertain_threshold = min( + project_threshold, + _safe_threshold( + config.get("project_uncertain_threshold"), + DEFAULT_PROJECT_UNCERTAIN_THRESHOLD, + ), + ) + return { + "raw": config, + "cold_start_enabled": cold_start_enabled, + "signal_auto_enter_threshold": signal_threshold, + "project_auto_create_threshold": project_threshold, + "project_uncertain_threshold": uncertain_threshold, + } + +async def _backfill_feishu_sender_names( + db: AsyncSession, + *, + tenant_id: int, + signals: list[Signal], +) -> None: + pending: dict[int, dict[str, str]] = {} + for signal in signals: + if str(signal.source or "").lower() != "feishu": + continue + source_context = _signal_source_context(signal) + open_id = str(source_context.get("sender_open_id") or "").strip() + sender = _signal_proposer_name(signal).strip() + if not open_id or not sender: + continue + if _FEISHU_PLACEHOLDER_RE.match(sender): + pending[int(signal.id)] = { + "open_id": open_id, + "current_sender": sender, + } + if not pending: + return + + service = get_feishu_service(db) + await service.load_config(tenant_id=tenant_id) + if not service.app_id or not service.app_secret: + return + + resolved_name_by_open_id: dict[str, str] = {} + for open_id in sorted({meta["open_id"] for meta in pending.values()})[:20]: + try: + remote_name = await service._fetch_user_name_by_open_id(open_id) # noqa: SLF001 + except Exception: + remote_name = None + normalized = (remote_name or "").strip() + if not normalized or _FEISHU_PLACEHOLDER_RE.match(normalized): + continue + resolved_name_by_open_id[open_id] = normalized + + if not resolved_name_by_open_id: + return + + changed = False + for signal in signals: + meta = pending.get(int(signal.id)) + if not meta: + continue + sender_name = resolved_name_by_open_id.get(meta["open_id"]) + if not sender_name: + continue + payload = _as_dict(signal.strategic_analysis) + source_context = payload.get("source_context") + if not isinstance(source_context, dict): + source_context = {} + source_context["sender"] = sender_name + source_context["sender_open_id"] = meta["open_id"] + payload["source_context"] = source_context + signal.strategic_analysis = json.dumps(payload, ensure_ascii=False) + signal.content = _replace_signal_sender_in_content(str(signal.content or ""), sender_name) + changed = True + + if changed: + await db.commit() + diff --git a/backend/app/services/sstc/governance.py b/backend/app/services/sstc/governance.py new file mode 100644 index 00000000..f39befc0 --- /dev/null +++ b/backend/app/services/sstc/governance.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +from .shared import * + +from .signal_core import _as_dict + +async def _project_task_metrics( + db: AsyncSession, + *, + tenant_id: int, + project_id: int, +) -> dict[str, int]: + result = await db.execute( + select(Task.status).where( + and_( + Task.project_id == project_id, + Task.tenant_id == tenant_id, + ) + ) + ) + statuses = [str(row[0] or "").lower() for row in result.all()] + total = len(statuses) + done = len([s for s in statuses if s in DONE_STATUSES]) + return {"total": total, "done": done} + +async def _build_governance_snapshot( + db: AsyncSession, + *, + tenant_id: int, + project_id: int, + charter_id: int | None = None, +) -> dict[str, Any]: + charter: TruthNode | None = None + if charter_id: + charter = ( + await db.execute( + select(TruthNode).where( + and_( + TruthNode.id == charter_id, + TruthNode.project_id == project_id, + TruthNode.tenant_id == tenant_id, + TruthNode.node_type == "charter", + ) + ) + ) + ).scalar_one_or_none() + if charter is None: + charter = ( + await db.execute( + select(TruthNode) + .where( + and_( + TruthNode.project_id == project_id, + TruthNode.tenant_id == tenant_id, + TruthNode.node_type == "charter", + ) + ) + .order_by(TruthNode.created_at.desc()) + .limit(1) + ) + ).scalar_one_or_none() + + confidence_score = None + confidence_risk = False + charter_id_value = int(charter.id) if charter else None + if charter: + charter_content = _as_dict(charter.content) + metadata = charter_content.get("metadata") + if isinstance(metadata, dict): + raw_score = metadata.get("confidence_score") + try: + confidence_score = float(raw_score) + except (TypeError, ValueError): + confidence_score = None + confidence_risk = confidence_score is not None and confidence_score < 0.8 + + binding = None + if charter_id_value: + binding = ( + await db.execute( + select(ModelRequirementBinding, ModelVersion) + .join( + ModelVersion, + ModelVersion.id == ModelRequirementBinding.model_version_id, + isouter=True, + ) + .where( + and_( + ModelRequirementBinding.tenant_id == tenant_id, + ModelRequirementBinding.truth_node_id == charter_id_value, + ) + ) + .order_by(ModelRequirementBinding.created_at.desc()) + .limit(1) + ) + ).first() + + model_block = False + model_info: dict[str, Any] = { + "bound": False, + "compatibility_score": None, + "model_version_id": None, + "model_name": None, + "provider": None, + } + est_cost = 0.01 + if binding: + binding_row, model_row = binding + score = int(binding_row.compatibility_score or 0) + model_block = score < 70 + model_info = { + "bound": True, + "compatibility_score": score, + "model_version_id": int(binding_row.model_version_id), + "model_name": getattr(model_row, "name", None), + "provider": getattr(model_row, "provider", None), + } + try: + est_cost = max(float(binding_row.estimated_cost or 0.01), 0.01) + except (TypeError, ValueError): + est_cost = 0.01 + + budget_service = BudgetEnforcementService(db) + budget_check = await budget_service.check_budget_before_ai_call( + tenant_id=tenant_id, + estimated_cost_usd=est_cost, + project_id=project_id, + ) + + budget_block = not bool(budget_check.get("allowed", True)) + intervention_required = confidence_risk or model_block or budget_block + blockers: list[str] = [] + warnings: list[str] = [] + if confidence_risk: + warnings.append("宪章 AI 置信度低于 0.80,建议补充关键事实后再推进。") + if model_block: + blockers.append("模型匹配分低于 70,禁止自动推进发布验证。") + elif not model_info["bound"]: + warnings.append("当前宪章未绑定模型版本,建议先完成模型能力矩阵校验。") + if budget_block: + blockers.append(f"预算管控阻断:{budget_check.get('reason') or 'budget_blocked'}") + elif str(budget_check.get("reason") or "") == "within_budget": + usage = budget_check.get("usage_percent") + if isinstance(usage, (int, float)) and float(usage) >= 80: + warnings.append("预算已接近阈值,建议执行成本优化方案。") + + return { + "project_id": project_id, + "charter_id": charter_id_value, + "intervention_required": intervention_required, + "blockers": blockers, + "warnings": warnings, + "confidence": { + "score": confidence_score, + "risk": confidence_risk, + }, + "model_binding": model_info, + "budget": budget_check, + } + +async def _assert_release_transition_allowed( + db: AsyncSession, + *, + tenant_id: int, + project_id: int, + release_node: TruthNode, + target_status: str, +) -> None: + current = str(release_node.status or "pending").lower() + target = str(target_status or "").lower() + if target == "verified": + raise HTTPException(status_code=409, detail="请通过验证完成接口标记为已验证") + if target not in RELEASE_STATUS_TRANSITIONS.get(current, set()): + raise HTTPException( + status_code=409, + detail=f"非法状态流转:{current} -> {target}", + ) + + content = _as_dict(release_node.content) + if target in {"releasing", "online"}: + task_ids = [int(tid) for tid in (content.get("task_ids") or []) if str(tid).isdigit()] + if not task_ids: + raise HTTPException(status_code=409, detail="发布计划未绑定任务") + task_result = await db.execute( + select(Task.id, Task.status).where( + and_( + Task.tenant_id == tenant_id, + Task.project_id == project_id, + Task.id.in_(task_ids), + ) + ) + ) + task_rows = list(task_result.all()) + if len(task_rows) != len(task_ids): + raise HTTPException(status_code=409, detail="发布计划存在无效任务绑定") + not_done = [row for row in task_rows if str(row[1] or "").lower() not in DONE_STATUSES] + if not_done: + raise HTTPException(status_code=409, detail="存在未完成任务,禁止进入发布流程") + + governance = await _build_governance_snapshot( + db, + tenant_id=tenant_id, + project_id=project_id, + charter_id=int(content.get("charter_id") or 0) or None, + ) + if governance.get("blockers"): + raise HTTPException( + status_code=409, + detail=";".join([str(item) for item in governance.get("blockers", [])]), + ) + + verification_items = content.get("verification_items") + if not isinstance(verification_items, list) or len(verification_items) == 0: + raise HTTPException(status_code=409, detail="请先生成发布验证清单") + +def _assert_charter_aligned(charter: TruthNode) -> None: + if (charter.status or "").lower() not in ALIGNED_STATUSES: + raise HTTPException( + status_code=409, + detail="宪章未对齐,任务环节未解锁", + ) + +def _is_manager(user: User) -> bool: + return has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]) + +def _require_manager(user: User) -> None: + if not _is_manager(user): + raise HTTPException(status_code=403, detail="仅项目负责人或管理员可执行该操作") + diff --git a/backend/app/services/sstc/review_utils.py b/backend/app/services/sstc/review_utils.py new file mode 100644 index 00000000..d75ebd23 --- /dev/null +++ b/backend/app/services/sstc/review_utils.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from .shared import * + +def _normalize_review_content(raw: dict[str, Any] | None) -> dict[str, Any]: + payload = dict(raw or {}) + summary = str(payload.get("summary") or "").strip() + goals = payload.get("goals") + if not isinstance(goals, list): + goals = [] + what_went_well = payload.get("what_went_well") + if not isinstance(what_went_well, list): + what_went_well = [] + root_causes = payload.get("root_causes") + if not isinstance(root_causes, list): + root_causes = [] + improvements = payload.get("improvements") + if not isinstance(improvements, list): + improvements = [] + + normalized_improvements: list[dict[str, Any]] = [] + def _optional_int(value: Any) -> int | None: + if value in (None, ""): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + for idx, item in enumerate(improvements): + if isinstance(item, str): + title = item.strip() + if not title: + continue + normalized_improvements.append( + { + "id": f"imp-{idx+1}", + "title": title, + "status": "pending", + "owner_id": None, + "due_date": None, + "task_id": None, + } + ) + continue + if not isinstance(item, dict): + continue + title = str(item.get("title") or "").strip() + if not title: + continue + normalized_improvements.append( + { + "id": str(item.get("id") or f"imp-{idx+1}"), + "title": title, + "status": str(item.get("status") or "pending"), + "owner_id": _optional_int(item.get("owner_id")), + "due_date": item.get("due_date"), + "task_id": _optional_int(item.get("task_id")), + "description": str(item.get("description") or "").strip() or None, + } + ) + + return { + "summary": summary, + "goals": [str(x).strip() for x in goals if str(x).strip()], + "what_went_well": [str(x).strip() for x in what_went_well if str(x).strip()], + "root_causes": [str(x).strip() for x in root_causes if str(x).strip()], + "improvements": normalized_improvements, + "last_updated_at": now_cn_aware().isoformat(), + } + diff --git a/backend/app/services/sstc/shared.py b/backend/app/services/sstc/shared.py new file mode 100644 index 00000000..17963786 --- /dev/null +++ b/backend/app/services/sstc/shared.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from __future__ import annotations + +import asyncio +import json +import re +from datetime import datetime, timedelta, timezone +from io import BytesIO +from pathlib import Path +from typing import Any, Optional +from uuid import uuid4 + +from fastapi import APIRouter, BackgroundTasks, Body, Depends, File, HTTPException, Query, UploadFile +from fastapi.responses import JSONResponse, StreamingResponse +from pydantic import BaseModel +from sqlalchemy import and_, delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.response import error_response, success_response +from ...database import get_db +from ...models import ( + AICallLog, + AlignmentRecord, + Integration, + ModelRequirementBinding, + ModelVersion, + ProjectSignalLink, + Signal, + Task, + TruthNode, + User, + UserProject, +) +from ...schemas.sstc import ( + AlignmentConfirmRequest, + AlignmentStartRequest, + CharterUpdateRequest, + ReleaseVerificationItemPatchRequest, + ReleaseCreateRequest, + ReleaseStatusPatchRequest, + ReviewArchiveRequest, + ReviewContentPatchRequest, + ReviewCreateRequest, + ReviewImprovementToTaskRequest, + WorkloadOptimizeRequest, + TaskStatusPatchRequest, +) +from ...services.llm import llm_service +from ...services.ai_service import AIService +from ...services.ai_pmo_kernel_service import AIPMOKernelService +from ...services.operation_log_service import log_operation +from ...services.auth import has_any_role +from ...services.skill_service import recommend_personnel +from ...services import skill_score_service +from ...services.sstc_service import ( + ALIGNED_STATUSES, + DONE_STATUSES, + RELEASE_VERIFIED_STATUSES, + SSTCService, +) +from ...services import workload_service +from ...services.budget_enforcement_service import BudgetEnforcementService +from ...services.feishu_integration import get_feishu_service +from ...services.adaptive_rag_service import AdaptiveRAGService +from ...services.context_cache import context_cache_service +from ...services.task_persistence import build_task_create_draft, persist_task +from ...dependencies import get_current_user, require_project_member_or_manager +from ...utils.title import compact_title + + +from ...utils.timezone import CN_TZ, now_cn_aware, now_cn_naive +_FEISHU_PLACEHOLDER_RE = re.compile(r"^飞书用户\(([^)]+)\)$") +_SIGNAL_PREFIX_RE = re.compile(r"^\[(.*?)\]\s*:\s*") +_GENERIC_SIGNAL_SOURCE_TITLE_RE = re.compile( + r"(加入到信号源|加入信号源|新增(?:一个)?信号源|创建(?:一个)?信号源|添加(?:一个)?信号源)" +) + +DEFAULT_SIGNAL_AUTO_ENTER_THRESHOLD = 0.80 +DEFAULT_PROJECT_AUTO_CREATE_THRESHOLD = 0.85 +DEFAULT_PROJECT_UNCERTAIN_THRESHOLD = 0.60 + +_SIGNAL_STATUS_CODE_PENDING = "pending" +_SIGNAL_STATUS_CODE_LINKED = "linked" +_SIGNAL_STATUS_CODE_CLOSED = "closed" +_SIGNAL_STATUS_LABEL_BY_CODE = { + _SIGNAL_STATUS_CODE_PENDING: "待处理", + _SIGNAL_STATUS_CODE_LINKED: "已关联宪章", + _SIGNAL_STATUS_CODE_CLOSED: "已关闭", +} +_SIGNAL_STATUS_FILTER_ALIAS = { + "pending": _SIGNAL_STATUS_CODE_PENDING, + "待处理": _SIGNAL_STATUS_CODE_PENDING, + "linked": _SIGNAL_STATUS_CODE_LINKED, + "已关联宪章": _SIGNAL_STATUS_CODE_LINKED, + "closed": _SIGNAL_STATUS_CODE_CLOSED, + "archived": _SIGNAL_STATUS_CODE_CLOSED, + "已关闭": _SIGNAL_STATUS_CODE_CLOSED, + "已归档": _SIGNAL_STATUS_CODE_CLOSED, +} +_SIGNAL_LOCK_REASON = "信号已关联宪章,禁止重复新建" +_BEIJING_UTC_OFFSET = timedelta(hours=8) +_ALLOWED_ATTACHMENT_TYPES = {"image", "file", "media", "document"} +_SIGNAL_ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024 +_SIGNAL_ATTACHMENT_ALLOWED_EXTENSIONS = { + ".jpg": "image", + ".jpeg": "image", + ".png": "image", + ".gif": "image", + ".pdf": "document", + ".doc": "document", + ".docx": "document", + ".zip": "file", +} +_SIGNAL_ATTACHMENT_ALLOWED_MIME_TYPES = { + "image/jpeg", + "image/png", + "image/gif", + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/zip", + "application/x-zip-compressed", + "multipart/x-zip", + "application/octet-stream", +} + +_CHARTER_REQUIRED_FIELDS: dict[str, tuple[str, ...]] = { + "module_1": ("problem_statement", "goal_statement", "kpis"), + "module_2": ("in_scope", "out_of_scope", "alignment_qa"), + "module_3": ("primary_model", "fallback_model", "capability_table", "dependency_map"), + "module_4": ("milestones", "role_effort_estimates", "capability_owner_assignment"), + "module_5": ("risk_register", "trigger_rules", "execution_rule"), + "module_6": ("attachment_metadata", "attachment_summary", "search_tags"), +} + +RELEASE_STATUS_TRANSITIONS: dict[str, set[str]] = { + "pending": {"releasing", "online"}, + "releasing": {"online", "rolled_back"}, + "online": {"rolled_back"}, + "rolled_back": {"releasing"}, + "verified": set(), +} + + diff --git a/backend/app/services/sstc/signal_core.py b/backend/app/services/sstc/signal_core.py new file mode 100644 index 00000000..35493346 --- /dev/null +++ b/backend/app/services/sstc/signal_core.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +from .shared import * + +def _sync_pmflow_rag_async( + *, + tenant_id: int, + record_type: str, + record_id: int, + project_id: int, + title: str, + content: dict[str, Any] | str | None, +) -> None: + try: + if isinstance(content, str): + payload = content + else: + payload = json.dumps(content or {}, ensure_ascii=False) + asyncio.create_task( + AdaptiveRAGService.upsert_pmflow_record_async( + tenant_id=tenant_id, + record_type=record_type, + record_id=record_id, + project_id=project_id, + title=title, + content=payload, + ) + ) + except Exception: + return + +def _parse_project_id(project_id: str) -> int: + try: + return int(project_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid project_id") from exc + +def _parse_node_id(node_id: str, label: str) -> int: + try: + return int(node_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=f"Invalid {label}") from exc + +def _as_dict(value: Any) -> dict: + if isinstance(value, dict): + return value + if isinstance(value, str): + import json + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {"text": value} + except (TypeError, ValueError): + return {"text": value} + return {} + +def _signal_ai_analysis(signal: Signal) -> dict: + payload = _as_dict(signal.strategic_analysis) + ai_payload = payload.get("ai_analysis") + return ai_payload if isinstance(ai_payload, dict) else {} + +def _signal_project_scope(signal: Signal) -> Optional[str]: + scope = str(_signal_ai_analysis(signal).get("requirement_project_scope") or "").strip().lower() + if scope in {"new_project", "existing_project", "pending_classification", "non_requirement"}: + return scope + return None + +def _signal_display_type(signal: Signal) -> str: + scope = _signal_project_scope(signal) + if scope: + return scope + return "pending_classification" + +def _signal_source_context(signal: Signal) -> dict: + payload = _as_dict(signal.strategic_analysis) + source_context = payload.get("source_context") + return source_context if isinstance(source_context, dict) else {} + +def _normalize_planner_status(value: Any) -> str: + raw = str(value or "").strip().lower() + if raw in {"pending", "completed", "failed"}: + return raw + return "pending" + +def _safe_int(value: Any) -> Optional[int]: + try: + return int(str(value).strip()) + except (TypeError, ValueError, AttributeError): + return None + +def _signal_attachment_storage_root() -> Path: + root = Path(__file__).resolve().parents[4] / "storage" / "signal_attachments" + root.mkdir(parents=True, exist_ok=True) + return root + +def _build_signal_attachment_download_url(project_id: int, signal_id: int, attachment_id: str) -> str: + return f"/projects/{project_id}/signals/{signal_id}/attachments/{attachment_id}/download" + +def _resolve_local_attachment_path(file_key: str) -> Optional[Path]: + raw = str(file_key or "").strip() + if not raw.startswith("local://"): + return None + relative = raw.removeprefix("local://").strip().lstrip("/") + if not relative: + return None + storage_root = _signal_attachment_storage_root().resolve() + candidate = (storage_root / relative).resolve() + try: + candidate.relative_to(storage_root) + except ValueError: + return None + return candidate + +def _error_json(status_code: int, message: str) -> JSONResponse: + return JSONResponse(status_code=status_code, content=error_response(status_code, message)) + +def _normalize_attachment_type(value: Any) -> str: + raw = str(value or "").strip().lower() + alias = { + "img": "image", + "picture": "image", + "attachment": "file", + "doc": "document", + "docs": "document", + } + mapped = alias.get(raw, raw) + return mapped if mapped in _ALLOWED_ATTACHMENT_TYPES else "file" + +def _normalize_signal_attachment(item: Any, *, index: int) -> Optional[dict[str, Any]]: + if not isinstance(item, dict): + return None + attachment_type = _normalize_attachment_type(item.get("attachment_type") or item.get("type")) + attachment_id = ( + str(item.get("attachment_id") or item.get("id") or item.get("file_key") or item.get("image_key") or item.get("media_id") or "") + .strip() + ) + if not attachment_id: + attachment_id = f"attachment-{index + 1}" + file_name = ( + str(item.get("file_name") or item.get("name") or item.get("title") or "").strip() + or f"{attachment_type}-{attachment_id}" + ) + file_size = _safe_int(item.get("file_size") or item.get("size")) + mime_type = str(item.get("mime_type") or item.get("mime") or "").strip() or None + occurred_at = str(item.get("occurred_at") or "").strip() or None + download_url = str(item.get("download_url") or item.get("url") or "").strip() or None + file_key = str(item.get("file_key") or "").strip() or None + image_key = str(item.get("image_key") or "").strip() or None + media_id = str(item.get("media_id") or "").strip() or None + source = str(item.get("source") or "").strip() or None + if not any([file_key, image_key, media_id, download_url]): + return None + return { + "id": attachment_id, + "attachment_id": attachment_id, + "attachment_type": attachment_type, + "file_name": file_name, + "mime_type": mime_type, + "file_size": file_size, + "file_key": file_key, + "image_key": image_key, + "media_id": media_id, + "source": source, + "occurred_at": occurred_at, + "download_url": download_url, + } + +def _extract_source_attachments(source_context: dict[str, Any]) -> list[dict[str, Any]]: + raw_items = source_context.get("attachments") + if not isinstance(raw_items, list): + return [] + items: list[dict[str, Any]] = [] + seen_ids: set[str] = set() + for idx, raw in enumerate(raw_items): + normalized = _normalize_signal_attachment(raw, index=idx) + if normalized is None: + continue + aid = str(normalized.get("attachment_id") or "").strip() + if not aid or aid in seen_ids: + continue + seen_ids.add(aid) + items.append(normalized) + return items + +def _extract_signal_attachments(signal: Signal) -> list[dict[str, Any]]: + source_context = _signal_source_context(signal) + return _extract_source_attachments(source_context) + +def _normalize_signal_status_code( + *, + linked_state: Optional[str], + signal_status: Optional[str], + processed: bool, +) -> str: + if linked_state in {_SIGNAL_STATUS_CODE_LINKED, _SIGNAL_STATUS_CODE_CLOSED}: + return linked_state + raw = str(signal_status or "").strip().lower() + if "关联" in raw or raw == _SIGNAL_STATUS_CODE_LINKED: + return _SIGNAL_STATUS_CODE_LINKED + if "关闭" in raw or "归档" in raw or raw in {"closed", "archived"}: + return _SIGNAL_STATUS_CODE_CLOSED + if processed: + return _SIGNAL_STATUS_CODE_CLOSED + return _SIGNAL_STATUS_CODE_PENDING + +def _signal_status_label_from_code(status_code: str, fallback_status: Optional[str] = None) -> str: + fallback = str(fallback_status or "").strip() + normalized_code = str(status_code or "").strip().lower() + if ( + normalized_code == _SIGNAL_STATUS_CODE_PENDING + and fallback + and all(token not in fallback for token in ["关联", "关闭", "归档"]) + ): + return fallback + label = _SIGNAL_STATUS_LABEL_BY_CODE.get(normalized_code) + if label: + return label + return fallback or _SIGNAL_STATUS_LABEL_BY_CODE[_SIGNAL_STATUS_CODE_PENDING] + diff --git a/backend/app/services/sstc/signal_filters.py b/backend/app/services/sstc/signal_filters.py new file mode 100644 index 00000000..bc72e879 --- /dev/null +++ b/backend/app/services/sstc/signal_filters.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +from .shared import * + +from .signal_core import _as_dict, _safe_int, _signal_source_context + +def _extract_charter_related_signal_ids(charter: TruthNode) -> list[int]: + payload = _as_dict(charter.content) + signal_ids: list[int] = [] + raw_ids = payload.get("signal_ids") + if isinstance(raw_ids, list): + for raw_id in raw_ids: + sid = _safe_int(raw_id) + if sid is not None: + signal_ids.append(sid) + charter_signal_id = _safe_int(charter.signal_id) + if charter_signal_id is not None: + signal_ids.append(charter_signal_id) + return sorted(set(signal_ids)) + +def _collect_locked_signal_conflicts( + *, + charters: list[TruthNode], + candidate_signal_ids: list[int], +) -> tuple[list[int], dict[int, list[dict[str, Any]]]]: + candidate_set = set(int(sid) for sid in candidate_signal_ids) + conflicts: dict[int, list[dict[str, Any]]] = {} + for charter in charters: + charter_signal_ids = _extract_charter_related_signal_ids(charter) + if not charter_signal_ids: + continue + for sid in charter_signal_ids: + if sid not in candidate_set: + continue + conflicts.setdefault(sid, []).append( + { + "charter_id": int(charter.id), + "charter_title": str(charter.title or "").strip() or f"宪章#{int(charter.id)}", + } + ) + conflict_ids = sorted(conflicts.keys()) + return conflict_ids, conflicts + +def _parse_beijing_day_start(raw: str, *, label: str) -> datetime: + text = str(raw or "").strip() + if not text: + raise HTTPException(status_code=400, detail=f"{label} 不能为空") + if "T" in text: + text = text.split("T")[0] + try: + local_day = datetime.strptime(text, "%Y-%m-%d") + except ValueError as exc: + raise HTTPException(status_code=400, detail=f"Invalid {label}") from exc + return local_day - _BEIJING_UTC_OFFSET + +def _build_signal_created_at_range( + *, + created_from: Optional[str], + created_to: Optional[str], +) -> tuple[Optional[datetime], Optional[datetime]]: + start_utc = _parse_beijing_day_start(created_from, label="created_from") if created_from else None + end_utc = None + if created_to: + end_day_start_utc = _parse_beijing_day_start(created_to, label="created_to") + end_utc = end_day_start_utc + timedelta(days=1) + if start_utc and end_utc and start_utc >= end_utc: + raise HTTPException(status_code=400, detail="created_from 不能晚于 created_to") + return start_utc, end_utc + +def _normalize_signal_status_filter(value: Optional[str]) -> Optional[str]: + raw = str(value or "").strip() + if not raw: + return None + return _SIGNAL_STATUS_FILTER_ALIAS.get(raw.lower(), _SIGNAL_STATUS_FILTER_ALIAS.get(raw, raw.lower())) + +def _match_signal_filters( + *, + row: dict[str, Any], + status_filter: Optional[str], + keyword: Optional[str], + source: Optional[str], + priority: Optional[str], + created_from_utc: Optional[datetime], + created_to_utc: Optional[datetime], + created_at_dt: Optional[datetime], +) -> bool: + if status_filter: + if str(row.get("status_code") or "").strip().lower() != str(status_filter).strip().lower(): + return False + + keyword_text = str(keyword or "").strip().lower() + if keyword_text: + haystack = " ".join( + [ + str(row.get("id") or ""), + str(row.get("title") or ""), + str(row.get("content") or ""), + str(row.get("proposer_name") or ""), + str(row.get("charter_name") or ""), + ] + ).lower() + if keyword_text not in haystack: + return False + + source_text = str(source or "").strip().lower() + if source_text and source_text != "all": + row_source = str(row.get("source") or "").strip().lower() + if row_source != source_text: + return False + + priority_text = str(priority or "").strip().lower() + if priority_text and priority_text != "all": + row_priority = str(row.get("priority") or "").strip().lower() + if row_priority != priority_text: + return False + + if created_from_utc or created_to_utc: + if created_at_dt is None: + return False + if created_from_utc and created_at_dt < created_from_utc: + return False + if created_to_utc and created_at_dt >= created_to_utc: + return False + + return True + +def _strip_signal_sender_prefix(content: str) -> str: + text = str(content or "") + return _SIGNAL_PREFIX_RE.sub("", text, count=1).strip() or text + +def _extract_sender_from_content(content: str) -> Optional[str]: + text = str(content or "") + matched = _SIGNAL_PREFIX_RE.match(text) + if not matched: + return None + sender = (matched.group(1) or "").strip() + return sender or None + +def _signal_title_text(signal: Signal) -> str: + for candidate in ( + str(signal.title or "").strip(), + str(signal.summary or "").strip(), + _strip_signal_sender_prefix(str(signal.content or "")), + ): + compacted = compact_title(candidate, max_len=80) + if not compacted: + continue + normalized = re.sub(r"\s+", "", compacted).lower() + if _GENERIC_SIGNAL_SOURCE_TITLE_RE.search(normalized): + specific_hints = ("chat_id", "http", "https", "飞书", "企业微信", "github", "gitlab", "jira", "webhook", "项目", "群聊", "repo") + if not any(hint in normalized for hint in specific_hints): + return "信号源接入请求(待补充参数)" + return compacted + return "" + +def _signal_proposer_name(signal: Signal) -> str: + source_context = _signal_source_context(signal) + sender = str(source_context.get("sender") or "").strip() + if sender: + return sender + sender_from_content = _extract_sender_from_content(str(signal.content or "")) + return sender_from_content or "未知用户" + +def _replace_signal_sender_in_content(content: str, sender_name: str) -> str: + text = str(content or "") + if not text: + return text + if _SIGNAL_PREFIX_RE.match(text): + return _SIGNAL_PREFIX_RE.sub(f"[{sender_name}]: ", text, count=1) + return f"[{sender_name}]: {text}" + diff --git a/backend/app/services/sstc_service.py b/backend/app/services/sstc_service.py index d9dfd87d..017315d0 100644 --- a/backend/app/services/sstc_service.py +++ b/backend/app/services/sstc_service.py @@ -1,642 +1,3 @@ -"""SSTC 看板聚合服务""" - from __future__ import annotations -from datetime import datetime, timezone -from typing import Any, Iterable - -from sqlalchemy import and_, func, select -from sqlalchemy.ext.asyncio import AsyncSession - -from ..models import ( - AlignmentRecord, - CognitiveConflict, - Project, - ProjectSignalLink, - Signal, - Task, - TruthNode, - User, - UserProject, - UserWorkload, -) -from ..utils.project_members import ensure_project_default_members - -from ..utils.timezone import now_cn_aware -DONE_STATUSES = {"done", "completed"} -ACTIVE_STATUSES = {"in_progress", "review"} -BLOCKED_STATUSES = {"blocked"} -ALIGNED_STATUSES = {"aligned", "confirmed", "已对齐"} -ALIGNING_STATUSES = {"aligning", "alignment_in_progress", "对齐中"} -ARCHIVED_STATUSES = {"archived", "已归档"} -RELEASE_VERIFIED_STATUSES = {"verified", "已验证"} -RELEASE_LIVE_STATUSES = {"online", "deployed", "已上线"} -RELEASE_RELEASING_STATUSES = {"releasing", "发布中"} - - -def _to_json(value: Any, fallback: dict | None = None) -> dict: - if fallback is None: - fallback = {} - if isinstance(value, dict): - return value - if isinstance(value, str): - import json - try: - parsed = json.loads(value) - return parsed if isinstance(parsed, dict) else {"text": value} - except (TypeError, ValueError): - return {"text": value} - return fallback - - -def _avg(values: Iterable[float]) -> float: - values = list(values) - if not values: - return 0.0 - return float(sum(values) / len(values)) - - -class SSTCService: - def __init__(self, db: AsyncSession, tenant_id: int): - self.db = db - self.tenant_id = tenant_id - - async def get_project(self, project_id: int) -> Project | None: - result = await self.db.execute( - select(Project).where( - and_(Project.id == project_id, Project.tenant_id == self.tenant_id) - ) - ) - return result.scalar_one_or_none() - - async def get_signals(self, project_id: int) -> list[Signal]: - linked_result = await self.db.execute( - select(Signal) - .join(ProjectSignalLink, ProjectSignalLink.signal_id == Signal.id) - .where( - and_( - ProjectSignalLink.project_id == project_id, - Signal.tenant_id == self.tenant_id, - ) - ) - .order_by(Signal.created_at.desc()) - ) - linked_rows = list(linked_result.scalars().all()) - merged: dict[int, Signal] = { - int(row.id): row - for row in linked_rows - if getattr(row, "id", None) is not None - } - - fallback_result = await self.db.execute( - select(Signal) - .where( - and_( - Signal.matched_project_id == project_id, - Signal.tenant_id == self.tenant_id, - ) - ) - .order_by(Signal.created_at.desc()) - ) - for row in fallback_result.scalars().all(): - if getattr(row, "id", None) is None: - continue - merged.setdefault(int(row.id), row) - - return sorted( - merged.values(), - key=lambda row: row.created_at or datetime.min, - reverse=True, - ) - - async def get_charters(self, project_id: int) -> list[TruthNode]: - result = await self.db.execute( - select(TruthNode) - .where( - and_( - TruthNode.project_id == project_id, - TruthNode.tenant_id == self.tenant_id, - TruthNode.node_type == "charter", - ) - ) - .order_by(TruthNode.created_at.desc()) - ) - return list(result.scalars().all()) - - async def get_tasks(self, project_id: int) -> list[Task]: - result = await self.db.execute( - select(Task).where( - and_(Task.project_id == project_id, Task.tenant_id == self.tenant_id) - ) - ) - return list(result.scalars().all()) - - async def get_releases(self, project_id: int) -> list[TruthNode]: - result = await self.db.execute( - select(TruthNode) - .where( - and_( - TruthNode.project_id == project_id, - TruthNode.tenant_id == self.tenant_id, - TruthNode.node_type == "release", - ) - ) - .order_by(TruthNode.created_at.desc()) - ) - return list(result.scalars().all()) - - async def get_reviews(self, project_id: int) -> list[TruthNode]: - result = await self.db.execute( - select(TruthNode) - .where( - and_( - TruthNode.project_id == project_id, - TruthNode.tenant_id == self.tenant_id, - TruthNode.node_type == "review", - ) - ) - .order_by(TruthNode.created_at.desc()) - ) - return list(result.scalars().all()) - - async def get_alignment_stats(self, charter_ids: list[int]) -> dict[str, Any]: - if not charter_ids: - return {"score": 0.0, "pending_nodes": 0, "unresolved_conflicts": 0} - - ar_result = await self.db.execute( - select(AlignmentRecord).where( - and_( - AlignmentRecord.truth_node_id.in_(charter_ids), - AlignmentRecord.tenant_id == self.tenant_id, - ) - ) - ) - records = list(ar_result.scalars().all()) - score = _avg([float(r.alignment_score or 0) for r in records]) - - node_confirmed: dict[int, set[int]] = {} - for r in records: - if r.is_confirmed: - node_confirmed.setdefault(int(r.truth_node_id), set()).add(int(r.user_id)) - pending_nodes = len([nid for nid in charter_ids if not node_confirmed.get(nid)]) - - conflict_result = await self.db.execute( - select(func.count(CognitiveConflict.id)).where( - and_( - CognitiveConflict.truth_node_id.in_(charter_ids), - CognitiveConflict.tenant_id == self.tenant_id, - CognitiveConflict.status != "resolved", - ) - ) - ) - unresolved_conflicts = int(conflict_result.scalar() or 0) - return { - "score": round(score, 2), - "pending_nodes": pending_nodes, - "unresolved_conflicts": unresolved_conflicts, - } - - async def get_workload_health(self, assignee_ids: list[int]) -> dict[str, Any]: - member_profiles = await self._load_member_workload_profiles(assignee_ids) - if not member_profiles: - return { - "avg_ratio": 0.0, - "overloaded_count": 0, - "idle_count": 0, - "member_count": 0, - "members": [], - } - - ratios = [float(item["workload_ratio"]) for item in member_profiles] - overloaded_count = len([x for x in ratios if x > 1.0]) - idle_count = len([x for x in ratios if x < 0.3]) - return { - "avg_ratio": round(_avg(ratios), 2), - "overloaded_count": overloaded_count, - "idle_count": idle_count, - "member_count": len(member_profiles), - "members": member_profiles, - } - - @staticmethod - def _normalize_workload_ratio(raw_ratio: float | int | None) -> float: - ratio = float(raw_ratio or 0) - if ratio > 1.5: - ratio = ratio / 100.0 - return max(0.0, ratio) - - async def _load_member_workload_profiles(self, assignee_ids: list[int]) -> list[dict[str, Any]]: - if not assignee_ids: - return [] - - rows = await self.db.execute( - select(User.id, User.name, User.username, User.role, UserWorkload.workload_ratio) - .select_from(User) - .outerjoin( - UserWorkload, - and_( - UserWorkload.tenant_id == self.tenant_id, - UserWorkload.user_id == User.id, - ), - ) - .where( - and_( - User.tenant_id == self.tenant_id, - User.id.in_(assignee_ids), - User.is_active.is_(True), - ) - ) - .order_by(User.id.asc()) - ) - - members: list[dict[str, Any]] = [] - for user_id, name, username, role, workload_ratio in rows.all(): - ratio = self._normalize_workload_ratio(workload_ratio) - if ratio > 1.0: - status = "overloaded" - elif ratio < 0.3: - status = "idle" - else: - status = "normal" - members.append( - { - "user_id": str(int(user_id)), - "name": str(name or username or f"用户{int(user_id)}"), - "role": str(role or ""), - "workload_ratio": round(ratio, 2), - "status": status, - } - ) - return members - - @staticmethod - def _task_metrics(tasks: list[Task]) -> dict[str, Any]: - total = len(tasks) - done = len([t for t in tasks if (t.status or "").lower() in DONE_STATUSES]) - blocked = len([t for t in tasks if (t.status or "").lower() in BLOCKED_STATUSES]) - in_progress = len([t for t in tasks if (t.status or "").lower() in ACTIVE_STATUSES]) - today_done = len( - [ - t - for t in tasks - if (t.status or "").lower() in DONE_STATUSES - and t.completed_at - and t.completed_at.date() == datetime.now().date() - ] - ) - overdue = len( - [ - t - for t in tasks - if t.due_date and t.due_date < now_cn_aware().replace(tzinfo=None) and (t.status or "").lower() not in DONE_STATUSES - ] - ) - return { - "total": total, - "done": done, - "blocked": blocked, - "in_progress": in_progress, - "today_done": today_done, - "overdue": overdue, - "progress": round((done / total) * 100, 2) if total else 0, - } - - async def build_overview(self, project_id: int) -> dict[str, Any]: - project = await self.get_project(project_id) - if not project: - return {} - signals = await self.get_signals(project_id) - charters = await self.get_charters(project_id) - tasks = await self.get_tasks(project_id) - charters_ids = [int(c.id) for c in charters] - alignment = await self.get_alignment_stats(charters_ids) - metrics = self._task_metrics(tasks) - member_ids = await self.get_project_workload_member_ids(project_id) - workload = await self.get_workload_health(member_ids) - chain_id = str(charters[0].id) if charters else None - return { - "project": { - "id": str(project.id), - "title": project.title or project.name, - "description": project.description, - "status": project.status, - "owner_id": str(project.owner_id) if project.owner_id else None, - "created_at": project.created_at.isoformat() if project.created_at else None, - "chain_id": chain_id, - "signal_count": len(signals), - "charter_count": len(charters), - "task_count": len(tasks), - }, - "chain_id": chain_id, - "alignment_score": alignment["score"], - "pending_alignment_nodes": alignment["pending_nodes"], - "unresolved_conflicts": alignment["unresolved_conflicts"], - "task_progress": metrics, - "workload_health": workload, - } - - async def build_stages(self, project_id: int) -> list[dict[str, Any]]: - signals = await self.get_signals(project_id) - charters = await self.get_charters(project_id) - tasks = await self.get_tasks(project_id) - releases = await self.get_releases(project_id) - reviews = await self.get_reviews(project_id) - - has_p0 = any((s.priority or "").lower() == "p0" for s in signals) - has_pending_signal = any(not bool(s.processed) for s in signals) - signal_status = "已同步" - if has_p0 or has_pending_signal: - signal_status = "待处理" - - latest_charter = charters[0] if charters else None - charter_status_raw = (latest_charter.status if latest_charter else "").lower() - if not latest_charter: - charter_status = "草稿" - elif charter_status_raw in ALIGNING_STATUSES: - charter_status = "对齐中" - elif charter_status_raw in ARCHIVED_STATUSES: - charter_status = "已归档" - elif charter_status_raw in ALIGNED_STATUSES: - charter_status = "已对齐" - else: - charter_status = "草稿" - charter_aligned = charter_status == "已对齐" - - if not charter_aligned: - task_status = "未解锁" - elif not tasks: - task_status = "待确认" - elif all((t.status or "").lower() in DONE_STATUSES for t in tasks): - task_status = "已确认" - else: - task_status = "待确认" - - if not tasks: - execution_status = "未开始" - elif all((t.status or "").lower() in DONE_STATUSES for t in tasks): - execution_status = "已完成" - elif any((t.status or "").lower() in (ACTIVE_STATUSES | BLOCKED_STATUSES) for t in tasks): - execution_status = "进行中" - else: - execution_status = "未开始" - metrics = self._task_metrics(tasks) - execution_done = execution_status == "已完成" - release_pre_unlocked = charter_aligned and metrics["done"] > 0 - - latest_release = releases[0] if releases else None - release_raw = (latest_release.status if latest_release else "").lower() - if not release_pre_unlocked: - release_status = "未解锁" - elif not execution_done and not latest_release: - release_status = "可预发布" - elif not latest_release: - release_status = "待发布" - elif release_raw in RELEASE_VERIFIED_STATUSES: - release_status = "已验证" - elif release_raw in RELEASE_RELEASING_STATUSES: - release_status = "发布中" - elif release_raw in RELEASE_LIVE_STATUSES: - release_status = "已上线" - else: - release_status = "可预发布" if not execution_done else "待发布" - release_verified = release_status == "已验证" - review_pre_unlocked = release_status in {"已上线", "已验证", "发布中"} - - latest_review = reviews[0] if reviews else None - review_raw = (latest_review.status if latest_review else "").lower() - if not review_pre_unlocked: - review_status = "未解锁" - elif not release_verified and not latest_review: - review_status = "可阶段复盘" - elif not latest_review: - review_status = "草稿" - elif review_raw in ARCHIVED_STATUSES: - review_status = "已归档" - else: - review_status = "草稿" - - return [ - { - "key": "signal", - "label": "信号源", - "status": signal_status, - "summary": f"{len(signals)} 条有效信号", - "locked": False, - "editable": True, - "risk_count": int(has_p0), - }, - { - "key": "charter", - "label": "需求宪章", - "status": charter_status, - "summary": f"{len(charters)} 份宪章", - "locked": False, - "editable": True, - "risk_count": 1 if charter_status == "对齐中" else 0, - }, - { - "key": "task", - "label": "任务拆解", - "status": task_status, - "summary": f"{len(tasks)} 个任务", - "locked": not charter_aligned, - "editable": charter_aligned, - "risk_count": metrics["blocked"], - }, - { - "key": "execution", - "label": "执行落地", - "status": execution_status, - "summary": f"完成率 {metrics['progress']}%", - "locked": not charter_aligned, - "editable": charter_aligned, - "risk_count": metrics["blocked"] + metrics["overdue"], - }, - { - "key": "release", - "label": "发布验证", - "status": release_status, - "summary": f"{len(releases)} 次发布", - "locked": not release_pre_unlocked, - "editable": release_pre_unlocked, - "risk_count": metrics["overdue"], - }, - { - "key": "review", - "label": "复盘沉淀", - "status": review_status, - "summary": f"{len(reviews)} 份复盘", - "locked": not review_pre_unlocked, - "editable": review_pre_unlocked, - "risk_count": 0, - }, - ] - - async def build_risks(self, project_id: int) -> dict[str, Any]: - charters = await self.get_charters(project_id) - tasks = await self.get_tasks(project_id) - charter_ids = [int(c.id) for c in charters] - conflict_rows = [] - if charter_ids: - conflict_result = await self.db.execute( - select(CognitiveConflict).where( - and_( - CognitiveConflict.truth_node_id.in_(charter_ids), - CognitiveConflict.tenant_id == self.tenant_id, - CognitiveConflict.status != "resolved", - ) - ) - ) - conflict_rows = list(conflict_result.scalars().all()) - - blocked_tasks = [t for t in tasks if (t.status or "").lower() in BLOCKED_STATUSES] - overdue_tasks = [ - t - for t in tasks - if t.due_date and t.due_date < now_cn_aware().replace(tzinfo=None) and (t.status or "").lower() not in DONE_STATUSES - ] - risk_items = [ - { - "id": f"task-blocked-{t.id}", - "type": "task_blocked", - "priority": "high", - "title": f"任务阻塞:{t.title}", - "entity_id": str(t.id), - } - for t in blocked_tasks - ] + [ - { - "id": f"task-overdue-{t.id}", - "type": "task_overdue", - "priority": "medium", - "title": f"任务逾期:{t.title}", - "entity_id": str(t.id), - } - for t in overdue_tasks - ] - - conflicts = [ - { - "id": str(c.id), - "type": c.conflict_type, - "priority": "high", - "title": c.description[:80], - "entity_id": str(c.truth_node_id), - } - for c in conflict_rows - ] - return { - "conflicts": conflicts, - "risks": risk_items, - "summary": { - "conflict_count": len(conflicts), - "risk_count": len(risk_items), - "blocked_task_count": len(blocked_tasks), - "overdue_task_count": len(overdue_tasks), - }, - } - - async def build_chain(self, project_id: int) -> dict[str, Any]: - project = await self.get_project(project_id) - if not project: - return {} - - signals = await self.get_signals(project_id) - charters = await self.get_charters(project_id) - tasks = await self.get_tasks(project_id) - - tasks_by_charter: dict[str, list[dict[str, Any]]] = {} - for c in charters: - tasks_by_charter[str(c.id)] = [ - { - "id": str(t.id), - "title": t.title, - "status": t.status, - "priority": t.priority, - "charter_id": str(c.id), - "description": t.description, - "estimated_hours": float(t.estimated_hours) if t.estimated_hours else None, - "assignee_id": str(t.assignee_id) if t.assignee_id else None, - } - for t in tasks - if int(t.truth_node_id) == int(c.id) - ] - - return { - "project": { - "id": str(project.id), - "title": project.title or project.name, - "description": project.description, - }, - "signals": [ - { - "id": str(s.id), - "source": s.source, - "content": (s.content or "")[:240], - "impact_score": s.impact_score, - "priority": s.priority, - "processed": s.processed, - } - for s in signals - ], - "charters": [ - { - "id": str(c.id), - "title": c.title, - "content": _to_json(c.content), - "signal_ids": _to_json(c.content).get("signal_ids", []), - "status": c.status, - "version": c.version, - "parent_id": str(c.parent_id) if c.parent_id else None, - } - for c in charters - ], - "tasks_by_charter": tasks_by_charter, - } - - async def has_member_confirmed(self, charter_id: int, user_id: int) -> bool: - result = await self.db.execute( - select(func.count(AlignmentRecord.id)).where( - and_( - AlignmentRecord.tenant_id == self.tenant_id, - AlignmentRecord.truth_node_id == charter_id, - AlignmentRecord.user_id == user_id, - AlignmentRecord.is_confirmed.is_(True), - ) - ) - ) - return int(result.scalar() or 0) > 0 - - async def _load_project_member_id_set(self, project_id: int) -> set[int]: - member_result = await self.db.execute( - select(UserProject.user_id).where( - and_( - UserProject.project_id == project_id, - UserProject.tenant_id == self.tenant_id, - ) - ) - ) - return {int(x) for x in member_result.scalars().all() if x is not None} - - async def get_project_workload_member_ids(self, project_id: int) -> list[int]: - member_ids = await self._load_project_member_id_set(project_id) - project = await self.get_project(project_id) - if not member_ids and project: - member_ids = set( - await ensure_project_default_members( - self.db, - tenant_id=self.tenant_id, - project_id=int(project_id), - owner_id=int(project.owner_id) if project.owner_id else None, - assigned_role=project.assigned_role, - ) - ) - await self.db.commit() - return sorted(member_ids) - - async def get_project_member_ids(self, project_id: int) -> list[int]: - member_ids = set(await self.get_project_workload_member_ids(project_id)) - project = await self.get_project(project_id) - if project and project.owner_id: - member_ids.add(int(project.owner_id)) - return sorted(member_ids) +from .sstc_service_core import * diff --git a/backend/app/services/sstc_service_core/__init__.py b/backend/app/services/sstc_service_core/__init__.py new file mode 100644 index 00000000..bf7820f3 --- /dev/null +++ b/backend/app/services/sstc_service_core/__init__.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +from .service import * diff --git a/backend/app/services/sstc_service_core/mixin_build_views.py b/backend/app/services/sstc_service_core/mixin_build_views.py new file mode 100644 index 00000000..e6d9bd5b --- /dev/null +++ b/backend/app/services/sstc_service_core/mixin_build_views.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +from .shared_helpers import * + +class SSTCBuildViewsMixin: + async def build_overview(self, project_id: int) -> dict[str, Any]: + project = await self.get_project(project_id) + if not project: + return {} + signals = await self.get_signals(project_id) + charters = await self.get_charters(project_id) + tasks = await self.get_tasks(project_id) + charters_ids = [int(c.id) for c in charters] + alignment = await self.get_alignment_stats(charters_ids) + metrics = self._task_metrics(tasks) + member_ids = await self.get_project_workload_member_ids(project_id) + workload = await self.get_workload_health(member_ids) + chain_id = str(charters[0].id) if charters else None + return { + "project": { + "id": str(project.id), + "title": project.title or project.name, + "description": project.description, + "status": project.status, + "owner_id": str(project.owner_id) if project.owner_id else None, + "created_at": project.created_at.isoformat() if project.created_at else None, + "chain_id": chain_id, + "signal_count": len(signals), + "charter_count": len(charters), + "task_count": len(tasks), + }, + "chain_id": chain_id, + "alignment_score": alignment["score"], + "pending_alignment_nodes": alignment["pending_nodes"], + "unresolved_conflicts": alignment["unresolved_conflicts"], + "task_progress": metrics, + "workload_health": workload, + } + + async def build_stages(self, project_id: int) -> list[dict[str, Any]]: + signals = await self.get_signals(project_id) + charters = await self.get_charters(project_id) + tasks = await self.get_tasks(project_id) + releases = await self.get_releases(project_id) + reviews = await self.get_reviews(project_id) + + has_p0 = any((s.priority or "").lower() == "p0" for s in signals) + has_pending_signal = any(not bool(s.processed) for s in signals) + signal_status = "已同步" + if has_p0 or has_pending_signal: + signal_status = "待处理" + + latest_charter = charters[0] if charters else None + charter_status_raw = (latest_charter.status if latest_charter else "").lower() + if not latest_charter: + charter_status = "草稿" + elif charter_status_raw in ALIGNING_STATUSES: + charter_status = "对齐中" + elif charter_status_raw in ARCHIVED_STATUSES: + charter_status = "已归档" + elif charter_status_raw in ALIGNED_STATUSES: + charter_status = "已对齐" + else: + charter_status = "草稿" + charter_aligned = charter_status == "已对齐" + + if not charter_aligned: + task_status = "未解锁" + elif not tasks: + task_status = "待确认" + elif all((t.status or "").lower() in DONE_STATUSES for t in tasks): + task_status = "已确认" + else: + task_status = "待确认" + + if not tasks: + execution_status = "未开始" + elif all((t.status or "").lower() in DONE_STATUSES for t in tasks): + execution_status = "已完成" + elif any((t.status or "").lower() in (ACTIVE_STATUSES | BLOCKED_STATUSES) for t in tasks): + execution_status = "进行中" + else: + execution_status = "未开始" + metrics = self._task_metrics(tasks) + execution_done = execution_status == "已完成" + release_pre_unlocked = charter_aligned and metrics["done"] > 0 + + latest_release = releases[0] if releases else None + release_raw = (latest_release.status if latest_release else "").lower() + if not release_pre_unlocked: + release_status = "未解锁" + elif not execution_done and not latest_release: + release_status = "可预发布" + elif not latest_release: + release_status = "待发布" + elif release_raw in RELEASE_VERIFIED_STATUSES: + release_status = "已验证" + elif release_raw in RELEASE_RELEASING_STATUSES: + release_status = "发布中" + elif release_raw in RELEASE_LIVE_STATUSES: + release_status = "已上线" + else: + release_status = "可预发布" if not execution_done else "待发布" + release_verified = release_status == "已验证" + review_pre_unlocked = release_status in {"已上线", "已验证", "发布中"} + + latest_review = reviews[0] if reviews else None + review_raw = (latest_review.status if latest_review else "").lower() + if not review_pre_unlocked: + review_status = "未解锁" + elif not release_verified and not latest_review: + review_status = "可阶段复盘" + elif not latest_review: + review_status = "草稿" + elif review_raw in ARCHIVED_STATUSES: + review_status = "已归档" + else: + review_status = "草稿" + + return [ + { + "key": "signal", + "label": "信号源", + "status": signal_status, + "summary": f"{len(signals)} 条有效信号", + "locked": False, + "editable": True, + "risk_count": int(has_p0), + }, + { + "key": "charter", + "label": "需求宪章", + "status": charter_status, + "summary": f"{len(charters)} 份宪章", + "locked": False, + "editable": True, + "risk_count": 1 if charter_status == "对齐中" else 0, + }, + { + "key": "task", + "label": "任务拆解", + "status": task_status, + "summary": f"{len(tasks)} 个任务", + "locked": not charter_aligned, + "editable": charter_aligned, + "risk_count": metrics["blocked"], + }, + { + "key": "execution", + "label": "执行落地", + "status": execution_status, + "summary": f"完成率 {metrics['progress']}%", + "locked": not charter_aligned, + "editable": charter_aligned, + "risk_count": metrics["blocked"] + metrics["overdue"], + }, + { + "key": "release", + "label": "发布验证", + "status": release_status, + "summary": f"{len(releases)} 次发布", + "locked": not release_pre_unlocked, + "editable": release_pre_unlocked, + "risk_count": metrics["overdue"], + }, + { + "key": "review", + "label": "复盘沉淀", + "status": review_status, + "summary": f"{len(reviews)} 份复盘", + "locked": not review_pre_unlocked, + "editable": review_pre_unlocked, + "risk_count": 0, + }, + ] + + async def build_risks(self, project_id: int) -> dict[str, Any]: + charters = await self.get_charters(project_id) + tasks = await self.get_tasks(project_id) + charter_ids = [int(c.id) for c in charters] + conflict_rows = [] + if charter_ids: + conflict_result = await self.db.execute( + select(CognitiveConflict).where( + and_( + CognitiveConflict.truth_node_id.in_(charter_ids), + CognitiveConflict.tenant_id == self.tenant_id, + CognitiveConflict.status != "resolved", + ) + ) + ) + conflict_rows = list(conflict_result.scalars().all()) + + blocked_tasks = [t for t in tasks if (t.status or "").lower() in BLOCKED_STATUSES] + overdue_tasks = [ + t + for t in tasks + if t.due_date and t.due_date < now_cn_aware().replace(tzinfo=None) and (t.status or "").lower() not in DONE_STATUSES + ] + risk_items = [ + { + "id": f"task-blocked-{t.id}", + "type": "task_blocked", + "priority": "high", + "title": f"任务阻塞:{t.title}", + "entity_id": str(t.id), + } + for t in blocked_tasks + ] + [ + { + "id": f"task-overdue-{t.id}", + "type": "task_overdue", + "priority": "medium", + "title": f"任务逾期:{t.title}", + "entity_id": str(t.id), + } + for t in overdue_tasks + ] + + conflicts = [ + { + "id": str(c.id), + "type": c.conflict_type, + "priority": "high", + "title": c.description[:80], + "entity_id": str(c.truth_node_id), + } + for c in conflict_rows + ] + return { + "conflicts": conflicts, + "risks": risk_items, + "summary": { + "conflict_count": len(conflicts), + "risk_count": len(risk_items), + "blocked_task_count": len(blocked_tasks), + "overdue_task_count": len(overdue_tasks), + }, + } + + async def build_chain(self, project_id: int) -> dict[str, Any]: + project = await self.get_project(project_id) + if not project: + return {} + + signals = await self.get_signals(project_id) + charters = await self.get_charters(project_id) + tasks = await self.get_tasks(project_id) + + tasks_by_charter: dict[str, list[dict[str, Any]]] = {} + for c in charters: + tasks_by_charter[str(c.id)] = [ + { + "id": str(t.id), + "title": t.title, + "status": t.status, + "priority": t.priority, + "charter_id": str(c.id), + "description": t.description, + "estimated_hours": float(t.estimated_hours) if t.estimated_hours else None, + "assignee_id": str(t.assignee_id) if t.assignee_id else None, + } + for t in tasks + if int(t.truth_node_id) == int(c.id) + ] + + return { + "project": { + "id": str(project.id), + "title": project.title or project.name, + "description": project.description, + }, + "signals": [ + { + "id": str(s.id), + "source": s.source, + "content": (s.content or "")[:240], + "impact_score": s.impact_score, + "priority": s.priority, + "processed": s.processed, + } + for s in signals + ], + "charters": [ + { + "id": str(c.id), + "title": c.title, + "content": _to_json(c.content), + "signal_ids": _to_json(c.content).get("signal_ids", []), + "status": c.status, + "version": c.version, + "parent_id": str(c.parent_id) if c.parent_id else None, + } + for c in charters + ], + "tasks_by_charter": tasks_by_charter, + } + diff --git a/backend/app/services/sstc_service_core/mixin_fetch_core.py b/backend/app/services/sstc_service_core/mixin_fetch_core.py new file mode 100644 index 00000000..310fd5a4 --- /dev/null +++ b/backend/app/services/sstc_service_core/mixin_fetch_core.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +from .shared_helpers import * + +class SSTCFetchCoreMixin: + def __init__(self, db: AsyncSession, tenant_id: int): + self.db = db + self.tenant_id = tenant_id + + async def get_project(self, project_id: int) -> Project | None: + result = await self.db.execute( + select(Project).where( + and_(Project.id == project_id, Project.tenant_id == self.tenant_id) + ) + ) + return result.scalar_one_or_none() + + async def get_signals(self, project_id: int) -> list[Signal]: + linked_result = await self.db.execute( + select(Signal) + .join(ProjectSignalLink, ProjectSignalLink.signal_id == Signal.id) + .where( + and_( + ProjectSignalLink.project_id == project_id, + Signal.tenant_id == self.tenant_id, + ) + ) + .order_by(Signal.created_at.desc()) + ) + linked_rows = list(linked_result.scalars().all()) + merged: dict[int, Signal] = { + int(row.id): row + for row in linked_rows + if getattr(row, "id", None) is not None + } + + fallback_result = await self.db.execute( + select(Signal) + .where( + and_( + Signal.matched_project_id == project_id, + Signal.tenant_id == self.tenant_id, + ) + ) + .order_by(Signal.created_at.desc()) + ) + for row in fallback_result.scalars().all(): + if getattr(row, "id", None) is None: + continue + merged.setdefault(int(row.id), row) + + return sorted( + merged.values(), + key=lambda row: row.created_at or datetime.min, + reverse=True, + ) + + async def get_charters(self, project_id: int) -> list[TruthNode]: + result = await self.db.execute( + select(TruthNode) + .where( + and_( + TruthNode.project_id == project_id, + TruthNode.tenant_id == self.tenant_id, + TruthNode.node_type == "charter", + ) + ) + .order_by(TruthNode.created_at.desc()) + ) + return list(result.scalars().all()) + + async def get_tasks(self, project_id: int) -> list[Task]: + result = await self.db.execute( + select(Task).where( + and_(Task.project_id == project_id, Task.tenant_id == self.tenant_id) + ) + ) + return list(result.scalars().all()) + + async def get_releases(self, project_id: int) -> list[TruthNode]: + result = await self.db.execute( + select(TruthNode) + .where( + and_( + TruthNode.project_id == project_id, + TruthNode.tenant_id == self.tenant_id, + TruthNode.node_type == "release", + ) + ) + .order_by(TruthNode.created_at.desc()) + ) + return list(result.scalars().all()) + + async def get_reviews(self, project_id: int) -> list[TruthNode]: + result = await self.db.execute( + select(TruthNode) + .where( + and_( + TruthNode.project_id == project_id, + TruthNode.tenant_id == self.tenant_id, + TruthNode.node_type == "review", + ) + ) + .order_by(TruthNode.created_at.desc()) + ) + return list(result.scalars().all()) + + async def get_alignment_stats(self, charter_ids: list[int]) -> dict[str, Any]: + if not charter_ids: + return {"score": 0.0, "pending_nodes": 0, "unresolved_conflicts": 0} + + ar_result = await self.db.execute( + select(AlignmentRecord).where( + and_( + AlignmentRecord.truth_node_id.in_(charter_ids), + AlignmentRecord.tenant_id == self.tenant_id, + ) + ) + ) + records = list(ar_result.scalars().all()) + score = _avg([float(r.alignment_score or 0) for r in records]) + + node_confirmed: dict[int, set[int]] = {} + for r in records: + if r.is_confirmed: + node_confirmed.setdefault(int(r.truth_node_id), set()).add(int(r.user_id)) + pending_nodes = len([nid for nid in charter_ids if not node_confirmed.get(nid)]) + + conflict_result = await self.db.execute( + select(func.count(CognitiveConflict.id)).where( + and_( + CognitiveConflict.truth_node_id.in_(charter_ids), + CognitiveConflict.tenant_id == self.tenant_id, + CognitiveConflict.status != "resolved", + ) + ) + ) + unresolved_conflicts = int(conflict_result.scalar() or 0) + return { + "score": round(score, 2), + "pending_nodes": pending_nodes, + "unresolved_conflicts": unresolved_conflicts, + } + + async def get_workload_health(self, assignee_ids: list[int]) -> dict[str, Any]: + member_profiles = await self._load_member_workload_profiles(assignee_ids) + if not member_profiles: + return { + "avg_ratio": 0.0, + "overloaded_count": 0, + "idle_count": 0, + "member_count": 0, + "members": [], + } + + ratios = [float(item["workload_ratio"]) for item in member_profiles] + overloaded_count = len([x for x in ratios if x > 1.0]) + idle_count = len([x for x in ratios if x < 0.3]) + return { + "avg_ratio": round(_avg(ratios), 2), + "overloaded_count": overloaded_count, + "idle_count": idle_count, + "member_count": len(member_profiles), + "members": member_profiles, + } + + @staticmethod + def _normalize_workload_ratio(raw_ratio: float | int | None) -> float: + ratio = float(raw_ratio or 0) + if ratio > 1.5: + ratio = ratio / 100.0 + return max(0.0, ratio) + + async def _load_member_workload_profiles(self, assignee_ids: list[int]) -> list[dict[str, Any]]: + if not assignee_ids: + return [] + + rows = await self.db.execute( + select(User.id, User.name, User.username, User.role, UserWorkload.workload_ratio) + .select_from(User) + .outerjoin( + UserWorkload, + and_( + UserWorkload.tenant_id == self.tenant_id, + UserWorkload.user_id == User.id, + ), + ) + .where( + and_( + User.tenant_id == self.tenant_id, + User.id.in_(assignee_ids), + User.is_active.is_(True), + ) + ) + .order_by(User.id.asc()) + ) + + members: list[dict[str, Any]] = [] + for user_id, name, username, role, workload_ratio in rows.all(): + ratio = self._normalize_workload_ratio(workload_ratio) + if ratio > 1.0: + status = "overloaded" + elif ratio < 0.3: + status = "idle" + else: + status = "normal" + members.append( + { + "user_id": str(int(user_id)), + "name": str(name or username or f"用户{int(user_id)}"), + "role": str(role or ""), + "workload_ratio": round(ratio, 2), + "status": status, + } + ) + return members + + @staticmethod + def _task_metrics(tasks: list[Task]) -> dict[str, Any]: + total = len(tasks) + done = len([t for t in tasks if (t.status or "").lower() in DONE_STATUSES]) + blocked = len([t for t in tasks if (t.status or "").lower() in BLOCKED_STATUSES]) + in_progress = len([t for t in tasks if (t.status or "").lower() in ACTIVE_STATUSES]) + today_done = len( + [ + t + for t in tasks + if (t.status or "").lower() in DONE_STATUSES + and t.completed_at + and t.completed_at.date() == datetime.now().date() + ] + ) + overdue = len( + [ + t + for t in tasks + if t.due_date and t.due_date < now_cn_aware().replace(tzinfo=None) and (t.status or "").lower() not in DONE_STATUSES + ] + ) + return { + "total": total, + "done": done, + "blocked": blocked, + "in_progress": in_progress, + "today_done": today_done, + "overdue": overdue, + "progress": round((done / total) * 100, 2) if total else 0, + } + diff --git a/backend/app/services/sstc_service_core/mixin_members.py b/backend/app/services/sstc_service_core/mixin_members.py new file mode 100644 index 00000000..d63fb3f2 --- /dev/null +++ b/backend/app/services/sstc_service_core/mixin_members.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from .shared_helpers import * + +class SSTCMembersMixin: + async def has_member_confirmed(self, charter_id: int, user_id: int) -> bool: + result = await self.db.execute( + select(func.count(AlignmentRecord.id)).where( + and_( + AlignmentRecord.tenant_id == self.tenant_id, + AlignmentRecord.truth_node_id == charter_id, + AlignmentRecord.user_id == user_id, + AlignmentRecord.is_confirmed.is_(True), + ) + ) + ) + return int(result.scalar() or 0) > 0 + + async def _load_project_member_id_set(self, project_id: int) -> set[int]: + member_result = await self.db.execute( + select(UserProject.user_id).where( + and_( + UserProject.project_id == project_id, + UserProject.tenant_id == self.tenant_id, + ) + ) + ) + return {int(x) for x in member_result.scalars().all() if x is not None} + + async def get_project_workload_member_ids(self, project_id: int) -> list[int]: + member_ids = await self._load_project_member_id_set(project_id) + project = await self.get_project(project_id) + if not member_ids and project: + member_ids = set( + await ensure_project_default_members( + self.db, + tenant_id=self.tenant_id, + project_id=int(project_id), + owner_id=int(project.owner_id) if project.owner_id else None, + assigned_role=project.assigned_role, + ) + ) + await self.db.commit() + return sorted(member_ids) + + async def get_project_member_ids(self, project_id: int) -> list[int]: + member_ids = set(await self.get_project_workload_member_ids(project_id)) + project = await self.get_project(project_id) + if project and project.owner_id: + member_ids.add(int(project.owner_id)) + return sorted(member_ids) + diff --git a/backend/app/services/sstc_service_core/service.py b/backend/app/services/sstc_service_core/service.py new file mode 100644 index 00000000..d3f57f64 --- /dev/null +++ b/backend/app/services/sstc_service_core/service.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from .mixin_build_views import SSTCBuildViewsMixin +from .mixin_fetch_core import SSTCFetchCoreMixin +from .mixin_members import SSTCMembersMixin + + +class SSTCService( + SSTCFetchCoreMixin, + SSTCBuildViewsMixin, + SSTCMembersMixin, +): + pass + + +__all__ = [ + "ALIGNED_STATUSES", + "ARCHIVED_STATUSES", + "BLOCKED_STATUSES", + "DONE_STATUSES", + "RELEASE_LIVE_STATUSES", + "RELEASE_RELEASING_STATUSES", + "RELEASE_VERIFIED_STATUSES", + "SSTCService", +] diff --git a/backend/app/services/sstc_service_core/shared.py b/backend/app/services/sstc_service_core/shared.py new file mode 100644 index 00000000..dd180a68 --- /dev/null +++ b/backend/app/services/sstc_service_core/shared.py @@ -0,0 +1,58 @@ +"""SSTC 看板聚合服务""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Iterable + +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...models import ( + AlignmentRecord, + CognitiveConflict, + Project, + ProjectSignalLink, + Signal, + Task, + TruthNode, + User, + UserProject, + UserWorkload, +) +from ...utils.project_members import ensure_project_default_members + +from ...utils.timezone import now_cn_aware +DONE_STATUSES = {"done", "completed"} +ACTIVE_STATUSES = {"in_progress", "review"} +BLOCKED_STATUSES = {"blocked"} +ALIGNED_STATUSES = {"aligned", "confirmed", "已对齐"} +ALIGNING_STATUSES = {"aligning", "alignment_in_progress", "对齐中"} +ARCHIVED_STATUSES = {"archived", "已归档"} +RELEASE_VERIFIED_STATUSES = {"verified", "已验证"} +RELEASE_LIVE_STATUSES = {"online", "deployed", "已上线"} +RELEASE_RELEASING_STATUSES = {"releasing", "发布中"} + + +def _to_json(value: Any, fallback: dict | None = None) -> dict: + if fallback is None: + fallback = {} + if isinstance(value, dict): + return value + if isinstance(value, str): + import json + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {"text": value} + except (TypeError, ValueError): + return {"text": value} + return fallback + + +def _avg(values: Iterable[float]) -> float: + values = list(values) + if not values: + return 0.0 + return float(sum(values) / len(values)) + + diff --git a/backend/app/services/sstc_service_core/shared_helpers.py b/backend/app/services/sstc_service_core/shared_helpers.py new file mode 100644 index 00000000..4803b670 --- /dev/null +++ b/backend/app/services/sstc_service_core/shared_helpers.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from .shared import * + +def _to_json(value: Any, fallback: dict | None = None) -> dict: + if fallback is None: + fallback = {} + if isinstance(value, dict): + return value + if isinstance(value, str): + import json + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {"text": value} + except (TypeError, ValueError): + return {"text": value} + return fallback + +def _avg(values: Iterable[float]) -> float: + values = list(values) + if not values: + return 0.0 + return float(sum(values) / len(values)) + diff --git a/backend/app/services/symbol_index/__init__.py b/backend/app/services/symbol_index/__init__.py new file mode 100644 index 00000000..edfb06ee --- /dev/null +++ b/backend/app/services/symbol_index/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .service import ParsedReference, ParsedSymbol, SymbolIndexService + +__all__ = ["ParsedReference", "ParsedSymbol", "SymbolIndexService"] diff --git a/backend/app/services/symbol_index/mixin_parse_python.py b/backend/app/services/symbol_index/mixin_parse_python.py new file mode 100644 index 00000000..d38cdd91 --- /dev/null +++ b/backend/app/services/symbol_index/mixin_parse_python.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from .shared import * + +class SymbolIndexParsePythonMixin: + def _parse_file( + self, + *, + file_path: str, + language: str, + content: str, + ) -> tuple[list[ParsedSymbol], list[ParsedReference]]: + if language == "python": + return self._parse_python_file(file_path=file_path, content=content) + return self._parse_typescript_file(file_path=file_path, content=content) + + def _parse_python_file(self, *, file_path: str, content: str) -> tuple[list[ParsedSymbol], list[ParsedReference]]: + text = str(content or "") + if not text.strip(): + return ([], []) + try: + tree = ast.parse(text) + except Exception: + return ([], []) + symbols: list[ParsedSymbol] = [] + refs: list[ParsedReference] = [] + + class Visitor(ast.NodeVisitor): + def __init__(self): + self.class_stack: list[str] = [] + self.func_stack: list[str] = [] + + def _current_source(self) -> str: + if self.func_stack: + return self.func_stack[-1] + if self.class_stack: + return self.class_stack[-1] + return "" + + def _qualify(self, name: str) -> str: + chain = [*self.class_stack] + if self.func_stack: + chain.extend(self.func_stack[-1].split(".")[len(self.class_stack):]) + chain.append(name) + uniq: list[str] = [] + for item in chain: + if item and (not uniq or uniq[-1] != item): + uniq.append(item) + return ".".join(uniq) if uniq else name + + def visit_ClassDef(self, node: ast.ClassDef) -> Any: + qualified = ".".join([*self.class_stack, node.name]) if self.class_stack else node.name + symbols.append( + ParsedSymbol( + symbol_type="class", + symbol_name=node.name, + qualified_name=qualified, + start_line=int(getattr(node, "lineno", 0) or 0), + end_line=int(getattr(node, "end_lineno", getattr(node, "lineno", 0)) or 0), + signature=f"class {node.name}", + docstring=str(ast.get_docstring(node) or ""), + payload={"file_path": file_path}, + ) + ) + self.class_stack.append(node.name) + self.generic_visit(node) + self.class_stack.pop() + + def visit_FunctionDef(self, node: ast.FunctionDef) -> Any: + self._visit_function_node(node=node, is_async=False) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> Any: + self._visit_function_node(node=node, is_async=True) + + def _visit_function_node(self, *, node: Any, is_async: bool) -> None: + if self.class_stack: + qualified = ".".join([*self.class_stack, node.name]) + symbol_type = "method" + else: + qualified = node.name + symbol_type = "function" + arg_names = [] + for arg in list(node.args.posonlyargs) + list(node.args.args): + arg_names.append(str(arg.arg)) + if node.args.vararg is not None: + arg_names.append(f"*{node.args.vararg.arg}") + for arg in node.args.kwonlyargs: + arg_names.append(str(arg.arg)) + if node.args.kwarg is not None: + arg_names.append(f"**{node.args.kwarg.arg}") + prefix = "async def" if is_async else "def" + signature = f"{prefix} {node.name}({', '.join(arg_names)})" + symbols.append( + ParsedSymbol( + symbol_type=symbol_type, + symbol_name=node.name, + qualified_name=qualified, + start_line=int(getattr(node, "lineno", 0) or 0), + end_line=int(getattr(node, "end_lineno", getattr(node, "lineno", 0)) or 0), + signature=signature[:1000], + docstring=str(ast.get_docstring(node) or ""), + payload={"file_path": file_path}, + ) + ) + self.func_stack.append(qualified) + self.generic_visit(node) + self.func_stack.pop() + + def visit_Import(self, node: ast.Import) -> Any: + source = self._current_source() + for alias in node.names: + target = str(alias.name or "").strip() + if not target: + continue + refs.append( + ParsedReference( + relation_type="imports", + target_symbol_name=target.split(".")[-1][:255], + target_qualified_name=target[:700], + source_qualified_name=source[:700], + start_line=int(getattr(node, "lineno", 0) or 0), + end_line=int(getattr(node, "end_lineno", getattr(node, "lineno", 0)) or 0), + payload={"alias": str(alias.asname or "")[:255] if alias.asname else None}, + ) + ) + self.generic_visit(node) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> Any: + source = self._current_source() + module = str(node.module or "").strip() + for alias in node.names: + name = str(alias.name or "").strip() + if not name: + continue + target_qn = f"{module}.{name}" if module else name + refs.append( + ParsedReference( + relation_type="imports", + target_symbol_name=name[:255], + target_qualified_name=target_qn[:700], + source_qualified_name=source[:700], + start_line=int(getattr(node, "lineno", 0) or 0), + end_line=int(getattr(node, "end_lineno", getattr(node, "lineno", 0)) or 0), + payload={"module": module[:255] if module else None}, + ) + ) + self.generic_visit(node) + + def visit_Call(self, node: ast.Call) -> Any: + source = self._current_source() + target = "" + if isinstance(node.func, ast.Name): + target = str(node.func.id or "").strip() + elif isinstance(node.func, ast.Attribute): + target = str(node.func.attr or "").strip() + if target: + refs.append( + ParsedReference( + relation_type="calls", + target_symbol_name=target[:255], + target_qualified_name=target[:700], + source_qualified_name=source[:700], + start_line=int(getattr(node, "lineno", 0) or 0), + end_line=int(getattr(node, "end_lineno", getattr(node, "lineno", 0)) or 0), + payload={}, + ) + ) + self.generic_visit(node) + + Visitor().visit(tree) + return (symbols[:5000], refs[:12000]) + + @staticmethod + def _detect_language(path: Path) -> str: + ext = path.suffix.lower() + if ext == ".py": + return "python" + if ext in {".ts", ".tsx", ".js", ".jsx"}: + return "typescript" + return "" + diff --git a/backend/app/services/symbol_index/mixin_parse_typescript.py b/backend/app/services/symbol_index/mixin_parse_typescript.py new file mode 100644 index 00000000..2d53de43 --- /dev/null +++ b/backend/app/services/symbol_index/mixin_parse_typescript.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from .shared import * + +class SymbolIndexParseTypeScriptMixin: + def _parse_typescript_file(self, *, file_path: str, content: str) -> tuple[list[ParsedSymbol], list[ParsedReference]]: + text = str(content or "") + if not text.strip(): + return ([], []) + symbols: list[ParsedSymbol] = [] + refs: list[ParsedReference] = [] + lines = text.splitlines() + line_symbol_map: dict[int, str] = {} + + for idx, line in enumerate(lines, start=1): + match = re.match( + r"^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(function|class|interface|type|const)\s+([A-Za-z_][A-Za-z0-9_]*)", + line, + ) + if not match: + continue + symbol_type = str(match.group(1) or "").strip().lower() + name = str(match.group(2) or "").strip() + if not name: + continue + signature = line.strip() + symbols.append( + ParsedSymbol( + symbol_type=symbol_type, + symbol_name=name, + qualified_name=name, + start_line=idx, + end_line=idx, + signature=signature[:1000], + docstring="", + payload={"file_path": file_path}, + ) + ) + line_symbol_map[idx] = name + + if "frontend/src/router/" in str(file_path).replace("\\", "/"): + for idx, line in enumerate(lines, start=1): + m = re.search( + r'path:\s*"([^"]+)"\s*,\s*element:\s*<([A-Za-z_][A-Za-z0-9_]*)', + line, + ) + if not m: + continue + route_path = str(m.group(1) or "").strip() + component = str(m.group(2) or "").strip() + if not route_path: + continue + clean_route = route_path if route_path.startswith("/") else f"/{route_path}" + symbol_name = f"route:{clean_route}" + symbols.append( + ParsedSymbol( + symbol_type="route", + symbol_name=symbol_name[:255], + qualified_name=symbol_name[:700], + start_line=idx, + end_line=idx, + signature=line.strip()[:1000], + docstring=f"component:{component}", + payload={"file_path": file_path, "route_path": clean_route, "component": component}, + ) + ) + + if str(file_path).replace("\\", "/").endswith("components/layout/Sidebar.tsx"): + for idx, line in enumerate(lines, start=1): + m = re.search(r'to:\s*"([^"]+)"\s*,\s*label:\s*"([^"]+)"', line) + if not m: + continue + route_path = str(m.group(1) or "").strip() + label = str(m.group(2) or "").strip() + if not route_path or not label: + continue + clean_route = route_path if route_path.startswith("/") else f"/{route_path}" + symbol_name = f"feature:{label}" + symbols.append( + ParsedSymbol( + symbol_type="feature", + symbol_name=symbol_name[:255], + qualified_name=f"{label}@{clean_route}"[:700], + start_line=idx, + end_line=idx, + signature=line.strip()[:1000], + docstring=f"menu:{clean_route}", + payload={"file_path": file_path, "feature_label": label, "route_path": clean_route}, + ) + ) + + imports = TS_IMPORT_RE.finditer(text) + for match in imports: + module = str(match.group(1) or "").strip() + if not module: + continue + start = text[: match.start()].count("\n") + 1 + refs.append( + ParsedReference( + relation_type="imports", + target_symbol_name=module.split("/")[-1][:255], + target_qualified_name=module[:700], + source_qualified_name="", + start_line=start, + end_line=start, + payload={}, + ) + ) + + for idx, line in enumerate(lines, start=1): + source_symbol = "" + candidate_lines = [x for x in line_symbol_map.keys() if x <= idx] + if candidate_lines: + source_symbol = line_symbol_map[max(candidate_lines)] + for call in TS_CALL_RE.finditer(line): + name = str(call.group(1) or "").strip() + if not name or name.lower() in TS_KEYWORDS: + continue + refs.append( + ParsedReference( + relation_type="calls", + target_symbol_name=name[:255], + target_qualified_name=name[:700], + source_qualified_name=source_symbol[:700], + start_line=idx, + end_line=idx, + payload={}, + ) + ) + return (symbols[:5000], refs[:12000]) + diff --git a/backend/app/services/symbol_index/mixin_query_overview.py b/backend/app/services/symbol_index/mixin_query_overview.py new file mode 100644 index 00000000..d4bccc0f --- /dev/null +++ b/backend/app/services/symbol_index/mixin_query_overview.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +from .shared import * + +class SymbolIndexQueryOverviewMixin: + async def search_symbols( + self, + *, + query: str, + language: Optional[str] = None, + symbol_type: Optional[str] = None, + limit: int = 30, + ) -> dict[str, Any]: + text = str(query or "").strip() + if not text: + return {"count": 0, "items": []} + + like = f"%{text}%" + stmt = select(SymbolDefinition).where( + and_( + SymbolDefinition.tenant_id == self.tenant_id, + SymbolDefinition.status == "active", + or_( + SymbolDefinition.symbol_name.ilike(like), + SymbolDefinition.qualified_name.ilike(like), + SymbolDefinition.file_path.ilike(like), + ), + ) + ) + if language: + stmt = stmt.where(SymbolDefinition.language == str(language).strip().lower()[:20]) + if symbol_type: + stmt = stmt.where(SymbolDefinition.symbol_type == str(symbol_type).strip().lower()[:32]) + rows = ( + await self.db.execute( + stmt.order_by(SymbolDefinition.updated_at.desc(), SymbolDefinition.id.desc()).limit(max(1, min(int(limit), 200))) + ) + ).scalars().all() + return { + "count": len(rows), + "items": [self._serialize_symbol(row) for row in rows], + } + + async def get_symbol_detail(self, *, symbol_id: int) -> dict[str, Any]: + symbol = ( + await self.db.execute( + select(SymbolDefinition).where( + and_( + SymbolDefinition.tenant_id == self.tenant_id, + SymbolDefinition.id == int(symbol_id), + ) + ) + ) + ).scalar_one_or_none() + if symbol is None: + raise ValueError("symbol_not_found") + + refs = ( + await self.db.execute( + select(SymbolReference).where( + and_( + SymbolReference.tenant_id == self.tenant_id, + SymbolReference.source_symbol_id == int(symbol.id), + ) + ) + .order_by(SymbolReference.id.desc()) + .limit(120) + ) + ).scalars().all() + + inbound_refs = ( + await self.db.execute( + select(SymbolReference) + .where( + and_( + SymbolReference.tenant_id == self.tenant_id, + or_( + SymbolReference.target_symbol_name == str(symbol.symbol_name), + SymbolReference.target_qualified_name == str(symbol.qualified_name or ""), + ), + ) + ) + .order_by(SymbolReference.id.desc()) + .limit(120) + ) + ).scalars().all() + + return { + "symbol": self._serialize_symbol(symbol), + "outbound_refs": [self._serialize_ref(row) for row in refs], + "inbound_refs": [self._serialize_ref(row) for row in inbound_refs], + } + + async def get_overview( + self, + *, + top_n_files: int = 10, + auto_sync_if_empty: bool = False, + base_dir: Optional[str] = None, + ) -> dict[str, Any]: + total = int( + ( + await self.db.execute( + select(func.count(SymbolDefinition.id)).where(SymbolDefinition.tenant_id == self.tenant_id) + ) + ).scalar_one() + or 0 + ) + if total == 0 and auto_sync_if_empty: + await self.sync_codebase_symbols(base_dir=base_dir, max_files=300, force_reindex=False) + total = int( + ( + await self.db.execute( + select(func.count(SymbolDefinition.id)).where(SymbolDefinition.tenant_id == self.tenant_id) + ) + ).scalar_one() + or 0 + ) + + language_groups = ( + await self.db.execute( + select(SymbolDefinition.language, func.count(SymbolDefinition.id)) + .where(SymbolDefinition.tenant_id == self.tenant_id) + .group_by(SymbolDefinition.language) + ) + ).all() + type_groups = ( + await self.db.execute( + select(SymbolDefinition.symbol_type, func.count(SymbolDefinition.id)) + .where(SymbolDefinition.tenant_id == self.tenant_id) + .group_by(SymbolDefinition.symbol_type) + ) + ).all() + file_groups = ( + await self.db.execute( + select(SymbolDefinition.file_path, func.count(SymbolDefinition.id)) + .where(SymbolDefinition.tenant_id == self.tenant_id) + .group_by(SymbolDefinition.file_path) + .order_by(func.count(SymbolDefinition.id).desc()) + .limit(max(1, min(int(top_n_files), 50))) + ) + ).all() + ref_total = int( + ( + await self.db.execute( + select(func.count(SymbolReference.id)).where(SymbolReference.tenant_id == self.tenant_id) + ) + ).scalar_one() + or 0 + ) + indexed_files_total = int( + ( + await self.db.execute( + select(func.count(func.distinct(SymbolDefinition.file_path))).where( + SymbolDefinition.tenant_id == self.tenant_id + ) + ) + ).scalar_one() + or 0 + ) + return { + "tenant_id": self.tenant_id, + "indexed_files": indexed_files_total, + "symbol_total": total, + "reference_total": ref_total, + "python_symbols": int(dict(language_groups).get("python", 0) or 0), + "typescript_symbols": int(dict(language_groups).get("typescript", 0) or 0), + "symbols_by_language": {str(k): int(v or 0) for k, v in language_groups}, + "symbols_by_type": {str(k): int(v or 0) for k, v in type_groups}, + "top_modules": [ + {"module": str(path), "symbols": int(cnt or 0)} + for path, cnt in file_groups + ], + "snapshot_at": now_cn_aware().isoformat(), + } + diff --git a/backend/app/services/symbol_index/mixin_serialize.py b/backend/app/services/symbol_index/mixin_serialize.py new file mode 100644 index 00000000..0a465b5b --- /dev/null +++ b/backend/app/services/symbol_index/mixin_serialize.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from .shared import * + +class SymbolIndexSerializeMixin: + @staticmethod + def _serialize_symbol(row: SymbolDefinition) -> dict[str, Any]: + return { + "id": int(row.id), + "file_path": str(row.file_path or ""), + "language": str(row.language or ""), + "symbol_type": str(row.symbol_type or ""), + "symbol_name": str(row.symbol_name or ""), + "qualified_name": str(row.qualified_name or ""), + "start_line": int(row.start_line) if row.start_line is not None else None, + "end_line": int(row.end_line) if row.end_line is not None else None, + "signature": str(row.signature or ""), + "docstring": str(row.docstring or ""), + "status": str(row.status or ""), + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + "last_indexed_at": row.last_indexed_at.isoformat() if row.last_indexed_at else None, + } + + @staticmethod + def _serialize_ref(row: SymbolReference) -> dict[str, Any]: + return { + "id": int(row.id), + "source_symbol_id": int(row.source_symbol_id) if row.source_symbol_id is not None else None, + "file_path": str(row.file_path or ""), + "relation_type": str(row.relation_type or ""), + "target_symbol_name": str(row.target_symbol_name or ""), + "target_qualified_name": str(row.target_qualified_name or ""), + "start_line": int(row.start_line) if row.start_line is not None else None, + "end_line": int(row.end_line) if row.end_line is not None else None, + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + } + diff --git a/backend/app/services/symbol_index/mixin_sync_core.py b/backend/app/services/symbol_index/mixin_sync_core.py new file mode 100644 index 00000000..660427bf --- /dev/null +++ b/backend/app/services/symbol_index/mixin_sync_core.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +from .shared import * + +class SymbolIndexSyncCoreMixin: + def __init__(self, db: AsyncSession, *, tenant_id: int) -> None: + self.db = db + self.tenant_id = int(tenant_id) + + @staticmethod + def _default_scan_roots(*, backend_root: Path, workspace_root: Path) -> list[Path]: + roots: list[Path] = [] + for candidate in [ + (backend_root / "app").resolve(), + (workspace_root / "frontend" / "src").resolve(), + ]: + if candidate.exists() and candidate.is_dir(): + roots.append(candidate) + return roots + + @staticmethod + def _to_rel_path(*, file_path: Path, workspace_root: Path, backend_root: Path) -> str: + try: + return str(file_path.resolve().relative_to(workspace_root)) + except Exception: + try: + return str(file_path.resolve().relative_to(backend_root)) + except Exception: + return str(file_path.resolve()) + + async def sync_codebase_symbols( + self, + *, + base_dir: Optional[str] = None, + max_files: int = 500, + force_reindex: bool = False, + ) -> dict[str, Any]: + backend_root = Path(__file__).resolve().parents[2] + workspace_root = backend_root.parent + if base_dir: + root = Path(base_dir).resolve() + if not root.exists() or not root.is_dir(): + raise ValueError(f"symbol scan path not found: {root}") + scan_roots = [root] + else: + scan_roots = self._default_scan_roots(backend_root=backend_root, workspace_root=workspace_root) + if not scan_roots: + raise ValueError("symbol scan path not found: backend/app and frontend/src") + + allowed = {".py", ".ts", ".tsx", ".js", ".jsx"} + files: list[Path] = [] + for root in scan_roots: + files.extend([p for p in root.rglob("*") if p.is_file() and p.suffix.lower() in allowed]) + files.sort(key=lambda x: str(x)) + selected = files[: max(1, int(max_files))] + + stats = { + "root": str(scan_roots[0]), + "roots": [str(x) for x in scan_roots], + "candidate_files": len(files), + "selected_files": len(selected), + "changed_files": 0, + "skipped_unchanged_files": 0, + "indexed_symbols": 0, + "indexed_references": 0, + "failed_files": 0, + "errors": [], + } + + for file_path in selected: + language = self._detect_language(file_path) + if not language: + continue + try: + text = file_path.read_text(encoding="utf-8", errors="ignore") + rel_path = self._to_rel_path(file_path=file_path, workspace_root=workspace_root, backend_root=backend_root) + file_hash = hashlib.sha1(text.encode("utf-8")).hexdigest() + mtime = datetime.fromtimestamp(file_path.stat().st_mtime, tz=CN_TZ).replace(tzinfo=None) + if not force_reindex: + latest_hash = await self._get_latest_file_hash(rel_path=rel_path) + if latest_hash and latest_hash == file_hash: + stats["skipped_unchanged_files"] += 1 + continue + + symbols, refs = self._parse_file( + file_path=rel_path, + language=language, + content=text, + ) + s_count, r_count = await self._replace_file_index( + file_path=rel_path, + language=language, + file_hash=file_hash, + source_mtime=mtime, + symbols=symbols, + refs=refs, + ) + stats["changed_files"] += 1 + stats["indexed_symbols"] += s_count + stats["indexed_references"] += r_count + except Exception as exc: + stats["failed_files"] += 1 + stats["errors"].append(f"{str(file_path)}: {str(exc)[:180]}") + + await self.db.commit() + stats["errors"] = stats["errors"][:20] + stats["finished_at"] = now_cn_aware().isoformat() + return stats + + async def _get_latest_file_hash(self, *, rel_path: str) -> Optional[str]: + row = ( + await self.db.execute( + select(SymbolDefinition.file_hash).where( + and_( + SymbolDefinition.tenant_id == self.tenant_id, + SymbolDefinition.file_path == rel_path, + ) + ) + .order_by(SymbolDefinition.updated_at.desc(), SymbolDefinition.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + return str(row).strip() if row else None + + async def _replace_file_index( + self, + *, + file_path: str, + language: str, + file_hash: str, + source_mtime: datetime, + symbols: list[ParsedSymbol], + refs: list[ParsedReference], + ) -> tuple[int, int]: + await self.db.execute( + delete(SymbolReference).where( + and_( + SymbolReference.tenant_id == self.tenant_id, + SymbolReference.file_path == file_path, + ) + ) + ) + await self.db.execute( + delete(SymbolDefinition).where( + and_( + SymbolDefinition.tenant_id == self.tenant_id, + SymbolDefinition.file_path == file_path, + ) + ) + ) + + created_symbols: list[SymbolDefinition] = [] + qn_to_id: dict[str, int] = {} + name_to_id: dict[str, int] = {} + for item in symbols: + row = SymbolDefinition( + tenant_id=self.tenant_id, + file_path=file_path, + language=language, + symbol_type=item.symbol_type[:32], + symbol_name=item.symbol_name[:255], + qualified_name=item.qualified_name[:700] if item.qualified_name else None, + start_line=int(item.start_line) if item.start_line > 0 else None, + end_line=int(item.end_line) if item.end_line > 0 else None, + signature=item.signature[:1000] if item.signature else None, + docstring=item.docstring[:2000] if item.docstring else None, + file_hash=file_hash, + source_mtime=source_mtime, + status="active", + payload=item.payload, + last_indexed_at=now_cn_naive(), + ) + self.db.add(row) + created_symbols.append(row) + await self.db.flush() + for row in created_symbols: + if row.qualified_name: + qn_to_id[str(row.qualified_name)] = int(row.id) + name_to_id[str(row.symbol_name)] = int(row.id) + + ref_count = 0 + # DB 层有 (tenant_id,file_path,relation_type,target_symbol_name,start_line,source_symbol_id) 唯一约束, + # 这里先去重,避免同一行重复调用(例如链式调用)触发批量写入冲突。 + ref_seen_keys: set[tuple[str, str, Optional[int], Optional[int]]] = set() + for ref in refs: + source_id = None + source_qn = str(ref.source_qualified_name or "").strip() + if source_qn: + source_id = qn_to_id.get(source_qn) + if source_id is None: + source_tail = source_qn.split(".")[-1] + source_id = name_to_id.get(source_tail) + relation_type = ref.relation_type[:32] + target_symbol_name = ref.target_symbol_name[:255] + start_line = int(ref.start_line) if ref.start_line > 0 else None + dedupe_key = ( + relation_type, + target_symbol_name, + start_line, + int(source_id) if source_id is not None else None, + ) + if dedupe_key in ref_seen_keys: + continue + ref_seen_keys.add(dedupe_key) + row = SymbolReference( + tenant_id=self.tenant_id, + source_symbol_id=int(source_id) if source_id is not None else None, + file_path=file_path, + relation_type=relation_type, + target_symbol_name=target_symbol_name, + target_qualified_name=ref.target_qualified_name[:700] if ref.target_qualified_name else None, + start_line=start_line, + end_line=int(ref.end_line) if ref.end_line > 0 else None, + payload=ref.payload, + ) + self.db.add(row) + ref_count += 1 + await self.db.flush() + return (len(created_symbols), ref_count) + diff --git a/backend/app/services/symbol_index/service.py b/backend/app/services/symbol_index/service.py new file mode 100644 index 00000000..ddb19b03 --- /dev/null +++ b/backend/app/services/symbol_index/service.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from .mixin_parse_python import SymbolIndexParsePythonMixin +from .mixin_parse_typescript import SymbolIndexParseTypeScriptMixin +from .mixin_query_overview import SymbolIndexQueryOverviewMixin +from .mixin_serialize import SymbolIndexSerializeMixin +from .mixin_sync_core import SymbolIndexSyncCoreMixin +from .shared import ParsedReference, ParsedSymbol + + +class SymbolIndexService( + SymbolIndexSyncCoreMixin, + SymbolIndexQueryOverviewMixin, + SymbolIndexParsePythonMixin, + SymbolIndexParseTypeScriptMixin, + SymbolIndexSerializeMixin, +): + pass + + +__all__ = ["ParsedReference", "ParsedSymbol", "SymbolIndexService"] diff --git a/backend/app/services/symbol_index/shared.py b/backend/app/services/symbol_index/shared.py new file mode 100644 index 00000000..fbbc6b46 --- /dev/null +++ b/backend/app/services/symbol_index/shared.py @@ -0,0 +1,57 @@ +"""Symbol 索引服务。 + +目标: +1. 对代码库执行增量符号扫描(def/ref) +2. 提供符号查询、详情、概览 +3. 为 GraphRAG / Agent 检索提供结构化工具层 +""" + +from __future__ import annotations + +import ast +import hashlib +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +from sqlalchemy import and_, delete, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...models import SymbolDefinition, SymbolReference + + +from ...utils.timezone import CN_TZ, now_cn_aware, now_cn_naive +TS_SYMBOL_RE = re.compile( + r"^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|const)\s+([A-Za-z_][A-Za-z0-9_]*)", + re.MULTILINE, +) +TS_IMPORT_RE = re.compile(r"^\s*import\s+.*?\s+from\s+['\"]([^'\"]+)['\"]", re.MULTILINE) +TS_CALL_RE = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(") +TS_KEYWORDS = {"if", "for", "while", "switch", "catch", "return", "new", "typeof", "await"} + + +@dataclass(slots=True) +class ParsedSymbol: + symbol_type: str + symbol_name: str + qualified_name: str + start_line: int + end_line: int + signature: str + docstring: str + payload: dict[str, Any] + + +@dataclass(slots=True) +class ParsedReference: + relation_type: str + target_symbol_name: str + target_qualified_name: str + source_qualified_name: str + start_line: int + end_line: int + payload: dict[str, Any] + + diff --git a/backend/app/services/symbol_index_service.py b/backend/app/services/symbol_index_service.py index f133457c..54bb2f88 100644 --- a/backend/app/services/symbol_index_service.py +++ b/backend/app/services/symbol_index_service.py @@ -1,777 +1,5 @@ -"""Symbol 索引服务。 - -目标: -1. 对代码库执行增量符号扫描(def/ref) -2. 提供符号查询、详情、概览 -3. 为 GraphRAG / Agent 检索提供结构化工具层 -""" - from __future__ import annotations -import ast -import hashlib -import re -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Optional +from .symbol_index import ParsedReference, ParsedSymbol, SymbolIndexService -from sqlalchemy import and_, delete, func, or_, select -from sqlalchemy.ext.asyncio import AsyncSession - -from ..models import SymbolDefinition, SymbolReference - - -from ..utils.timezone import CN_TZ, now_cn_aware, now_cn_naive -TS_SYMBOL_RE = re.compile( - r"^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|const)\s+([A-Za-z_][A-Za-z0-9_]*)", - re.MULTILINE, -) -TS_IMPORT_RE = re.compile(r"^\s*import\s+.*?\s+from\s+['\"]([^'\"]+)['\"]", re.MULTILINE) -TS_CALL_RE = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(") -TS_KEYWORDS = {"if", "for", "while", "switch", "catch", "return", "new", "typeof", "await"} - - -@dataclass(slots=True) -class ParsedSymbol: - symbol_type: str - symbol_name: str - qualified_name: str - start_line: int - end_line: int - signature: str - docstring: str - payload: dict[str, Any] - - -@dataclass(slots=True) -class ParsedReference: - relation_type: str - target_symbol_name: str - target_qualified_name: str - source_qualified_name: str - start_line: int - end_line: int - payload: dict[str, Any] - - -class SymbolIndexService: - def __init__(self, db: AsyncSession, *, tenant_id: int) -> None: - self.db = db - self.tenant_id = int(tenant_id) - - @staticmethod - def _default_scan_roots(*, backend_root: Path, workspace_root: Path) -> list[Path]: - roots: list[Path] = [] - for candidate in [ - (backend_root / "app").resolve(), - (workspace_root / "frontend" / "src").resolve(), - ]: - if candidate.exists() and candidate.is_dir(): - roots.append(candidate) - return roots - - @staticmethod - def _to_rel_path(*, file_path: Path, workspace_root: Path, backend_root: Path) -> str: - try: - return str(file_path.resolve().relative_to(workspace_root)) - except Exception: - try: - return str(file_path.resolve().relative_to(backend_root)) - except Exception: - return str(file_path.resolve()) - - async def sync_codebase_symbols( - self, - *, - base_dir: Optional[str] = None, - max_files: int = 500, - force_reindex: bool = False, - ) -> dict[str, Any]: - backend_root = Path(__file__).resolve().parents[2] - workspace_root = backend_root.parent - if base_dir: - root = Path(base_dir).resolve() - if not root.exists() or not root.is_dir(): - raise ValueError(f"symbol scan path not found: {root}") - scan_roots = [root] - else: - scan_roots = self._default_scan_roots(backend_root=backend_root, workspace_root=workspace_root) - if not scan_roots: - raise ValueError("symbol scan path not found: backend/app and frontend/src") - - allowed = {".py", ".ts", ".tsx", ".js", ".jsx"} - files: list[Path] = [] - for root in scan_roots: - files.extend([p for p in root.rglob("*") if p.is_file() and p.suffix.lower() in allowed]) - files.sort(key=lambda x: str(x)) - selected = files[: max(1, int(max_files))] - - stats = { - "root": str(scan_roots[0]), - "roots": [str(x) for x in scan_roots], - "candidate_files": len(files), - "selected_files": len(selected), - "changed_files": 0, - "skipped_unchanged_files": 0, - "indexed_symbols": 0, - "indexed_references": 0, - "failed_files": 0, - "errors": [], - } - - for file_path in selected: - language = self._detect_language(file_path) - if not language: - continue - try: - text = file_path.read_text(encoding="utf-8", errors="ignore") - rel_path = self._to_rel_path(file_path=file_path, workspace_root=workspace_root, backend_root=backend_root) - file_hash = hashlib.sha1(text.encode("utf-8")).hexdigest() - mtime = datetime.fromtimestamp(file_path.stat().st_mtime, tz=CN_TZ).replace(tzinfo=None) - if not force_reindex: - latest_hash = await self._get_latest_file_hash(rel_path=rel_path) - if latest_hash and latest_hash == file_hash: - stats["skipped_unchanged_files"] += 1 - continue - - symbols, refs = self._parse_file( - file_path=rel_path, - language=language, - content=text, - ) - s_count, r_count = await self._replace_file_index( - file_path=rel_path, - language=language, - file_hash=file_hash, - source_mtime=mtime, - symbols=symbols, - refs=refs, - ) - stats["changed_files"] += 1 - stats["indexed_symbols"] += s_count - stats["indexed_references"] += r_count - except Exception as exc: - stats["failed_files"] += 1 - stats["errors"].append(f"{str(file_path)}: {str(exc)[:180]}") - - await self.db.commit() - stats["errors"] = stats["errors"][:20] - stats["finished_at"] = now_cn_aware().isoformat() - return stats - - async def search_symbols( - self, - *, - query: str, - language: Optional[str] = None, - symbol_type: Optional[str] = None, - limit: int = 30, - ) -> dict[str, Any]: - text = str(query or "").strip() - if not text: - return {"count": 0, "items": []} - - like = f"%{text}%" - stmt = select(SymbolDefinition).where( - and_( - SymbolDefinition.tenant_id == self.tenant_id, - SymbolDefinition.status == "active", - or_( - SymbolDefinition.symbol_name.ilike(like), - SymbolDefinition.qualified_name.ilike(like), - SymbolDefinition.file_path.ilike(like), - ), - ) - ) - if language: - stmt = stmt.where(SymbolDefinition.language == str(language).strip().lower()[:20]) - if symbol_type: - stmt = stmt.where(SymbolDefinition.symbol_type == str(symbol_type).strip().lower()[:32]) - rows = ( - await self.db.execute( - stmt.order_by(SymbolDefinition.updated_at.desc(), SymbolDefinition.id.desc()).limit(max(1, min(int(limit), 200))) - ) - ).scalars().all() - return { - "count": len(rows), - "items": [self._serialize_symbol(row) for row in rows], - } - - async def get_symbol_detail(self, *, symbol_id: int) -> dict[str, Any]: - symbol = ( - await self.db.execute( - select(SymbolDefinition).where( - and_( - SymbolDefinition.tenant_id == self.tenant_id, - SymbolDefinition.id == int(symbol_id), - ) - ) - ) - ).scalar_one_or_none() - if symbol is None: - raise ValueError("symbol_not_found") - - refs = ( - await self.db.execute( - select(SymbolReference).where( - and_( - SymbolReference.tenant_id == self.tenant_id, - SymbolReference.source_symbol_id == int(symbol.id), - ) - ) - .order_by(SymbolReference.id.desc()) - .limit(120) - ) - ).scalars().all() - - inbound_refs = ( - await self.db.execute( - select(SymbolReference) - .where( - and_( - SymbolReference.tenant_id == self.tenant_id, - or_( - SymbolReference.target_symbol_name == str(symbol.symbol_name), - SymbolReference.target_qualified_name == str(symbol.qualified_name or ""), - ), - ) - ) - .order_by(SymbolReference.id.desc()) - .limit(120) - ) - ).scalars().all() - - return { - "symbol": self._serialize_symbol(symbol), - "outbound_refs": [self._serialize_ref(row) for row in refs], - "inbound_refs": [self._serialize_ref(row) for row in inbound_refs], - } - - async def get_overview( - self, - *, - top_n_files: int = 10, - auto_sync_if_empty: bool = False, - base_dir: Optional[str] = None, - ) -> dict[str, Any]: - total = int( - ( - await self.db.execute( - select(func.count(SymbolDefinition.id)).where(SymbolDefinition.tenant_id == self.tenant_id) - ) - ).scalar_one() - or 0 - ) - if total == 0 and auto_sync_if_empty: - await self.sync_codebase_symbols(base_dir=base_dir, max_files=300, force_reindex=False) - total = int( - ( - await self.db.execute( - select(func.count(SymbolDefinition.id)).where(SymbolDefinition.tenant_id == self.tenant_id) - ) - ).scalar_one() - or 0 - ) - - language_groups = ( - await self.db.execute( - select(SymbolDefinition.language, func.count(SymbolDefinition.id)) - .where(SymbolDefinition.tenant_id == self.tenant_id) - .group_by(SymbolDefinition.language) - ) - ).all() - type_groups = ( - await self.db.execute( - select(SymbolDefinition.symbol_type, func.count(SymbolDefinition.id)) - .where(SymbolDefinition.tenant_id == self.tenant_id) - .group_by(SymbolDefinition.symbol_type) - ) - ).all() - file_groups = ( - await self.db.execute( - select(SymbolDefinition.file_path, func.count(SymbolDefinition.id)) - .where(SymbolDefinition.tenant_id == self.tenant_id) - .group_by(SymbolDefinition.file_path) - .order_by(func.count(SymbolDefinition.id).desc()) - .limit(max(1, min(int(top_n_files), 50))) - ) - ).all() - ref_total = int( - ( - await self.db.execute( - select(func.count(SymbolReference.id)).where(SymbolReference.tenant_id == self.tenant_id) - ) - ).scalar_one() - or 0 - ) - indexed_files_total = int( - ( - await self.db.execute( - select(func.count(func.distinct(SymbolDefinition.file_path))).where( - SymbolDefinition.tenant_id == self.tenant_id - ) - ) - ).scalar_one() - or 0 - ) - return { - "tenant_id": self.tenant_id, - "indexed_files": indexed_files_total, - "symbol_total": total, - "reference_total": ref_total, - "python_symbols": int(dict(language_groups).get("python", 0) or 0), - "typescript_symbols": int(dict(language_groups).get("typescript", 0) or 0), - "symbols_by_language": {str(k): int(v or 0) for k, v in language_groups}, - "symbols_by_type": {str(k): int(v or 0) for k, v in type_groups}, - "top_modules": [ - {"module": str(path), "symbols": int(cnt or 0)} - for path, cnt in file_groups - ], - "snapshot_at": now_cn_aware().isoformat(), - } - - async def _get_latest_file_hash(self, *, rel_path: str) -> Optional[str]: - row = ( - await self.db.execute( - select(SymbolDefinition.file_hash).where( - and_( - SymbolDefinition.tenant_id == self.tenant_id, - SymbolDefinition.file_path == rel_path, - ) - ) - .order_by(SymbolDefinition.updated_at.desc(), SymbolDefinition.id.desc()) - .limit(1) - ) - ).scalar_one_or_none() - return str(row).strip() if row else None - - async def _replace_file_index( - self, - *, - file_path: str, - language: str, - file_hash: str, - source_mtime: datetime, - symbols: list[ParsedSymbol], - refs: list[ParsedReference], - ) -> tuple[int, int]: - await self.db.execute( - delete(SymbolReference).where( - and_( - SymbolReference.tenant_id == self.tenant_id, - SymbolReference.file_path == file_path, - ) - ) - ) - await self.db.execute( - delete(SymbolDefinition).where( - and_( - SymbolDefinition.tenant_id == self.tenant_id, - SymbolDefinition.file_path == file_path, - ) - ) - ) - - created_symbols: list[SymbolDefinition] = [] - qn_to_id: dict[str, int] = {} - name_to_id: dict[str, int] = {} - for item in symbols: - row = SymbolDefinition( - tenant_id=self.tenant_id, - file_path=file_path, - language=language, - symbol_type=item.symbol_type[:32], - symbol_name=item.symbol_name[:255], - qualified_name=item.qualified_name[:700] if item.qualified_name else None, - start_line=int(item.start_line) if item.start_line > 0 else None, - end_line=int(item.end_line) if item.end_line > 0 else None, - signature=item.signature[:1000] if item.signature else None, - docstring=item.docstring[:2000] if item.docstring else None, - file_hash=file_hash, - source_mtime=source_mtime, - status="active", - payload=item.payload, - last_indexed_at=now_cn_naive(), - ) - self.db.add(row) - created_symbols.append(row) - await self.db.flush() - for row in created_symbols: - if row.qualified_name: - qn_to_id[str(row.qualified_name)] = int(row.id) - name_to_id[str(row.symbol_name)] = int(row.id) - - ref_count = 0 - # DB 层有 (tenant_id,file_path,relation_type,target_symbol_name,start_line,source_symbol_id) 唯一约束, - # 这里先去重,避免同一行重复调用(例如链式调用)触发批量写入冲突。 - ref_seen_keys: set[tuple[str, str, Optional[int], Optional[int]]] = set() - for ref in refs: - source_id = None - source_qn = str(ref.source_qualified_name or "").strip() - if source_qn: - source_id = qn_to_id.get(source_qn) - if source_id is None: - source_tail = source_qn.split(".")[-1] - source_id = name_to_id.get(source_tail) - relation_type = ref.relation_type[:32] - target_symbol_name = ref.target_symbol_name[:255] - start_line = int(ref.start_line) if ref.start_line > 0 else None - dedupe_key = ( - relation_type, - target_symbol_name, - start_line, - int(source_id) if source_id is not None else None, - ) - if dedupe_key in ref_seen_keys: - continue - ref_seen_keys.add(dedupe_key) - row = SymbolReference( - tenant_id=self.tenant_id, - source_symbol_id=int(source_id) if source_id is not None else None, - file_path=file_path, - relation_type=relation_type, - target_symbol_name=target_symbol_name, - target_qualified_name=ref.target_qualified_name[:700] if ref.target_qualified_name else None, - start_line=start_line, - end_line=int(ref.end_line) if ref.end_line > 0 else None, - payload=ref.payload, - ) - self.db.add(row) - ref_count += 1 - await self.db.flush() - return (len(created_symbols), ref_count) - - def _parse_file( - self, - *, - file_path: str, - language: str, - content: str, - ) -> tuple[list[ParsedSymbol], list[ParsedReference]]: - if language == "python": - return self._parse_python_file(file_path=file_path, content=content) - return self._parse_typescript_file(file_path=file_path, content=content) - - def _parse_python_file(self, *, file_path: str, content: str) -> tuple[list[ParsedSymbol], list[ParsedReference]]: - text = str(content or "") - if not text.strip(): - return ([], []) - try: - tree = ast.parse(text) - except Exception: - return ([], []) - symbols: list[ParsedSymbol] = [] - refs: list[ParsedReference] = [] - - class Visitor(ast.NodeVisitor): - def __init__(self): - self.class_stack: list[str] = [] - self.func_stack: list[str] = [] - - def _current_source(self) -> str: - if self.func_stack: - return self.func_stack[-1] - if self.class_stack: - return self.class_stack[-1] - return "" - - def _qualify(self, name: str) -> str: - chain = [*self.class_stack] - if self.func_stack: - chain.extend(self.func_stack[-1].split(".")[len(self.class_stack):]) - chain.append(name) - uniq: list[str] = [] - for item in chain: - if item and (not uniq or uniq[-1] != item): - uniq.append(item) - return ".".join(uniq) if uniq else name - - def visit_ClassDef(self, node: ast.ClassDef) -> Any: - qualified = ".".join([*self.class_stack, node.name]) if self.class_stack else node.name - symbols.append( - ParsedSymbol( - symbol_type="class", - symbol_name=node.name, - qualified_name=qualified, - start_line=int(getattr(node, "lineno", 0) or 0), - end_line=int(getattr(node, "end_lineno", getattr(node, "lineno", 0)) or 0), - signature=f"class {node.name}", - docstring=str(ast.get_docstring(node) or ""), - payload={"file_path": file_path}, - ) - ) - self.class_stack.append(node.name) - self.generic_visit(node) - self.class_stack.pop() - - def visit_FunctionDef(self, node: ast.FunctionDef) -> Any: - self._visit_function_node(node=node, is_async=False) - - def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> Any: - self._visit_function_node(node=node, is_async=True) - - def _visit_function_node(self, *, node: Any, is_async: bool) -> None: - if self.class_stack: - qualified = ".".join([*self.class_stack, node.name]) - symbol_type = "method" - else: - qualified = node.name - symbol_type = "function" - arg_names = [] - for arg in list(node.args.posonlyargs) + list(node.args.args): - arg_names.append(str(arg.arg)) - if node.args.vararg is not None: - arg_names.append(f"*{node.args.vararg.arg}") - for arg in node.args.kwonlyargs: - arg_names.append(str(arg.arg)) - if node.args.kwarg is not None: - arg_names.append(f"**{node.args.kwarg.arg}") - prefix = "async def" if is_async else "def" - signature = f"{prefix} {node.name}({', '.join(arg_names)})" - symbols.append( - ParsedSymbol( - symbol_type=symbol_type, - symbol_name=node.name, - qualified_name=qualified, - start_line=int(getattr(node, "lineno", 0) or 0), - end_line=int(getattr(node, "end_lineno", getattr(node, "lineno", 0)) or 0), - signature=signature[:1000], - docstring=str(ast.get_docstring(node) or ""), - payload={"file_path": file_path}, - ) - ) - self.func_stack.append(qualified) - self.generic_visit(node) - self.func_stack.pop() - - def visit_Import(self, node: ast.Import) -> Any: - source = self._current_source() - for alias in node.names: - target = str(alias.name or "").strip() - if not target: - continue - refs.append( - ParsedReference( - relation_type="imports", - target_symbol_name=target.split(".")[-1][:255], - target_qualified_name=target[:700], - source_qualified_name=source[:700], - start_line=int(getattr(node, "lineno", 0) or 0), - end_line=int(getattr(node, "end_lineno", getattr(node, "lineno", 0)) or 0), - payload={"alias": str(alias.asname or "")[:255] if alias.asname else None}, - ) - ) - self.generic_visit(node) - - def visit_ImportFrom(self, node: ast.ImportFrom) -> Any: - source = self._current_source() - module = str(node.module or "").strip() - for alias in node.names: - name = str(alias.name or "").strip() - if not name: - continue - target_qn = f"{module}.{name}" if module else name - refs.append( - ParsedReference( - relation_type="imports", - target_symbol_name=name[:255], - target_qualified_name=target_qn[:700], - source_qualified_name=source[:700], - start_line=int(getattr(node, "lineno", 0) or 0), - end_line=int(getattr(node, "end_lineno", getattr(node, "lineno", 0)) or 0), - payload={"module": module[:255] if module else None}, - ) - ) - self.generic_visit(node) - - def visit_Call(self, node: ast.Call) -> Any: - source = self._current_source() - target = "" - if isinstance(node.func, ast.Name): - target = str(node.func.id or "").strip() - elif isinstance(node.func, ast.Attribute): - target = str(node.func.attr or "").strip() - if target: - refs.append( - ParsedReference( - relation_type="calls", - target_symbol_name=target[:255], - target_qualified_name=target[:700], - source_qualified_name=source[:700], - start_line=int(getattr(node, "lineno", 0) or 0), - end_line=int(getattr(node, "end_lineno", getattr(node, "lineno", 0)) or 0), - payload={}, - ) - ) - self.generic_visit(node) - - Visitor().visit(tree) - return (symbols[:5000], refs[:12000]) - - def _parse_typescript_file(self, *, file_path: str, content: str) -> tuple[list[ParsedSymbol], list[ParsedReference]]: - text = str(content or "") - if not text.strip(): - return ([], []) - symbols: list[ParsedSymbol] = [] - refs: list[ParsedReference] = [] - lines = text.splitlines() - line_symbol_map: dict[int, str] = {} - - for idx, line in enumerate(lines, start=1): - match = re.match( - r"^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(function|class|interface|type|const)\s+([A-Za-z_][A-Za-z0-9_]*)", - line, - ) - if not match: - continue - symbol_type = str(match.group(1) or "").strip().lower() - name = str(match.group(2) or "").strip() - if not name: - continue - signature = line.strip() - symbols.append( - ParsedSymbol( - symbol_type=symbol_type, - symbol_name=name, - qualified_name=name, - start_line=idx, - end_line=idx, - signature=signature[:1000], - docstring="", - payload={"file_path": file_path}, - ) - ) - line_symbol_map[idx] = name - - if "frontend/src/router/" in str(file_path).replace("\\", "/"): - for idx, line in enumerate(lines, start=1): - m = re.search( - r'path:\s*"([^"]+)"\s*,\s*element:\s*<([A-Za-z_][A-Za-z0-9_]*)', - line, - ) - if not m: - continue - route_path = str(m.group(1) or "").strip() - component = str(m.group(2) or "").strip() - if not route_path: - continue - clean_route = route_path if route_path.startswith("/") else f"/{route_path}" - symbol_name = f"route:{clean_route}" - symbols.append( - ParsedSymbol( - symbol_type="route", - symbol_name=symbol_name[:255], - qualified_name=symbol_name[:700], - start_line=idx, - end_line=idx, - signature=line.strip()[:1000], - docstring=f"component:{component}", - payload={"file_path": file_path, "route_path": clean_route, "component": component}, - ) - ) - - if str(file_path).replace("\\", "/").endswith("components/layout/Sidebar.tsx"): - for idx, line in enumerate(lines, start=1): - m = re.search(r'to:\s*"([^"]+)"\s*,\s*label:\s*"([^"]+)"', line) - if not m: - continue - route_path = str(m.group(1) or "").strip() - label = str(m.group(2) or "").strip() - if not route_path or not label: - continue - clean_route = route_path if route_path.startswith("/") else f"/{route_path}" - symbol_name = f"feature:{label}" - symbols.append( - ParsedSymbol( - symbol_type="feature", - symbol_name=symbol_name[:255], - qualified_name=f"{label}@{clean_route}"[:700], - start_line=idx, - end_line=idx, - signature=line.strip()[:1000], - docstring=f"menu:{clean_route}", - payload={"file_path": file_path, "feature_label": label, "route_path": clean_route}, - ) - ) - - imports = TS_IMPORT_RE.finditer(text) - for match in imports: - module = str(match.group(1) or "").strip() - if not module: - continue - start = text[: match.start()].count("\n") + 1 - refs.append( - ParsedReference( - relation_type="imports", - target_symbol_name=module.split("/")[-1][:255], - target_qualified_name=module[:700], - source_qualified_name="", - start_line=start, - end_line=start, - payload={}, - ) - ) - - for idx, line in enumerate(lines, start=1): - source_symbol = "" - candidate_lines = [x for x in line_symbol_map.keys() if x <= idx] - if candidate_lines: - source_symbol = line_symbol_map[max(candidate_lines)] - for call in TS_CALL_RE.finditer(line): - name = str(call.group(1) or "").strip() - if not name or name.lower() in TS_KEYWORDS: - continue - refs.append( - ParsedReference( - relation_type="calls", - target_symbol_name=name[:255], - target_qualified_name=name[:700], - source_qualified_name=source_symbol[:700], - start_line=idx, - end_line=idx, - payload={}, - ) - ) - return (symbols[:5000], refs[:12000]) - - @staticmethod - def _detect_language(path: Path) -> str: - ext = path.suffix.lower() - if ext == ".py": - return "python" - if ext in {".ts", ".tsx", ".js", ".jsx"}: - return "typescript" - return "" - - @staticmethod - def _serialize_symbol(row: SymbolDefinition) -> dict[str, Any]: - return { - "id": int(row.id), - "file_path": str(row.file_path or ""), - "language": str(row.language or ""), - "symbol_type": str(row.symbol_type or ""), - "symbol_name": str(row.symbol_name or ""), - "qualified_name": str(row.qualified_name or ""), - "start_line": int(row.start_line) if row.start_line is not None else None, - "end_line": int(row.end_line) if row.end_line is not None else None, - "signature": str(row.signature or ""), - "docstring": str(row.docstring or ""), - "status": str(row.status or ""), - "updated_at": row.updated_at.isoformat() if row.updated_at else None, - "last_indexed_at": row.last_indexed_at.isoformat() if row.last_indexed_at else None, - } - - @staticmethod - def _serialize_ref(row: SymbolReference) -> dict[str, Any]: - return { - "id": int(row.id), - "source_symbol_id": int(row.source_symbol_id) if row.source_symbol_id is not None else None, - "file_path": str(row.file_path or ""), - "relation_type": str(row.relation_type or ""), - "target_symbol_name": str(row.target_symbol_name or ""), - "target_qualified_name": str(row.target_qualified_name or ""), - "start_line": int(row.start_line) if row.start_line is not None else None, - "end_line": int(row.end_line) if row.end_line is not None else None, - "updated_at": row.updated_at.isoformat() if row.updated_at else None, - } +__all__ = ["ParsedReference", "ParsedSymbol", "SymbolIndexService"] diff --git a/backend/app/services/task_brief/__init__.py b/backend/app/services/task_brief/__init__.py new file mode 100644 index 00000000..01ccb308 --- /dev/null +++ b/backend/app/services/task_brief/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .service import TaskBriefService + +__all__ = ["TaskBriefService"] diff --git a/backend/app/services/task_brief/mixin_bundle_crud.py b/backend/app/services/task_brief/mixin_bundle_crud.py new file mode 100644 index 00000000..be8aef33 --- /dev/null +++ b/backend/app/services/task_brief/mixin_bundle_crud.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +from .shared import * + +class TaskBriefBundleCrudMixin: + @classmethod + async def _get_or_init_bundle(cls, db: AsyncSession, *, tenant_id: int) -> tuple[Integration, dict[str, Any]]: + row = ( + await db.execute( + select(Integration).where( + and_( + Integration.tenant_id == int(tenant_id), + Integration.type == INTEGRATION_TYPE, + ) + ) + ) + ).scalar_one_or_none() + if row is None: + row = Integration( + tenant_id=int(tenant_id), + type=INTEGRATION_TYPE, + is_active=True, + config={ + "templates": [], + "runs": [], + "briefs": [], + }, + ) + db.add(row) + await db.flush() + + bundle = cls._safe_dict(row.config) + changed = False + for key in ("templates", "runs", "briefs"): + if not isinstance(bundle.get(key), list): + bundle[key] = [] + changed = True + + existing_templates = [item for item in bundle.get("templates", []) if isinstance(item, dict)] + if not existing_templates: + bundle["templates"] = cls._build_system_templates() + existing_templates = [item for item in bundle["templates"] if isinstance(item, dict)] + changed = True + else: + upgraded_default_template = False + latest_default_content = cls._load_default_template_content() + for item in existing_templates: + if str(item.get("id") or "") != DEFAULT_TEMPLATE_ID: + continue + current_content = str(item.get("content") or "").strip() + if current_content and not cls._is_default_template_fallback(current_content): + break + item["name"] = str(item.get("name") or "").strip() or DEFAULT_TEMPLATE_NAME + item["description"] = str(item.get("description") or "").strip() or DEFAULT_TEMPLATE_DESCRIPTION + item["content"] = latest_default_content + item["updated_at"] = now_cn_naive().isoformat() + if item.get("updated_by") is None: + item["updated_by"] = 0 + upgraded_default_template = True + break + if upgraded_default_template: + bundle["templates"] = existing_templates + changed = True + + existing_templates, added_presets = cls._ensure_system_templates(existing_templates) + if added_presets: + bundle["templates"] = existing_templates + changed = True + + existing_briefs = [item for item in bundle.get("briefs", []) if isinstance(item, dict)] + existing_runs = [item for item in bundle.get("runs", []) if isinstance(item, dict)] + if not existing_briefs and not existing_runs: + seed_briefs, seed_runs = cls._load_seed_history() + if seed_briefs and seed_runs: + bundle["briefs"] = seed_briefs + bundle["runs"] = seed_runs + changed = True + + if changed: + row.config = bundle + await db.flush() + return row, bundle + + @staticmethod + def _sort_by_time_desc(items: list[dict[str, Any]], key: str = "created_at") -> list[dict[str, Any]]: + return sorted(items, key=lambda x: str(x.get(key) or ""), reverse=True) + + @classmethod + async def list_center(cls, db: AsyncSession, *, tenant_id: int) -> list[dict[str, Any]]: + _, bundle = await cls._get_or_init_bundle(db, tenant_id=tenant_id) + briefs = [dict(item) for item in bundle.get("briefs", []) if isinstance(item, dict)] + return cls._sort_by_time_desc(briefs, key="created_at") + + @classmethod + async def list_templates(cls, db: AsyncSession, *, tenant_id: int) -> list[dict[str, Any]]: + _, bundle = await cls._get_or_init_bundle(db, tenant_id=tenant_id) + templates = [dict(item) for item in bundle.get("templates", []) if isinstance(item, dict)] + return cls._sort_by_time_desc(templates, key="updated_at") + + @classmethod + async def create_template( + cls, + db: AsyncSession, + *, + tenant_id: int, + operator_user_id: int, + name: str, + content: str, + description: Optional[str] = None, + ) -> dict[str, Any]: + row, bundle = await cls._get_or_init_bundle(db, tenant_id=tenant_id) + now_text = now_cn_naive().isoformat() + item = { + "id": f"tpl_{uuid4().hex[:12]}", + "name": str(name or "").strip()[:120] or "默认模板", + "description": str(description or "").strip()[:300] or None, + "content": str(content or "").strip(), + "updated_at": now_text, + "updated_by": int(operator_user_id), + "created_at": now_text, + } + templates = bundle.get("templates") if isinstance(bundle.get("templates"), list) else [] + templates.append(item) + bundle["templates"] = templates + row.config = bundle + await db.flush() + return item + + @classmethod + async def update_template( + cls, + db: AsyncSession, + *, + tenant_id: int, + operator_user_id: int, + template_id: str, + name: Optional[str] = None, + content: Optional[str] = None, + description: Optional[str] = None, + ) -> dict[str, Any] | None: + row, bundle = await cls._get_or_init_bundle(db, tenant_id=tenant_id) + templates = bundle.get("templates") if isinstance(bundle.get("templates"), list) else [] + target = None + for item in templates: + if not isinstance(item, dict): + continue + if str(item.get("id") or "") == str(template_id or ""): + target = item + break + if target is None: + return None + if name is not None: + target["name"] = str(name or "").strip()[:120] or target.get("name") or "默认模板" + if content is not None: + target["content"] = str(content or "").strip() + if description is not None: + target["description"] = str(description or "").strip()[:300] or None + target["updated_at"] = now_cn_naive().isoformat() + target["updated_by"] = int(operator_user_id) + bundle["templates"] = templates + row.config = bundle + await db.flush() + return dict(target) + + @classmethod + async def list_runs(cls, db: AsyncSession, *, tenant_id: int) -> list[dict[str, Any]]: + _, bundle = await cls._get_or_init_bundle(db, tenant_id=tenant_id) + runs = [dict(item) for item in bundle.get("runs", []) if isinstance(item, dict)] + return cls._sort_by_time_desc(runs, key="generated_at") + diff --git a/backend/app/services/task_brief/mixin_generate_status.py b/backend/app/services/task_brief/mixin_generate_status.py new file mode 100644 index 00000000..f97e7ed9 --- /dev/null +++ b/backend/app/services/task_brief/mixin_generate_status.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from .shared import * + +class TaskBriefGenerateStatusMixin: + @classmethod + async def generate_brief( + cls, + db: AsyncSession, + *, + tenant_id: int, + operator_user_id: int, + title: str, + template_id: Optional[str], + changed_files: list[str], + scope_summary: Optional[str], + ) -> dict[str, Any]: + row, bundle = await cls._get_or_init_bundle(db, tenant_id=tenant_id) + now_text = now_cn_naive().isoformat() + templates = bundle.get("templates") if isinstance(bundle.get("templates"), list) else [] + template_obj = None + if template_id: + for item in templates: + if isinstance(item, dict) and str(item.get("id") or "") == str(template_id): + template_obj = item + break + if template_obj is None: + template_obj = next((item for item in templates if isinstance(item, dict)), None) + + brief_id = f"brief_{uuid4().hex[:12]}" + run_id = f"run_{uuid4().hex[:12]}" + file_lines = [str(path).strip() for path in changed_files if str(path).strip()] + summary = str(scope_summary or "").strip() or f"共 {len(file_lines)} 个文件发生变更" + risks = ( + [ + "跨文件改动可能引发契约/类型不一致,请补充联动核对。", + "合入前需完成 build/lint/test 并记录结果。", + ] + if file_lines + else ["未提供变更文件列表,风险评估信息不足,请人工补充。"] + ) + content = cls._render_template_content( + template_content=str((template_obj or {}).get("content") or "").strip(), + generated_at=now_text, + base_ref=cls._resolve_git_ref("HEAD~1"), + head_ref=cls._resolve_git_ref("HEAD"), + changed_files=file_lines, + scope_summary=summary, + risks=risks, + ) + + run = { + "id": run_id, + "brief_id": brief_id, + "title": str(title or "").strip()[:160] or "增量任务包", + "template_id": str((template_obj or {}).get("id") or "") or None, + "template_name": str((template_obj or {}).get("name") or "") or None, + "generated_at": now_text, + "generated_by": int(operator_user_id), + "status": "completed", + "changed_files": file_lines, + "scope_summary": summary, + } + brief = { + "id": brief_id, + "title": run["title"], + "status": "ready", + "created_at": now_text, + "updated_at": now_text, + "latest_run_id": run_id, + "template_id": run["template_id"], + "template_name": run["template_name"], + "summary": summary, + "content": content, + "changed_files": file_lines, + } + + runs = bundle.get("runs") if isinstance(bundle.get("runs"), list) else [] + briefs = bundle.get("briefs") if isinstance(bundle.get("briefs"), list) else [] + runs.append(run) + briefs = [item for item in briefs if isinstance(item, dict) and str(item.get("id") or "") != brief_id] + briefs.append(brief) + + bundle["runs"] = runs + bundle["briefs"] = briefs + row.config = bundle + await db.flush() + return {"run": run, "brief": brief} + + @classmethod + async def get_contract_sync_status(cls, db: AsyncSession, *, tenant_id: int) -> dict[str, Any]: + _ = db + _ = tenant_id + root = Path(__file__).resolve().parents[3] + contract_files = [ + root / "docs/architecture/MASTER_CONTRACT.md", + root / "docs/architecture/API_CONTRACTS.md", + root / "docs/architecture/DATAFLOW.md", + ] + statuses = [] + for file_path in contract_files: + exists = file_path.exists() + statuses.append( + { + "file": str(file_path.relative_to(root)), + "exists": exists, + "updated_at": ( + datetime.fromtimestamp(file_path.stat().st_mtime).isoformat() + if exists + else None + ), + } + ) + all_synced = all(bool(item.get("exists")) for item in statuses) + return { + "all_synced": all_synced, + "files": statuses, + } + diff --git a/backend/app/services/task_brief/mixin_seed_history.py b/backend/app/services/task_brief/mixin_seed_history.py new file mode 100644 index 00000000..826bcd2b --- /dev/null +++ b/backend/app/services/task_brief/mixin_seed_history.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from .shared import * + +class TaskBriefSeedHistoryMixin: + @staticmethod + def _extract_markdown_section_lines(content: str, *, heading: str) -> list[str]: + target = f"## {heading}".lower() + lines = content.splitlines() + in_section = False + collected: list[str] = [] + for raw in lines: + line = raw.strip() + if line.lower().startswith("## "): + if line.lower() == target: + in_section = True + continue + if in_section: + break + if in_section and line: + collected.append(line) + return collected + + @staticmethod + def _extract_markdown_meta_value(content: str, *, key: str) -> str | None: + prefix = f"- {key}:" + for raw in content.splitlines(): + line = raw.strip() + if line.startswith(prefix): + return line.split(":", 1)[1].strip() or None + return None + + @classmethod + def _normalize_generated_at(cls, raw_generated: str | None, fallback: datetime) -> str: + if not raw_generated: + return fallback.isoformat() + for fmt in ("%Y-%m-%d %H:%M:%S %z",): + try: + return datetime.strptime(raw_generated, fmt).isoformat() + except Exception: + pass + try: + return datetime.fromisoformat(raw_generated).isoformat() + except Exception: + return raw_generated + + @classmethod + def _load_seed_history(cls) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + docs_dir = cls._project_root() / "docs/task_briefs" + if not docs_dir.exists(): + return [], [] + + briefs: list[dict[str, Any]] = [] + runs: list[dict[str, Any]] = [] + for file_path in sorted(docs_dir.glob("task_brief_*.md")): + try: + content = file_path.read_text(encoding="utf-8") + except Exception: + continue + if not content.strip(): + continue + + stem = "".join(ch for ch in file_path.stem if ch.isalnum() or ch in {"_", "-"}) + brief_id = f"brief_seed_{stem}"[:64] + run_id = f"run_seed_{stem}"[:64] + fallback_time = datetime.fromtimestamp(file_path.stat().st_mtime) + generated_at = cls._normalize_generated_at( + cls._extract_markdown_meta_value(content, key="Generated At"), + fallback=fallback_time, + ) + + title = "导入任务包" + for line in content.splitlines(): + if line.strip().startswith("# "): + title = line.strip()[2:].strip() or title + break + + changed_section = cls._extract_markdown_section_lines(content, heading="Changed Files") + changed_files = [line[2:].strip() for line in changed_section if line.startswith("- ") and line[2:].strip()] + + scope_section = cls._extract_markdown_section_lines(content, heading="Scope") + scope_items = [line[2:].strip() if line.startswith("- ") else line for line in scope_section] + scope_summary = ";".join([item for item in scope_items if item][:2]).strip() + if not scope_summary: + scope_summary = "从 docs/task_briefs 自动导入" + + run = { + "id": run_id, + "brief_id": brief_id, + "title": title, + "template_id": DEFAULT_TEMPLATE_ID, + "template_name": DEFAULT_TEMPLATE_NAME, + "generated_at": generated_at, + "generated_by": 0, + "status": "imported", + "changed_files": changed_files, + "scope_summary": scope_summary, + } + brief = { + "id": brief_id, + "title": title, + "status": "ready", + "created_at": generated_at, + "updated_at": generated_at, + "latest_run_id": run_id, + "template_id": DEFAULT_TEMPLATE_ID, + "template_name": DEFAULT_TEMPLATE_NAME, + "summary": scope_summary, + "content": content.strip(), + "changed_files": changed_files, + } + runs.append(run) + briefs.append(brief) + return briefs, runs + diff --git a/backend/app/services/task_brief/mixin_templates.py b/backend/app/services/task_brief/mixin_templates.py new file mode 100644 index 00000000..9a7a72ad --- /dev/null +++ b/backend/app/services/task_brief/mixin_templates.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +from .shared import * + +class TaskBriefTemplatesMixin: + @classmethod + def _project_root_candidates(cls) -> list[Path]: + service_file = Path(__file__).resolve() + cwd = Path.cwd().resolve() + raw_candidates = [ + service_file.parents[3], + service_file.parents[2], + cwd, + cwd.parent, + ] + deduped: list[Path] = [] + seen: set[str] = set() + for path in raw_candidates: + key = str(path) + if key in seen: + continue + seen.add(key) + deduped.append(path) + return deduped + + @staticmethod + def _safe_dict(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + try: + # 深拷贝,避免浅拷贝触发 JSON 嵌套对象原地修改导致的“提交不落库”。 + return json.loads(json.dumps(value)) + except Exception: + return dict(value) + if isinstance(value, str): + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + return {} + + @classmethod + def _project_root(cls) -> Path: + for root in cls._project_root_candidates(): + if (root / "docs" / "architecture").exists(): + return root + return cls._project_root_candidates()[0] + + @staticmethod + def _normalize_template_text(content: str) -> str: + return "\n".join(line.rstrip() for line in str(content or "").strip().splitlines()).strip() + + @classmethod + def _is_default_template_fallback(cls, content: str) -> bool: + return cls._normalize_template_text(content) == cls._normalize_template_text(DEFAULT_TEMPLATE_FALLBACK) + + @classmethod + def _load_default_template_content(cls) -> str: + template_path = cls._project_root() / "docs/architecture/TASK_BRIEF_TEMPLATE.md" + if template_path.exists(): + try: + return template_path.read_text(encoding="utf-8").strip() or DEFAULT_TEMPLATE_FALLBACK + except Exception: + return DEFAULT_TEMPLATE_FALLBACK + return DEFAULT_TEMPLATE_FALLBACK + + @classmethod + def _resolve_git_ref(cls, ref: str) -> str: + try: + output = subprocess.check_output( + ["git", "rev-parse", "--short", ref], + cwd=str(cls._project_root()), + stderr=subprocess.DEVNULL, + text=True, + timeout=1.5, + ).strip() + return output or "-" + except Exception: + return "-" + + @staticmethod + def _to_markdown_list(items: list[str], *, empty_text: str) -> str: + cleaned = [str(item).strip() for item in items if str(item).strip()] + if not cleaned: + return f"- {empty_text}" + return "\n".join(f"- {item}" for item in cleaned) + + @classmethod + def _render_template_content( + cls, + *, + template_content: str, + generated_at: str, + base_ref: str, + head_ref: str, + changed_files: list[str], + scope_summary: str, + risks: list[str], + ) -> str: + content = str(template_content or "").strip() or cls._load_default_template_content() + replacements = { + "{{GENERATED_AT}}": str(generated_at or "-"), + "{{BASE_REF}}": str(base_ref or "-"), + "{{HEAD_REF}}": str(head_ref or "-"), + "{{CHANGED_FILES}}": cls._to_markdown_list(changed_files, empty_text="(none)"), + "{{SCOPE_SUMMARY}}": str(scope_summary or "-"), + "{{RISKS}}": cls._to_markdown_list(risks, empty_text="待补充风险评估"), + } + rendered = content + for token, value in replacements.items(): + rendered = rendered.replace(token, value) + return rendered.strip() + + @classmethod + def _build_default_template(cls) -> dict[str, Any]: + now_text = now_cn_naive().isoformat() + return { + "id": DEFAULT_TEMPLATE_ID, + "name": DEFAULT_TEMPLATE_NAME, + "description": DEFAULT_TEMPLATE_DESCRIPTION, + "content": cls._load_default_template_content(), + "updated_at": now_text, + "updated_by": 0, + "created_at": now_text, + } + + @staticmethod + def _build_template_item( + *, + template_id: str, + name: str, + description: str, + content: str, + now_text: str, + ) -> dict[str, Any]: + return { + "id": template_id, + "name": name, + "description": description, + "content": content, + "updated_at": now_text, + "updated_by": 0, + "created_at": now_text, + } + + @classmethod + def _build_system_templates(cls) -> list[dict[str, Any]]: + now_text = now_cn_naive().isoformat() + return [ + cls._build_default_template(), + cls._build_template_item( + template_id=LOW_TEMPLATE_ID, + name=LOW_TEMPLATE_NAME, + description=LOW_TEMPLATE_DESCRIPTION, + content=LOW_TEMPLATE_CONTENT, + now_text=now_text, + ), + cls._build_template_item( + template_id=HIGH_TEMPLATE_ID, + name=HIGH_TEMPLATE_NAME, + description=HIGH_TEMPLATE_DESCRIPTION, + content=HIGH_TEMPLATE_CONTENT, + now_text=now_text, + ), + cls._build_template_item( + template_id=HIGH_CONTRACT_TEMPLATE_ID, + name=HIGH_CONTRACT_TEMPLATE_NAME, + description=HIGH_CONTRACT_TEMPLATE_DESCRIPTION, + content=HIGH_CONTRACT_TEMPLATE_CONTENT, + now_text=now_text, + ), + ] + + @classmethod + def _ensure_system_templates( + cls, + templates: list[dict[str, Any]], + ) -> tuple[list[dict[str, Any]], bool]: + merged = [item for item in templates if isinstance(item, dict)] + known_ids = {str(item.get("id") or "").strip() for item in merged if str(item.get("id") or "").strip()} + changed = False + for preset in cls._build_system_templates(): + template_id = str(preset.get("id") or "").strip() + if not template_id or template_id in known_ids: + continue + merged.append(preset) + known_ids.add(template_id) + changed = True + return merged, changed + diff --git a/backend/app/services/task_brief/service.py b/backend/app/services/task_brief/service.py new file mode 100644 index 00000000..ba5c9a52 --- /dev/null +++ b/backend/app/services/task_brief/service.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from .mixin_bundle_crud import TaskBriefBundleCrudMixin +from .mixin_generate_status import TaskBriefGenerateStatusMixin +from .mixin_seed_history import TaskBriefSeedHistoryMixin +from .mixin_templates import TaskBriefTemplatesMixin + + +class TaskBriefService( + TaskBriefTemplatesMixin, + TaskBriefSeedHistoryMixin, + TaskBriefBundleCrudMixin, + TaskBriefGenerateStatusMixin, +): + pass + + +__all__ = ["TaskBriefService"] diff --git a/backend/app/services/task_brief/shared.py b/backend/app/services/task_brief/shared.py new file mode 100644 index 00000000..2574b61b --- /dev/null +++ b/backend/app/services/task_brief/shared.py @@ -0,0 +1,155 @@ +"""增量任务包管理服务。""" + +from __future__ import annotations + +import json +import subprocess +from datetime import datetime +from pathlib import Path +from typing import Any, Optional +from uuid import uuid4 + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...models import Integration +from ...utils.timezone import now_cn_naive + +INTEGRATION_TYPE = "task_briefs" +DEFAULT_TEMPLATE_ID = "tpl_default_signal_planner" +DEFAULT_TEMPLATE_NAME = "Signal Planner 默认模板" +DEFAULT_TEMPLATE_DESCRIPTION = "系统初始化模板(可按需修改)" +DEFAULT_TEMPLATE_FALLBACK = """# Task Brief + +## Meta +- Generated At: {{GENERATED_AT}} +- Base Ref: {{BASE_REF}} +- Head Ref: {{HEAD_REF}} + +## Changed Files +{{CHANGED_FILES}} + +## Scope +{{SCOPE_SUMMARY}} + +## Risks +{{RISKS}} +""" +LOW_TEMPLATE_ID = "tpl_low_model_execution" +LOW_TEMPLATE_NAME = "Low Model 执行模板(收敛)" +LOW_TEMPLATE_DESCRIPTION = "面向 low 模型:聚焦确定性执行、最小改动、可回归。" +LOW_TEMPLATE_CONTENT = """# Task Brief(Low Model 执行版) + +## Meta +- Generated At: {{GENERATED_AT}} +- Base Ref: {{BASE_REF}} +- Head Ref: {{HEAD_REF}} + +## Goal +- 任务目标:{{SCOPE_SUMMARY}} +- 执行原则:只做最小必要改动,不扩散需求边界。 + +## Changed Files +{{CHANGED_FILES}} + +## Atomic Actions +1. 明确本次唯一目标与目标验收标准(1-2 条)。 +2. 逐文件执行最小改动,禁止顺手重构。 +3. 写前后保持接口字段兼容,避免破坏已有调用。 +4. 每次改动后进行快速验证并记录结果。 + +## Must-Run Verification +- Backend: `python3 -m py_compile backend/app` +- Frontend: `npm run build` + `npx tsc --noEmit` +- 必须给出失败项与修复动作,禁止只报“已测试”。 + +## Escalation Gate (Upgrade to High Model) +- 跨模块文件超过 5 个; +- 涉及数据库 schema / 事务边界调整; +- 涉及前后端契约新增或破坏性变更; +- 无法在当前信息下保证零回归。 + +## Risks +{{RISKS}} +""" +HIGH_TEMPLATE_ID = "tpl_high_model_architect" +HIGH_TEMPLATE_NAME = "High Model 架构模板(跨栈)" +HIGH_TEMPLATE_DESCRIPTION = "面向 high 模型:跨栈影响评估、契约矩阵、回滚策略。" +HIGH_TEMPLATE_CONTENT = """# Task Brief(High Model 架构版) + +## Meta +- Generated At: {{GENERATED_AT}} +- Base Ref: {{BASE_REF}} +- Head Ref: {{HEAD_REF}} + +## Strategic Goal +- 业务目标:{{SCOPE_SUMMARY}} +- 决策边界:优先保障可回滚、契约兼容、线上稳定性。 + +## Changed Files +{{CHANGED_FILES}} + +## Cross-Stack Impact Matrix +| 领域 | 影响描述 | 契约同步动作 | 验证方式 | +| --- | --- | --- | --- | +| Backend API | | | | +| Frontend Type | | | | +| DB/Data | | | | +| Async/Job | | | | + +## Controlled Write Plan +- 所有数据库写入必须经受控接口(HTTP 或 `internal://`); +- 不允许直连 SQL 路径作为最终执行方案; +- 需给出接口化落地路径与调用顺序。 + +## Rollback Plan +1. 回滚触发条件(明确监控阈值)。 +2. 数据层回滚策略(兼容/补偿/回放)。 +3. 应用层回滚策略(开关/降级/流量切换)。 + +## Verification Gate +- 编译、单测、关键链路回归全部通过; +- 前后端契约文档同步完成; +- 风险项有 owner 与截止时间。 + +## Risks +{{RISKS}} +""" +HIGH_CONTRACT_TEMPLATE_ID = "tpl_high_model_contract_guard" +HIGH_CONTRACT_TEMPLATE_NAME = "High Model 契约审计模板" +HIGH_CONTRACT_TEMPLATE_DESCRIPTION = "面向 high 模型:前后端契约覆盖率审计与缺口修复。" +HIGH_CONTRACT_TEMPLATE_CONTENT = """# Task Brief(High Model 契约审计版) + +## Meta +- Generated At: {{GENERATED_AT}} +- Base Ref: {{BASE_REF}} +- Head Ref: {{HEAD_REF}} + +## Audit Scope +- 目标范围:{{SCOPE_SUMMARY}} + +## Changed Files +{{CHANGED_FILES}} + +## Contract Coverage Checklist +- [ ] 后端新增/变更 API 已在契约文档登记; +- [ ] 前端调用与后端路由一一对应; +- [ ] 响应包络保持 `{code, message, data}`; +- [ ] 关键类型(TS/Pydantic)字段一致; +- [ ] 历史兼容策略(向后兼容或版本化)已说明。 + +## Gap Register +| Gap | Severity | Owner | Fix Plan | ETA | +| --- | --- | --- | --- | --- | +| | | | | | + +## Delivery Artifacts +- 契约差异摘要(新增/删除/变更字段) +- 影响接口清单与调用方映射 +- 回归测试结果与剩余风险 + +## Risks +{{RISKS}} +""" + + diff --git a/backend/app/services/task_brief_service.py b/backend/app/services/task_brief_service.py index 24fb966f..4e116975 100644 --- a/backend/app/services/task_brief_service.py +++ b/backend/app/services/task_brief_service.py @@ -1,729 +1,5 @@ -"""增量任务包管理服务。""" - from __future__ import annotations -import json -import subprocess -from datetime import datetime -from pathlib import Path -from typing import Any, Optional -from uuid import uuid4 +from .task_brief import TaskBriefService -from sqlalchemy import and_, select -from sqlalchemy.ext.asyncio import AsyncSession - -from ..models import Integration -from ..utils.timezone import now_cn_naive - -INTEGRATION_TYPE = "task_briefs" -DEFAULT_TEMPLATE_ID = "tpl_default_signal_planner" -DEFAULT_TEMPLATE_NAME = "Signal Planner 默认模板" -DEFAULT_TEMPLATE_DESCRIPTION = "系统初始化模板(可按需修改)" -DEFAULT_TEMPLATE_FALLBACK = """# Task Brief - -## Meta -- Generated At: {{GENERATED_AT}} -- Base Ref: {{BASE_REF}} -- Head Ref: {{HEAD_REF}} - -## Changed Files -{{CHANGED_FILES}} - -## Scope -{{SCOPE_SUMMARY}} - -## Risks -{{RISKS}} -""" -LOW_TEMPLATE_ID = "tpl_low_model_execution" -LOW_TEMPLATE_NAME = "Low Model 执行模板(收敛)" -LOW_TEMPLATE_DESCRIPTION = "面向 low 模型:聚焦确定性执行、最小改动、可回归。" -LOW_TEMPLATE_CONTENT = """# Task Brief(Low Model 执行版) - -## Meta -- Generated At: {{GENERATED_AT}} -- Base Ref: {{BASE_REF}} -- Head Ref: {{HEAD_REF}} - -## Goal -- 任务目标:{{SCOPE_SUMMARY}} -- 执行原则:只做最小必要改动,不扩散需求边界。 - -## Changed Files -{{CHANGED_FILES}} - -## Atomic Actions -1. 明确本次唯一目标与目标验收标准(1-2 条)。 -2. 逐文件执行最小改动,禁止顺手重构。 -3. 写前后保持接口字段兼容,避免破坏已有调用。 -4. 每次改动后进行快速验证并记录结果。 - -## Must-Run Verification -- Backend: `python3 -m py_compile backend/app` -- Frontend: `npm run build` + `npx tsc --noEmit` -- 必须给出失败项与修复动作,禁止只报“已测试”。 - -## Escalation Gate (Upgrade to High Model) -- 跨模块文件超过 5 个; -- 涉及数据库 schema / 事务边界调整; -- 涉及前后端契约新增或破坏性变更; -- 无法在当前信息下保证零回归。 - -## Risks -{{RISKS}} -""" -HIGH_TEMPLATE_ID = "tpl_high_model_architect" -HIGH_TEMPLATE_NAME = "High Model 架构模板(跨栈)" -HIGH_TEMPLATE_DESCRIPTION = "面向 high 模型:跨栈影响评估、契约矩阵、回滚策略。" -HIGH_TEMPLATE_CONTENT = """# Task Brief(High Model 架构版) - -## Meta -- Generated At: {{GENERATED_AT}} -- Base Ref: {{BASE_REF}} -- Head Ref: {{HEAD_REF}} - -## Strategic Goal -- 业务目标:{{SCOPE_SUMMARY}} -- 决策边界:优先保障可回滚、契约兼容、线上稳定性。 - -## Changed Files -{{CHANGED_FILES}} - -## Cross-Stack Impact Matrix -| 领域 | 影响描述 | 契约同步动作 | 验证方式 | -| --- | --- | --- | --- | -| Backend API | | | | -| Frontend Type | | | | -| DB/Data | | | | -| Async/Job | | | | - -## Controlled Write Plan -- 所有数据库写入必须经受控接口(HTTP 或 `internal://`); -- 不允许直连 SQL 路径作为最终执行方案; -- 需给出接口化落地路径与调用顺序。 - -## Rollback Plan -1. 回滚触发条件(明确监控阈值)。 -2. 数据层回滚策略(兼容/补偿/回放)。 -3. 应用层回滚策略(开关/降级/流量切换)。 - -## Verification Gate -- 编译、单测、关键链路回归全部通过; -- 前后端契约文档同步完成; -- 风险项有 owner 与截止时间。 - -## Risks -{{RISKS}} -""" -HIGH_CONTRACT_TEMPLATE_ID = "tpl_high_model_contract_guard" -HIGH_CONTRACT_TEMPLATE_NAME = "High Model 契约审计模板" -HIGH_CONTRACT_TEMPLATE_DESCRIPTION = "面向 high 模型:前后端契约覆盖率审计与缺口修复。" -HIGH_CONTRACT_TEMPLATE_CONTENT = """# Task Brief(High Model 契约审计版) - -## Meta -- Generated At: {{GENERATED_AT}} -- Base Ref: {{BASE_REF}} -- Head Ref: {{HEAD_REF}} - -## Audit Scope -- 目标范围:{{SCOPE_SUMMARY}} - -## Changed Files -{{CHANGED_FILES}} - -## Contract Coverage Checklist -- [ ] 后端新增/变更 API 已在契约文档登记; -- [ ] 前端调用与后端路由一一对应; -- [ ] 响应包络保持 `{code, message, data}`; -- [ ] 关键类型(TS/Pydantic)字段一致; -- [ ] 历史兼容策略(向后兼容或版本化)已说明。 - -## Gap Register -| Gap | Severity | Owner | Fix Plan | ETA | -| --- | --- | --- | --- | --- | -| | | | | | - -## Delivery Artifacts -- 契约差异摘要(新增/删除/变更字段) -- 影响接口清单与调用方映射 -- 回归测试结果与剩余风险 - -## Risks -{{RISKS}} -""" - - -class TaskBriefService: - @classmethod - def _project_root_candidates(cls) -> list[Path]: - service_file = Path(__file__).resolve() - cwd = Path.cwd().resolve() - raw_candidates = [ - service_file.parents[3], - service_file.parents[2], - cwd, - cwd.parent, - ] - deduped: list[Path] = [] - seen: set[str] = set() - for path in raw_candidates: - key = str(path) - if key in seen: - continue - seen.add(key) - deduped.append(path) - return deduped - - @staticmethod - def _safe_dict(value: Any) -> dict[str, Any]: - if isinstance(value, dict): - try: - # 深拷贝,避免浅拷贝触发 JSON 嵌套对象原地修改导致的“提交不落库”。 - return json.loads(json.dumps(value)) - except Exception: - return dict(value) - if isinstance(value, str): - try: - parsed = json.loads(value) - return parsed if isinstance(parsed, dict) else {} - except Exception: - return {} - return {} - - @classmethod - def _project_root(cls) -> Path: - for root in cls._project_root_candidates(): - if (root / "docs" / "architecture").exists(): - return root - return cls._project_root_candidates()[0] - - @staticmethod - def _normalize_template_text(content: str) -> str: - return "\n".join(line.rstrip() for line in str(content or "").strip().splitlines()).strip() - - @classmethod - def _is_default_template_fallback(cls, content: str) -> bool: - return cls._normalize_template_text(content) == cls._normalize_template_text(DEFAULT_TEMPLATE_FALLBACK) - - @classmethod - def _load_default_template_content(cls) -> str: - template_path = cls._project_root() / "docs/architecture/TASK_BRIEF_TEMPLATE.md" - if template_path.exists(): - try: - return template_path.read_text(encoding="utf-8").strip() or DEFAULT_TEMPLATE_FALLBACK - except Exception: - return DEFAULT_TEMPLATE_FALLBACK - return DEFAULT_TEMPLATE_FALLBACK - - @classmethod - def _resolve_git_ref(cls, ref: str) -> str: - try: - output = subprocess.check_output( - ["git", "rev-parse", "--short", ref], - cwd=str(cls._project_root()), - stderr=subprocess.DEVNULL, - text=True, - timeout=1.5, - ).strip() - return output or "-" - except Exception: - return "-" - - @staticmethod - def _to_markdown_list(items: list[str], *, empty_text: str) -> str: - cleaned = [str(item).strip() for item in items if str(item).strip()] - if not cleaned: - return f"- {empty_text}" - return "\n".join(f"- {item}" for item in cleaned) - - @classmethod - def _render_template_content( - cls, - *, - template_content: str, - generated_at: str, - base_ref: str, - head_ref: str, - changed_files: list[str], - scope_summary: str, - risks: list[str], - ) -> str: - content = str(template_content or "").strip() or cls._load_default_template_content() - replacements = { - "{{GENERATED_AT}}": str(generated_at or "-"), - "{{BASE_REF}}": str(base_ref or "-"), - "{{HEAD_REF}}": str(head_ref or "-"), - "{{CHANGED_FILES}}": cls._to_markdown_list(changed_files, empty_text="(none)"), - "{{SCOPE_SUMMARY}}": str(scope_summary or "-"), - "{{RISKS}}": cls._to_markdown_list(risks, empty_text="待补充风险评估"), - } - rendered = content - for token, value in replacements.items(): - rendered = rendered.replace(token, value) - return rendered.strip() - - @classmethod - def _build_default_template(cls) -> dict[str, Any]: - now_text = now_cn_naive().isoformat() - return { - "id": DEFAULT_TEMPLATE_ID, - "name": DEFAULT_TEMPLATE_NAME, - "description": DEFAULT_TEMPLATE_DESCRIPTION, - "content": cls._load_default_template_content(), - "updated_at": now_text, - "updated_by": 0, - "created_at": now_text, - } - - @staticmethod - def _build_template_item( - *, - template_id: str, - name: str, - description: str, - content: str, - now_text: str, - ) -> dict[str, Any]: - return { - "id": template_id, - "name": name, - "description": description, - "content": content, - "updated_at": now_text, - "updated_by": 0, - "created_at": now_text, - } - - @classmethod - def _build_system_templates(cls) -> list[dict[str, Any]]: - now_text = now_cn_naive().isoformat() - return [ - cls._build_default_template(), - cls._build_template_item( - template_id=LOW_TEMPLATE_ID, - name=LOW_TEMPLATE_NAME, - description=LOW_TEMPLATE_DESCRIPTION, - content=LOW_TEMPLATE_CONTENT, - now_text=now_text, - ), - cls._build_template_item( - template_id=HIGH_TEMPLATE_ID, - name=HIGH_TEMPLATE_NAME, - description=HIGH_TEMPLATE_DESCRIPTION, - content=HIGH_TEMPLATE_CONTENT, - now_text=now_text, - ), - cls._build_template_item( - template_id=HIGH_CONTRACT_TEMPLATE_ID, - name=HIGH_CONTRACT_TEMPLATE_NAME, - description=HIGH_CONTRACT_TEMPLATE_DESCRIPTION, - content=HIGH_CONTRACT_TEMPLATE_CONTENT, - now_text=now_text, - ), - ] - - @classmethod - def _ensure_system_templates( - cls, - templates: list[dict[str, Any]], - ) -> tuple[list[dict[str, Any]], bool]: - merged = [item for item in templates if isinstance(item, dict)] - known_ids = {str(item.get("id") or "").strip() for item in merged if str(item.get("id") or "").strip()} - changed = False - for preset in cls._build_system_templates(): - template_id = str(preset.get("id") or "").strip() - if not template_id or template_id in known_ids: - continue - merged.append(preset) - known_ids.add(template_id) - changed = True - return merged, changed - - @staticmethod - def _extract_markdown_section_lines(content: str, *, heading: str) -> list[str]: - target = f"## {heading}".lower() - lines = content.splitlines() - in_section = False - collected: list[str] = [] - for raw in lines: - line = raw.strip() - if line.lower().startswith("## "): - if line.lower() == target: - in_section = True - continue - if in_section: - break - if in_section and line: - collected.append(line) - return collected - - @staticmethod - def _extract_markdown_meta_value(content: str, *, key: str) -> str | None: - prefix = f"- {key}:" - for raw in content.splitlines(): - line = raw.strip() - if line.startswith(prefix): - return line.split(":", 1)[1].strip() or None - return None - - @classmethod - def _normalize_generated_at(cls, raw_generated: str | None, fallback: datetime) -> str: - if not raw_generated: - return fallback.isoformat() - for fmt in ("%Y-%m-%d %H:%M:%S %z",): - try: - return datetime.strptime(raw_generated, fmt).isoformat() - except Exception: - pass - try: - return datetime.fromisoformat(raw_generated).isoformat() - except Exception: - return raw_generated - - @classmethod - def _load_seed_history(cls) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: - docs_dir = cls._project_root() / "docs/task_briefs" - if not docs_dir.exists(): - return [], [] - - briefs: list[dict[str, Any]] = [] - runs: list[dict[str, Any]] = [] - for file_path in sorted(docs_dir.glob("task_brief_*.md")): - try: - content = file_path.read_text(encoding="utf-8") - except Exception: - continue - if not content.strip(): - continue - - stem = "".join(ch for ch in file_path.stem if ch.isalnum() or ch in {"_", "-"}) - brief_id = f"brief_seed_{stem}"[:64] - run_id = f"run_seed_{stem}"[:64] - fallback_time = datetime.fromtimestamp(file_path.stat().st_mtime) - generated_at = cls._normalize_generated_at( - cls._extract_markdown_meta_value(content, key="Generated At"), - fallback=fallback_time, - ) - - title = "导入任务包" - for line in content.splitlines(): - if line.strip().startswith("# "): - title = line.strip()[2:].strip() or title - break - - changed_section = cls._extract_markdown_section_lines(content, heading="Changed Files") - changed_files = [line[2:].strip() for line in changed_section if line.startswith("- ") and line[2:].strip()] - - scope_section = cls._extract_markdown_section_lines(content, heading="Scope") - scope_items = [line[2:].strip() if line.startswith("- ") else line for line in scope_section] - scope_summary = ";".join([item for item in scope_items if item][:2]).strip() - if not scope_summary: - scope_summary = "从 docs/task_briefs 自动导入" - - run = { - "id": run_id, - "brief_id": brief_id, - "title": title, - "template_id": DEFAULT_TEMPLATE_ID, - "template_name": DEFAULT_TEMPLATE_NAME, - "generated_at": generated_at, - "generated_by": 0, - "status": "imported", - "changed_files": changed_files, - "scope_summary": scope_summary, - } - brief = { - "id": brief_id, - "title": title, - "status": "ready", - "created_at": generated_at, - "updated_at": generated_at, - "latest_run_id": run_id, - "template_id": DEFAULT_TEMPLATE_ID, - "template_name": DEFAULT_TEMPLATE_NAME, - "summary": scope_summary, - "content": content.strip(), - "changed_files": changed_files, - } - runs.append(run) - briefs.append(brief) - return briefs, runs - - @classmethod - async def _get_or_init_bundle(cls, db: AsyncSession, *, tenant_id: int) -> tuple[Integration, dict[str, Any]]: - row = ( - await db.execute( - select(Integration).where( - and_( - Integration.tenant_id == int(tenant_id), - Integration.type == INTEGRATION_TYPE, - ) - ) - ) - ).scalar_one_or_none() - if row is None: - row = Integration( - tenant_id=int(tenant_id), - type=INTEGRATION_TYPE, - is_active=True, - config={ - "templates": [], - "runs": [], - "briefs": [], - }, - ) - db.add(row) - await db.flush() - - bundle = cls._safe_dict(row.config) - changed = False - for key in ("templates", "runs", "briefs"): - if not isinstance(bundle.get(key), list): - bundle[key] = [] - changed = True - - existing_templates = [item for item in bundle.get("templates", []) if isinstance(item, dict)] - if not existing_templates: - bundle["templates"] = cls._build_system_templates() - existing_templates = [item for item in bundle["templates"] if isinstance(item, dict)] - changed = True - else: - upgraded_default_template = False - latest_default_content = cls._load_default_template_content() - for item in existing_templates: - if str(item.get("id") or "") != DEFAULT_TEMPLATE_ID: - continue - current_content = str(item.get("content") or "").strip() - if current_content and not cls._is_default_template_fallback(current_content): - break - item["name"] = str(item.get("name") or "").strip() or DEFAULT_TEMPLATE_NAME - item["description"] = str(item.get("description") or "").strip() or DEFAULT_TEMPLATE_DESCRIPTION - item["content"] = latest_default_content - item["updated_at"] = now_cn_naive().isoformat() - if item.get("updated_by") is None: - item["updated_by"] = 0 - upgraded_default_template = True - break - if upgraded_default_template: - bundle["templates"] = existing_templates - changed = True - - existing_templates, added_presets = cls._ensure_system_templates(existing_templates) - if added_presets: - bundle["templates"] = existing_templates - changed = True - - existing_briefs = [item for item in bundle.get("briefs", []) if isinstance(item, dict)] - existing_runs = [item for item in bundle.get("runs", []) if isinstance(item, dict)] - if not existing_briefs and not existing_runs: - seed_briefs, seed_runs = cls._load_seed_history() - if seed_briefs and seed_runs: - bundle["briefs"] = seed_briefs - bundle["runs"] = seed_runs - changed = True - - if changed: - row.config = bundle - await db.flush() - return row, bundle - - @staticmethod - def _sort_by_time_desc(items: list[dict[str, Any]], key: str = "created_at") -> list[dict[str, Any]]: - return sorted(items, key=lambda x: str(x.get(key) or ""), reverse=True) - - @classmethod - async def list_center(cls, db: AsyncSession, *, tenant_id: int) -> list[dict[str, Any]]: - _, bundle = await cls._get_or_init_bundle(db, tenant_id=tenant_id) - briefs = [dict(item) for item in bundle.get("briefs", []) if isinstance(item, dict)] - return cls._sort_by_time_desc(briefs, key="created_at") - - @classmethod - async def list_templates(cls, db: AsyncSession, *, tenant_id: int) -> list[dict[str, Any]]: - _, bundle = await cls._get_or_init_bundle(db, tenant_id=tenant_id) - templates = [dict(item) for item in bundle.get("templates", []) if isinstance(item, dict)] - return cls._sort_by_time_desc(templates, key="updated_at") - - @classmethod - async def create_template( - cls, - db: AsyncSession, - *, - tenant_id: int, - operator_user_id: int, - name: str, - content: str, - description: Optional[str] = None, - ) -> dict[str, Any]: - row, bundle = await cls._get_or_init_bundle(db, tenant_id=tenant_id) - now_text = now_cn_naive().isoformat() - item = { - "id": f"tpl_{uuid4().hex[:12]}", - "name": str(name or "").strip()[:120] or "默认模板", - "description": str(description or "").strip()[:300] or None, - "content": str(content or "").strip(), - "updated_at": now_text, - "updated_by": int(operator_user_id), - "created_at": now_text, - } - templates = bundle.get("templates") if isinstance(bundle.get("templates"), list) else [] - templates.append(item) - bundle["templates"] = templates - row.config = bundle - await db.flush() - return item - - @classmethod - async def update_template( - cls, - db: AsyncSession, - *, - tenant_id: int, - operator_user_id: int, - template_id: str, - name: Optional[str] = None, - content: Optional[str] = None, - description: Optional[str] = None, - ) -> dict[str, Any] | None: - row, bundle = await cls._get_or_init_bundle(db, tenant_id=tenant_id) - templates = bundle.get("templates") if isinstance(bundle.get("templates"), list) else [] - target = None - for item in templates: - if not isinstance(item, dict): - continue - if str(item.get("id") or "") == str(template_id or ""): - target = item - break - if target is None: - return None - if name is not None: - target["name"] = str(name or "").strip()[:120] or target.get("name") or "默认模板" - if content is not None: - target["content"] = str(content or "").strip() - if description is not None: - target["description"] = str(description or "").strip()[:300] or None - target["updated_at"] = now_cn_naive().isoformat() - target["updated_by"] = int(operator_user_id) - bundle["templates"] = templates - row.config = bundle - await db.flush() - return dict(target) - - @classmethod - async def list_runs(cls, db: AsyncSession, *, tenant_id: int) -> list[dict[str, Any]]: - _, bundle = await cls._get_or_init_bundle(db, tenant_id=tenant_id) - runs = [dict(item) for item in bundle.get("runs", []) if isinstance(item, dict)] - return cls._sort_by_time_desc(runs, key="generated_at") - - @classmethod - async def generate_brief( - cls, - db: AsyncSession, - *, - tenant_id: int, - operator_user_id: int, - title: str, - template_id: Optional[str], - changed_files: list[str], - scope_summary: Optional[str], - ) -> dict[str, Any]: - row, bundle = await cls._get_or_init_bundle(db, tenant_id=tenant_id) - now_text = now_cn_naive().isoformat() - templates = bundle.get("templates") if isinstance(bundle.get("templates"), list) else [] - template_obj = None - if template_id: - for item in templates: - if isinstance(item, dict) and str(item.get("id") or "") == str(template_id): - template_obj = item - break - if template_obj is None: - template_obj = next((item for item in templates if isinstance(item, dict)), None) - - brief_id = f"brief_{uuid4().hex[:12]}" - run_id = f"run_{uuid4().hex[:12]}" - file_lines = [str(path).strip() for path in changed_files if str(path).strip()] - summary = str(scope_summary or "").strip() or f"共 {len(file_lines)} 个文件发生变更" - risks = ( - [ - "跨文件改动可能引发契约/类型不一致,请补充联动核对。", - "合入前需完成 build/lint/test 并记录结果。", - ] - if file_lines - else ["未提供变更文件列表,风险评估信息不足,请人工补充。"] - ) - content = cls._render_template_content( - template_content=str((template_obj or {}).get("content") or "").strip(), - generated_at=now_text, - base_ref=cls._resolve_git_ref("HEAD~1"), - head_ref=cls._resolve_git_ref("HEAD"), - changed_files=file_lines, - scope_summary=summary, - risks=risks, - ) - - run = { - "id": run_id, - "brief_id": brief_id, - "title": str(title or "").strip()[:160] or "增量任务包", - "template_id": str((template_obj or {}).get("id") or "") or None, - "template_name": str((template_obj or {}).get("name") or "") or None, - "generated_at": now_text, - "generated_by": int(operator_user_id), - "status": "completed", - "changed_files": file_lines, - "scope_summary": summary, - } - brief = { - "id": brief_id, - "title": run["title"], - "status": "ready", - "created_at": now_text, - "updated_at": now_text, - "latest_run_id": run_id, - "template_id": run["template_id"], - "template_name": run["template_name"], - "summary": summary, - "content": content, - "changed_files": file_lines, - } - - runs = bundle.get("runs") if isinstance(bundle.get("runs"), list) else [] - briefs = bundle.get("briefs") if isinstance(bundle.get("briefs"), list) else [] - runs.append(run) - briefs = [item for item in briefs if isinstance(item, dict) and str(item.get("id") or "") != brief_id] - briefs.append(brief) - - bundle["runs"] = runs - bundle["briefs"] = briefs - row.config = bundle - await db.flush() - return {"run": run, "brief": brief} - - @classmethod - async def get_contract_sync_status(cls, db: AsyncSession, *, tenant_id: int) -> dict[str, Any]: - _ = db - _ = tenant_id - root = Path(__file__).resolve().parents[3] - contract_files = [ - root / "docs/architecture/MASTER_CONTRACT.md", - root / "docs/architecture/API_CONTRACTS.md", - root / "docs/architecture/DATAFLOW.md", - ] - statuses = [] - for file_path in contract_files: - exists = file_path.exists() - statuses.append( - { - "file": str(file_path.relative_to(root)), - "exists": exists, - "updated_at": ( - datetime.fromtimestamp(file_path.stat().st_mtime).isoformat() - if exists - else None - ), - } - ) - all_synced = all(bool(item.get("exists")) for item in statuses) - return { - "all_synced": all_synced, - "files": statuses, - } +__all__ = ["TaskBriefService"] diff --git a/backend/app/services/task_persistence.py b/backend/app/services/task_persistence.py index c753ee5a..b2d8f780 100644 --- a/backend/app/services/task_persistence.py +++ b/backend/app/services/task_persistence.py @@ -1,325 +1,35 @@ -"""共享任务持久化规范。""" +"""Compatibility exports for task persistence helpers.""" -from __future__ import annotations +from .task_persistence_core import ( + SUPPORTED_TASK_STATUSES, + TASK_PRIORITY_MAP, + TASK_STATUS_MAP, + SkillTagResolution, + TaskCreateDraft, + build_task_create_draft, + normalize_due_date, + normalize_estimated_hours, + normalize_int_list, + normalize_skill_names, + normalize_task_priority, + normalize_task_status, + persist_task, + resolve_skill_tag_ids_by_names, +) -from dataclasses import dataclass, field -from datetime import datetime, timezone -from typing import Any, Optional - -from sqlalchemy import and_, select -from sqlalchemy.ext.asyncio import AsyncSession - -from ..models import SkillTag, Task -from ..utils.title import compact_title -from . import workload_service -from .operation_log_service import log_operation - -from ..utils.timezone import CN_TZ -TASK_PRIORITY_MAP = { - "p0": "high", - "critical": "high", - "high": "high", - "p1": "medium", - "medium": "medium", - "normal": "medium", - "default": "medium", - "p2": "low", - "p3": "low", - "low": "low", -} -TASK_STATUS_MAP = { - "pending": "todo", - "todo": "todo", - "open": "todo", - "new": "todo", - "in_progress": "in_progress", - "doing": "in_progress", - "processing": "in_progress", - "review": "review", - "qa": "review", - "testing": "review", - "done": "done", - "completed": "done", - "blocked": "blocked", - "closed": "closed", -} -SUPPORTED_TASK_STATUSES = {"todo", "in_progress", "review", "done", "blocked", "closed"} - - -@dataclass(slots=True) -class SkillTagResolution: - input_names: list[str] - matched_ids: list[int] - matched_items: list[dict[str, Any]] - unmatched_names: list[str] - - -@dataclass(slots=True) -class TaskCreateDraft: - title: str - description: Optional[str] = None - assignee_id: Optional[int] = None - required_skill_tag_ids: list[int] = field(default_factory=list) - priority: str = "medium" - status: str = "todo" - estimated_hours: Optional[float] = None - due_date: Optional[datetime] = None - actual_hours: Optional[float] = None - quality_score: Optional[float] = None - ai_recommended_assignees: Optional[dict[str, Any]] = None - - -def normalize_task_priority(value: Any, *, default: str = "medium") -> str: - key = str(value or default).strip().lower() - return TASK_PRIORITY_MAP.get(key, TASK_PRIORITY_MAP.get(default, "medium")) - - -def normalize_task_status(value: Any, *, default: str = "todo") -> str: - key = str(value or default).strip().lower() - normalized = TASK_STATUS_MAP.get(key, TASK_STATUS_MAP.get(default, "todo")) - if normalized not in SUPPORTED_TASK_STATUSES: - return default - return normalized - - -def normalize_estimated_hours(value: Any) -> Optional[float]: - if value is None or value == "": - return None - try: - hours = round(float(value), 2) - except (TypeError, ValueError) as exc: - raise ValueError("invalid estimated_hours") from exc - if hours < 0 or hours > 999.99: - raise ValueError("invalid estimated_hours") - return hours - - -def normalize_due_date(value: Any) -> Optional[datetime]: - if value is None or value == "": - return None - if isinstance(value, datetime): - if value.tzinfo is not None: - return value.astimezone(CN_TZ).replace(tzinfo=None) - return value - text = str(value).strip() - if not text: - return None - try: - return datetime.fromisoformat(text.replace("Z", "+00:00")).replace(tzinfo=None) - except ValueError as exc: - raise ValueError("invalid due_date") from exc - - -def normalize_int_list(values: Any) -> list[int]: - if values is None: - return [] - if not isinstance(values, (list, tuple, set)): - values = [values] - normalized: list[int] = [] - seen: set[int] = set() - for raw in values: - if isinstance(raw, bool): - continue - try: - value = int(str(raw).strip()) - except (TypeError, ValueError): - continue - if value <= 0 or value in seen: - continue - seen.add(value) - normalized.append(value) - return normalized - - -def normalize_skill_names(values: Any) -> list[str]: - if values is None: - return [] - if not isinstance(values, (list, tuple, set)): - values = [values] - normalized: list[str] = [] - seen: set[str] = set() - for raw in values: - text = str(raw or "").strip() - if not text: - continue - key = text.lower() - if key in seen: - continue - seen.add(key) - normalized.append(text[:120]) - return normalized - - -def build_task_create_draft( - *, - title: str, - description: Optional[str] = None, - assignee_id: Optional[int] = None, - required_skill_tag_ids: Any = None, - priority: Any = "medium", - status: Any = "todo", - estimated_hours: Any = None, - due_date: Any = None, - actual_hours: Any = None, - quality_score: Any = None, - ai_recommended_assignees: Optional[dict[str, Any]] = None, -) -> TaskCreateDraft: - normalized_title = compact_title(str(title or "").strip(), max_len=32) - if not normalized_title: - raise ValueError("task title is empty") - return TaskCreateDraft( - title=normalized_title, - description=str(description or "").strip() or None, - assignee_id=int(assignee_id) if assignee_id is not None else None, - required_skill_tag_ids=normalize_int_list(required_skill_tag_ids), - priority=normalize_task_priority(priority), - status=normalize_task_status(status), - estimated_hours=normalize_estimated_hours(estimated_hours), - due_date=normalize_due_date(due_date), - actual_hours=normalize_estimated_hours(actual_hours), - quality_score=None if quality_score in {None, ""} else float(quality_score), - ai_recommended_assignees=ai_recommended_assignees, - ) - - -async def resolve_skill_tag_ids_by_names( - db: AsyncSession, - *, - tenant_id: int, - skill_names: Any, -) -> SkillTagResolution: - input_names = normalize_skill_names(skill_names) - if not input_names: - return SkillTagResolution( - input_names=[], - matched_ids=[], - matched_items=[], - unmatched_names=[], - ) - - rows = ( - await db.execute( - select(SkillTag).where(SkillTag.tenant_id == int(tenant_id)) - ) - ).scalars().all() - if not rows: - return SkillTagResolution( - input_names=input_names, - matched_ids=[], - matched_items=[], - unmatched_names=input_names, - ) - - exact: dict[str, SkillTag] = {} - fuzzy: list[tuple[str, SkillTag]] = [] - for row in rows: - name = str(row.name or "").strip().lower() - slug = str(row.slug or "").strip().lower() - if name: - exact[name] = row - fuzzy.append((name, row)) - if slug: - exact[slug] = row - fuzzy.append((slug, row)) - - matched_ids: list[int] = [] - matched_items: list[dict[str, Any]] = [] - unmatched_names: list[str] = [] - seen_ids: set[int] = set() - for name in input_names: - key = name.lower() - matched = exact.get(key) - if matched is None: - for candidate, row in fuzzy: - if key in candidate or candidate in key: - matched = row - break - if matched is None: - unmatched_names.append(name) - continue - skill_id = int(matched.id) - if skill_id not in seen_ids: - seen_ids.add(skill_id) - matched_ids.append(skill_id) - matched_items.append( - { - "input_name": name, - "skill_tag_id": skill_id, - "skill_tag_name": str(matched.name or "").strip(), - "skill_tag_slug": str(matched.slug or "").strip(), - } - ) - - return SkillTagResolution( - input_names=input_names, - matched_ids=matched_ids, - matched_items=matched_items, - unmatched_names=unmatched_names, - ) - - -async def persist_task( - db: AsyncSession, - *, - tenant_id: int, - truth_node_id: int, - project_id: int, - draft: TaskCreateDraft, - actor_user_id: Optional[int] = None, - action: str = "task.create", - remark: Optional[str] = None, - commit: bool = False, - refresh_workload: bool = False, -) -> Task: - task = Task( - tenant_id=int(tenant_id), - truth_node_id=int(truth_node_id), - project_id=int(project_id), - assignee_id=draft.assignee_id, - title=draft.title, - description=draft.description, - required_skill_tag_ids=draft.required_skill_tag_ids, - status=draft.status, - priority=draft.priority, - estimated_hours=draft.estimated_hours, - actual_hours=draft.actual_hours, - quality_score=draft.quality_score, - due_date=draft.due_date, - ai_recommended_assignees=draft.ai_recommended_assignees, - ) - db.add(task) - await db.flush() - - if actor_user_id is not None: - await log_operation( - db, - tenant_id=int(tenant_id), - user_id=int(actor_user_id), - project_id=int(project_id), - entity_type="task", - entity_id=str(task.id), - action=action, - before_data=None, - after_data={ - "title": task.title, - "truth_node_id": int(truth_node_id), - "priority": task.priority, - "status": task.status, - "assignee_id": int(task.assignee_id) if task.assignee_id is not None else None, - "required_skill_tag_ids": list(task.required_skill_tag_ids or []), - "estimated_hours": float(task.estimated_hours) if task.estimated_hours is not None else None, - "due_date": task.due_date.isoformat() if task.due_date else None, - }, - remark=remark, - ) - - if refresh_workload and task.assignee_id is not None: - await workload_service.refresh_user_assigned_hours_from_tasks( - db, - tenant_id=int(tenant_id), - user_id=int(task.assignee_id), - ) - elif commit: - await db.commit() - - return task +__all__ = [ + "TASK_PRIORITY_MAP", + "TASK_STATUS_MAP", + "SUPPORTED_TASK_STATUSES", + "SkillTagResolution", + "TaskCreateDraft", + "normalize_task_priority", + "normalize_task_status", + "normalize_estimated_hours", + "normalize_due_date", + "normalize_int_list", + "normalize_skill_names", + "build_task_create_draft", + "resolve_skill_tag_ids_by_names", + "persist_task", +] diff --git a/backend/app/services/task_persistence_core/__init__.py b/backend/app/services/task_persistence_core/__init__.py new file mode 100644 index 00000000..2b5c180b --- /dev/null +++ b/backend/app/services/task_persistence_core/__init__.py @@ -0,0 +1,32 @@ +"""Task persistence exports.""" + +from .models import SkillTagResolution, TaskCreateDraft +from .normalize import ( + build_task_create_draft, + normalize_due_date, + normalize_estimated_hours, + normalize_int_list, + normalize_skill_names, + normalize_task_priority, + normalize_task_status, +) +from .persist import persist_task +from .skill_resolution import resolve_skill_tag_ids_by_names +from .shared import TASK_PRIORITY_MAP, TASK_STATUS_MAP, SUPPORTED_TASK_STATUSES + +__all__ = [ + "TASK_PRIORITY_MAP", + "TASK_STATUS_MAP", + "SUPPORTED_TASK_STATUSES", + "SkillTagResolution", + "TaskCreateDraft", + "normalize_task_priority", + "normalize_task_status", + "normalize_estimated_hours", + "normalize_due_date", + "normalize_int_list", + "normalize_skill_names", + "build_task_create_draft", + "resolve_skill_tag_ids_by_names", + "persist_task", +] diff --git a/backend/app/services/task_persistence_core/models.py b/backend/app/services/task_persistence_core/models.py new file mode 100644 index 00000000..bd4e0d8c --- /dev/null +++ b/backend/app/services/task_persistence_core/models.py @@ -0,0 +1,31 @@ +"""Dataclasses for task persistence.""" + +from __future__ import annotations + +from .shared import Any, Optional, dataclass, datetime, field + + +@dataclass(slots=True) +class SkillTagResolution: + input_names: list[str] + matched_ids: list[int] + matched_items: list[dict[str, Any]] + unmatched_names: list[str] + + +@dataclass(slots=True) +class TaskCreateDraft: + title: str + description: Optional[str] = None + assignee_id: Optional[int] = None + required_skill_tag_ids: list[int] = field(default_factory=list) + priority: str = "medium" + status: str = "todo" + estimated_hours: Optional[float] = None + due_date: Optional[datetime] = None + actual_hours: Optional[float] = None + quality_score: Optional[float] = None + ai_recommended_assignees: Optional[dict[str, Any]] = None + + +__all__ = ["SkillTagResolution", "TaskCreateDraft"] diff --git a/backend/app/services/task_persistence_core/normalize.py b/backend/app/services/task_persistence_core/normalize.py new file mode 100644 index 00000000..fbe8882b --- /dev/null +++ b/backend/app/services/task_persistence_core/normalize.py @@ -0,0 +1,130 @@ +"""Normalization helpers for task persistence.""" + +from __future__ import annotations + +from .models import TaskCreateDraft +from .shared import Any, CN_TZ, SUPPORTED_TASK_STATUSES, TASK_PRIORITY_MAP, TASK_STATUS_MAP, compact_title, datetime + + +def normalize_task_priority(value: Any, *, default: str = "medium") -> str: + key = str(value or default).strip().lower() + return TASK_PRIORITY_MAP.get(key, TASK_PRIORITY_MAP.get(default, "medium")) + + +def normalize_task_status(value: Any, *, default: str = "todo") -> str: + key = str(value or default).strip().lower() + normalized = TASK_STATUS_MAP.get(key, TASK_STATUS_MAP.get(default, "todo")) + if normalized not in SUPPORTED_TASK_STATUSES: + return default + return normalized + + +def normalize_estimated_hours(value: Any) -> float | None: + if value is None or value == "": + return None + try: + hours = round(float(value), 2) + except (TypeError, ValueError) as exc: + raise ValueError("invalid estimated_hours") from exc + if hours < 0 or hours > 999.99: + raise ValueError("invalid estimated_hours") + return hours + + +def normalize_due_date(value: Any) -> datetime | None: + if value is None or value == "": + return None + if isinstance(value, datetime): + if value.tzinfo is not None: + return value.astimezone(CN_TZ).replace(tzinfo=None) + return value + text = str(value).strip() + if not text: + return None + try: + return datetime.fromisoformat(text.replace("Z", "+00:00")).replace(tzinfo=None) + except ValueError as exc: + raise ValueError("invalid due_date") from exc + + +def normalize_int_list(values: Any) -> list[int]: + if values is None: + return [] + if not isinstance(values, (list, tuple, set)): + values = [values] + normalized: list[int] = [] + seen: set[int] = set() + for raw in values: + if isinstance(raw, bool): + continue + try: + value = int(str(raw).strip()) + except (TypeError, ValueError): + continue + if value <= 0 or value in seen: + continue + seen.add(value) + normalized.append(value) + return normalized + + +def normalize_skill_names(values: Any) -> list[str]: + if values is None: + return [] + if not isinstance(values, (list, tuple, set)): + values = [values] + normalized: list[str] = [] + seen: set[str] = set() + for raw in values: + text = str(raw or "").strip() + if not text: + continue + key = text.lower() + if key in seen: + continue + seen.add(key) + normalized.append(text[:120]) + return normalized + + +def build_task_create_draft( + *, + title: str, + description: str | None = None, + assignee_id: int | None = None, + required_skill_tag_ids: Any = None, + priority: Any = "medium", + status: Any = "todo", + estimated_hours: Any = None, + due_date: Any = None, + actual_hours: Any = None, + quality_score: Any = None, + ai_recommended_assignees: dict[str, Any] | None = None, +) -> TaskCreateDraft: + normalized_title = compact_title(str(title or "").strip(), max_len=32) + if not normalized_title: + raise ValueError("task title is empty") + return TaskCreateDraft( + title=normalized_title, + description=str(description or "").strip() or None, + assignee_id=int(assignee_id) if assignee_id is not None else None, + required_skill_tag_ids=normalize_int_list(required_skill_tag_ids), + priority=normalize_task_priority(priority), + status=normalize_task_status(status), + estimated_hours=normalize_estimated_hours(estimated_hours), + due_date=normalize_due_date(due_date), + actual_hours=normalize_estimated_hours(actual_hours), + quality_score=None if quality_score in {None, ""} else float(quality_score), + ai_recommended_assignees=ai_recommended_assignees, + ) + + +__all__ = [ + "normalize_task_priority", + "normalize_task_status", + "normalize_estimated_hours", + "normalize_due_date", + "normalize_int_list", + "normalize_skill_names", + "build_task_create_draft", +] diff --git a/backend/app/services/task_persistence_core/persist.py b/backend/app/services/task_persistence_core/persist.py new file mode 100644 index 00000000..5e707f0a --- /dev/null +++ b/backend/app/services/task_persistence_core/persist.py @@ -0,0 +1,73 @@ +"""Task persistence entrypoint.""" + +from __future__ import annotations + +from .models import TaskCreateDraft +from .shared import AsyncSession, Optional, Task, log_operation, workload_service + + +async def persist_task( + db: AsyncSession, + *, + tenant_id: int, + truth_node_id: int, + project_id: int, + draft: TaskCreateDraft, + actor_user_id: Optional[int] = None, + action: str = "task.create", + remark: Optional[str] = None, + commit: bool = False, + refresh_workload: bool = False, +) -> Task: + task = Task( + tenant_id=int(tenant_id), + truth_node_id=int(truth_node_id), + project_id=int(project_id), + assignee_id=draft.assignee_id, + title=draft.title, + description=draft.description, + required_skill_tag_ids=draft.required_skill_tag_ids, + status=draft.status, + priority=draft.priority, + estimated_hours=draft.estimated_hours, + actual_hours=draft.actual_hours, + quality_score=draft.quality_score, + due_date=draft.due_date, + ai_recommended_assignees=draft.ai_recommended_assignees, + ) + db.add(task) + await db.flush() + + if actor_user_id is not None: + await log_operation( + db, + tenant_id=int(tenant_id), + user_id=int(actor_user_id), + project_id=int(project_id), + entity_type="task", + entity_id=str(task.id), + action=action, + before_data=None, + after_data={ + "title": task.title, + "truth_node_id": int(truth_node_id), + "priority": task.priority, + "status": task.status, + "assignee_id": int(task.assignee_id) if task.assignee_id is not None else None, + "required_skill_tag_ids": list(task.required_skill_tag_ids or []), + "estimated_hours": float(task.estimated_hours) if task.estimated_hours is not None else None, + "due_date": task.due_date.isoformat() if task.due_date else None, + }, + remark=remark, + ) + + if refresh_workload and task.assignee_id is not None: + await workload_service.refresh_user_assigned_hours_from_tasks( + db, + tenant_id=int(tenant_id), + user_id=int(task.assignee_id), + ) + elif commit: + await db.commit() + + return task diff --git a/backend/app/services/task_persistence_core/shared.py b/backend/app/services/task_persistence_core/shared.py new file mode 100644 index 00000000..28bcc3b4 --- /dev/null +++ b/backend/app/services/task_persistence_core/shared.py @@ -0,0 +1,67 @@ +"""Shared imports/constants for task persistence.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Optional + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...models import SkillTag, Task +from ...utils.title import compact_title +from ...utils.timezone import CN_TZ +from .. import workload_service +from ..operation_log_service import log_operation + +TASK_PRIORITY_MAP = { + "p0": "high", + "critical": "high", + "high": "high", + "p1": "medium", + "medium": "medium", + "normal": "medium", + "default": "medium", + "p2": "low", + "p3": "low", + "low": "low", +} +TASK_STATUS_MAP = { + "pending": "todo", + "todo": "todo", + "open": "todo", + "new": "todo", + "in_progress": "in_progress", + "doing": "in_progress", + "processing": "in_progress", + "review": "review", + "qa": "review", + "testing": "review", + "done": "done", + "completed": "done", + "blocked": "blocked", + "closed": "closed", +} +SUPPORTED_TASK_STATUSES = {"todo", "in_progress", "review", "done", "blocked", "closed"} + +__all__ = [ + "Any", + "AsyncSession", + "CN_TZ", + "Optional", + "SUPPORTED_TASK_STATUSES", + "SkillTag", + "TASK_PRIORITY_MAP", + "TASK_STATUS_MAP", + "Task", + "and_", + "compact_title", + "dataclass", + "datetime", + "field", + "log_operation", + "select", + "timezone", + "workload_service", +] diff --git a/backend/app/services/task_persistence_core/skill_resolution.py b/backend/app/services/task_persistence_core/skill_resolution.py new file mode 100644 index 00000000..dd2ea53a --- /dev/null +++ b/backend/app/services/task_persistence_core/skill_resolution.py @@ -0,0 +1,79 @@ +"""Skill tag resolution helpers for task persistence.""" + +from __future__ import annotations + +from .models import SkillTagResolution +from .normalize import normalize_skill_names +from .shared import Any, AsyncSession, SkillTag, select + + +async def resolve_skill_tag_ids_by_names( + db: AsyncSession, + *, + tenant_id: int, + skill_names: Any, +) -> SkillTagResolution: + input_names = normalize_skill_names(skill_names) + if not input_names: + return SkillTagResolution( + input_names=[], + matched_ids=[], + matched_items=[], + unmatched_names=[], + ) + + rows = (await db.execute(select(SkillTag).where(SkillTag.tenant_id == int(tenant_id)))).scalars().all() + if not rows: + return SkillTagResolution( + input_names=input_names, + matched_ids=[], + matched_items=[], + unmatched_names=input_names, + ) + + exact: dict[str, SkillTag] = {} + fuzzy: list[tuple[str, SkillTag]] = [] + for row in rows: + name = str(row.name or "").strip().lower() + slug = str(row.slug or "").strip().lower() + if name: + exact[name] = row + fuzzy.append((name, row)) + if slug: + exact[slug] = row + fuzzy.append((slug, row)) + + matched_ids: list[int] = [] + matched_items: list[dict[str, Any]] = [] + unmatched_names: list[str] = [] + seen_ids: set[int] = set() + for name in input_names: + key = name.lower() + matched = exact.get(key) + if matched is None: + for candidate, row in fuzzy: + if key in candidate or candidate in key: + matched = row + break + if matched is None: + unmatched_names.append(name) + continue + skill_id = int(matched.id) + if skill_id not in seen_ids: + seen_ids.add(skill_id) + matched_ids.append(skill_id) + matched_items.append( + { + "input_name": name, + "skill_tag_id": skill_id, + "skill_tag_name": str(matched.name or "").strip(), + "skill_tag_slug": str(matched.slug or "").strip(), + } + ) + + return SkillTagResolution( + input_names=input_names, + matched_ids=matched_ids, + matched_items=matched_items, + unmatched_names=unmatched_names, + ) diff --git a/backend/app/services/workload/__init__.py b/backend/app/services/workload/__init__.py new file mode 100644 index 00000000..9d9e5dee --- /dev/null +++ b/backend/app/services/workload/__init__.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from .ai_optimize import * +from .board_queries_a import * +from .board_queries_b import * +from .core_ratio import * + +__all__ = [ + "remaining_workdays_this_week", + "calculate_workload_ratio", + "ensure_user_workload", + "recalculate_user_workload_ratio", + "recalculate_all_users_workload_ratio", + "list_tenant_ids", + "get_team_workload_board", + "get_user_personal_workload", + "update_user_max_weekly_hours", + "list_user_assigned_tasks", + "get_user_workload_trend", + "refresh_user_assigned_hours_from_tasks", + "build_ai_optimize_preview", + "apply_ai_optimize_plan", +] diff --git a/backend/app/services/workload/ai_optimize.py b/backend/app/services/workload/ai_optimize.py new file mode 100644 index 00000000..df7ac7c7 --- /dev/null +++ b/backend/app/services/workload/ai_optimize.py @@ -0,0 +1,292 @@ +from __future__ import annotations + +from .shared import * + +def _skill_match_score(task_skill_ids: list[int], user_skill_ids: set[int]) -> float: + if not task_skill_ids: + return 0.0 + matched = sum(1 for sid in task_skill_ids if sid in user_skill_ids) + return matched / len(task_skill_ids) + +def _safe_int_list(values: object) -> list[int]: + """兼容历史脏数据:数组中可能混入技能名称等非数字值。""" + source = values + if source is None: + return [] + if isinstance(source, str): + text = source.strip() + if not text: + return [] + try: + source = json.loads(text) + except (TypeError, ValueError, json.JSONDecodeError): + return [] + if not isinstance(source, list): + return [] + + parsed: list[int] = [] + for item in source: + if item is None or isinstance(item, bool): + continue + if isinstance(item, int): + parsed.append(item) + continue + if isinstance(item, float): + if item.is_integer(): + parsed.append(int(item)) + continue + if isinstance(item, str): + text = item.strip() + if text and text.lstrip("-").isdigit(): + parsed.append(int(text)) + return parsed + +def _parse_json_response(text: str) -> dict: + body = (text or "").strip() + if body.startswith("```json"): + body = body[7:].strip() + if body.startswith("```"): + body = body[3:].strip() + if body.endswith("```"): + body = body[:-3].strip() + return json.loads(body) + +async def build_ai_optimize_preview( + db: AsyncSession, + tenant_id: int, + *, + project_id: Optional[int] = None, + max_moves: int = 20, + unassigned_only: bool = False, +) -> list[dict]: + """生成任务重分配建议(预览,不落库)。""" + task_query = select(Task).where( + and_( + Task.tenant_id == tenant_id, + func.lower(Task.status).notin_(["done", "completed", "closed"]), + ) + ) + if unassigned_only: + task_query = task_query.where(Task.assignee_id.is_(None)) + if project_id is not None: + task_query = task_query.where(Task.project_id == project_id) + + task_rows = (await db.execute(task_query.order_by(Task.updated_at.desc()))).scalars().all() + if not task_rows: + return [] + + active_users_query = select(User).where( + and_( + User.tenant_id == tenant_id, + User.status == "active", + User.is_active.is_(True), + ) + ) + users = (await db.execute(active_users_query)).scalars().all() + if not users: + return [] + + workload_rows = ( + await db.execute( + select(UserWorkload).where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id.in_([int(u.id) for u in users]), + ) + ) + ) + ).scalars().all() + workload_map = {int(w.user_id): float(w.workload_ratio or 0.0) for w in workload_rows} + + skill_rows = ( + await db.execute( + select(UserSkillBinding.user_id, UserSkillBinding.skill_tag_id).where( + UserSkillBinding.tenant_id == tenant_id + ) + ) + ).all() + user_skill_map: dict[int, set[int]] = {} + for uid, sid in skill_rows: + user_skill_map.setdefault(int(uid), set()).add(int(sid)) + + users_by_project: dict[int, list[User]] = {} + for task in task_rows: + if int(task.project_id) in users_by_project: + continue + links = ( + await db.execute( + select(UserProject.user_id).where( + and_( + UserProject.tenant_id == tenant_id, + UserProject.project_id == int(task.project_id), + ) + ) + ) + ).scalars().all() + allowed_ids = {int(uid) for uid in links} + users_by_project[int(task.project_id)] = [u for u in users if int(u.id) in allowed_ids] + + llm_suggestion_map: dict[int, dict] = {} + members_payload = [] + for user in users: + uid = int(user.id) + members_payload.append( + { + "user_id": uid, + "user_name": user.name, + "workload_ratio": round(workload_map.get(uid, float(user.workload or 0)), 2), + "skill_tag_ids": sorted(list(user_skill_map.get(uid, set()))), + } + ) + tasks_payload = [ + { + "task_id": int(task.id), + "project_id": int(task.project_id), + "title": task.title, + "assignee_id": int(task.assignee_id) if task.assignee_id else None, + "required_skill_tag_ids": _safe_int_list(task.required_skill_tag_ids), + "estimated_hours": float(task.estimated_hours or 0), + "status": task.status, + } + for task in task_rows + ] + llm_prompt = PROMPT_WORKLOAD_OPTIMIZATION.format( + members_json=json.dumps(members_payload, ensure_ascii=False), + tasks_json=json.dumps(tasks_payload, ensure_ascii=False), + ) + llm_text = "" + llm_status = "success" + try: + llm_text = await llm_service.generate( + llm_prompt, + function_name="workload_optimize_prompt", + tenant_id=tenant_id, + ) + parsed = _parse_json_response(llm_text) + for item in parsed.get("proposals", []) if isinstance(parsed, dict) else []: + try: + tid = int(item.get("task_id")) + llm_suggestion_map[tid] = { + "new_assignee_id": int(item.get("new_assignee_id")), + "reason": str(item.get("reason") or "llm_suggested"), + } + except (TypeError, ValueError): + continue + except Exception as exc: + llm_status = "failed" + llm_text = str(exc) + + db.add( + AICallLog( + tenant_id=tenant_id, + user_id=None, + function_name="workload_optimize_prompt", + prompt=llm_prompt, + response=(llm_text or "")[:8000], + tokens_used=max(len(llm_prompt) // 4, 1) + max(len(llm_text) // 4, 0), + cost=((max(len(llm_prompt) // 4, 1) * 0.00015 + max(len(llm_text) // 4, 0) * 0.0006) / 1000), + status=llm_status, + ) + ) + await db.commit() + + proposals: list[dict] = [] + for task in task_rows: + current_assignee = int(task.assignee_id or 0) + is_unassigned = current_assignee <= 0 + candidates = users_by_project.get(int(task.project_id), []) + if not candidates: + continue + + required_skill_ids = _safe_int_list(task.required_skill_tag_ids) + ranked = [] + llm_target_id = llm_suggestion_map.get(int(task.id), {}).get("new_assignee_id") + llm_reason = llm_suggestion_map.get(int(task.id), {}).get("reason") + for candidate in candidates: + uid = int(candidate.id) + if not is_unassigned and uid == current_assignee: + continue + ratio = workload_map.get(uid, float(candidate.workload or 0)) + skill_score = _skill_match_score(required_skill_ids, user_skill_map.get(uid, set())) + # workload 越低越优,skill_score 越高越优 + total_score = (skill_score * 0.7) + ((max(0.0, 120.0 - ratio) / 120.0) * 0.3) + if llm_target_id and uid == int(llm_target_id): + total_score += 0.2 + ranked.append((total_score, ratio, candidate)) + + ranked.sort(key=lambda x: (-x[0], x[1])) + if not ranked: + continue + + best_score, _best_ratio, best_user = ranked[0] + current_ratio = workload_map.get(current_assignee, 0.0) if not is_unassigned else 0.0 + if is_unassigned: + if best_score < 0.2: + continue + reason = f"initial_assignment_score={best_score:.2f}" + else: + if current_ratio <= 100 and best_score < 0.5: + continue + reason = f"skill_match+workload_score={best_score:.2f}" + if llm_target_id and int(best_user.id) == int(llm_target_id): + reason = f"{reason};llm={llm_reason or 'suggested'}" + + proposals.append( + { + "task_id": int(task.id), + "task_title": task.title, + "project_id": int(task.project_id), + "old_assignee_id": current_assignee if not is_unassigned else None, + "new_assignee_id": int(best_user.id), + "old_ratio": round(current_ratio, 2) if not is_unassigned else None, + "new_ratio": round(workload_map.get(int(best_user.id), 0.0), 2), + "reason": reason, + } + ) + if len(proposals) >= max_moves: + break + + return proposals + +async def apply_ai_optimize_plan( + db: AsyncSession, + tenant_id: int, + proposals: list[dict], +) -> dict: + """应用优化方案并重算涉及成员负载。""" + touched_user_ids: set[int] = set() + applied = 0 + + for item in proposals: + task_id = int(item.get("task_id", 0)) + new_assignee_id = int(item.get("new_assignee_id", 0)) + if task_id <= 0 or new_assignee_id <= 0: + continue + + task = ( + await db.execute( + select(Task).where( + and_( + Task.tenant_id == tenant_id, + Task.id == task_id, + ) + ) + ) + ).scalar_one_or_none() + if not task: + continue + + if task.assignee_id: + touched_user_ids.add(int(task.assignee_id)) + task.assignee_id = new_assignee_id + touched_user_ids.add(new_assignee_id) + applied += 1 + + if touched_user_ids: + for uid in touched_user_ids: + await refresh_user_assigned_hours_from_tasks(db, tenant_id, uid) + else: + await db.commit() + + return {"applied_count": applied, "affected_users": len(touched_user_ids)} + diff --git a/backend/app/services/workload/board_queries_a.py b/backend/app/services/workload/board_queries_a.py new file mode 100644 index 00000000..e31ebe34 --- /dev/null +++ b/backend/app/services/workload/board_queries_a.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +from .shared import * + +async def get_team_workload_board( + db: AsyncSession, + tenant_id: int = 1, + project_id: Optional[int] = None, +) -> list[dict]: + """团队负载看板数据。""" + query = ( + select(UserWorkload, User) + .join(User, UserWorkload.user_id == User.id) + .where( + and_( + UserWorkload.tenant_id == tenant_id, + User.tenant_id == tenant_id, + ) + ) + ) + if project_id is not None: + query = query.join( + UserProject, + and_( + UserProject.tenant_id == tenant_id, + UserProject.user_id == User.id, + ), + ).where(UserProject.project_id == project_id) + + query = query.order_by(UserWorkload.workload_ratio.desc(), User.id.asc()) + rows = (await db.execute(query)).all() + + return [ + { + "user_id": str(w.user_id), + "user_name": u.name, + "username": u.username, + "role": u.role, + "is_active": u.is_active, + "current_assigned_hours": float(w.current_assigned_hours or 0), + "max_weekly_hours": float(w.max_available_hours_per_week or 40), + "workload_ratio": float(w.workload_ratio or 0), + "remaining_hours": round( + max( + 0.0, + float(w.max_available_hours_per_week or 40) + - float(w.current_assigned_hours or 0), + ), + 2, + ), + "updated_at": w.updated_at.isoformat() if w.updated_at else None, + } + for w, u in rows + ] + +async def get_user_personal_workload( + db: AsyncSession, + tenant_id: int, + user_id: int, +) -> Optional[dict]: + """个人负载详情。""" + row = ( + await db.execute( + select(UserWorkload, User) + .join(User, UserWorkload.user_id == User.id) + .where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id == user_id, + User.tenant_id == tenant_id, + ) + ) + ) + ).first() + if row: + w, u = row + return { + "user_id": str(u.id), + "user_name": u.name, + "username": u.username, + "is_active": u.is_active, + "current_assigned_hours": float(w.current_assigned_hours or 0), + "max_weekly_hours": float(w.max_available_hours_per_week or 40), + "workload_ratio": float(w.workload_ratio or 0), + "remaining_hours": round( + max( + 0.0, + float(w.max_available_hours_per_week or 40) + - float(w.current_assigned_hours or 0), + ), + 2, + ), + "updated_at": w.updated_at.isoformat() if w.updated_at else None, + } + + user = ( + await db.execute( + select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) + ) + ).scalar_one_or_none() + if not user: + return None + + workload = await ensure_user_workload(db, tenant_id, user_id) + await db.commit() + return { + "user_id": str(user.id), + "user_name": user.name, + "username": user.username, + "is_active": user.is_active, + "current_assigned_hours": float(workload.current_assigned_hours or 0), + "max_weekly_hours": float(workload.max_available_hours_per_week or 40), + "workload_ratio": float(workload.workload_ratio or 0), + "remaining_hours": round( + max( + 0.0, + float(workload.max_available_hours_per_week or 40) + - float(workload.current_assigned_hours or 0), + ), + 2, + ), + "updated_at": workload.updated_at.isoformat() if workload.updated_at else None, + } + +async def update_user_max_weekly_hours( + db: AsyncSession, + tenant_id: int, + user_id: int, + max_weekly_hours: float, +) -> Optional[dict]: + """更新每周可用工时并重算负载率。""" + user = ( + await db.execute( + select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) + ) + ).scalar_one_or_none() + if not user: + return None + + workload = await ensure_user_workload(db, tenant_id, user_id) + workload.max_available_hours_per_week = max(1.0, float(max_weekly_hours)) + await recalculate_user_workload_ratio(db, tenant_id, user_id, commit=False) + await db.commit() + await db.refresh(workload) + + return { + "user_id": str(user.id), + "max_weekly_hours": float(workload.max_available_hours_per_week), + "workload_ratio": float(workload.workload_ratio or 0), + } + +async def list_user_assigned_tasks( + db: AsyncSession, + tenant_id: int, + user_id: int, + *, + status: Optional[str] = None, + priority: Optional[str] = None, +) -> list[dict]: + """查询用户任务列表。""" + query = select(Task).where( + and_( + Task.tenant_id == tenant_id, + Task.assignee_id == user_id, + ) + ) + if status: + query = query.where(Task.status == status) + if priority: + query = query.where(Task.priority == priority) + + rows = (await db.execute(query.order_by(Task.updated_at.desc()))).scalars().all() + return [ + { + "id": str(t.id), + "title": t.title, + "project_id": str(t.project_id), + "priority": t.priority, + "status": t.status, + "estimated_hours": float(t.estimated_hours or 0), + "due_date": t.due_date.isoformat() if t.due_date else None, + } + for t in rows + ] + diff --git a/backend/app/services/workload/board_queries_b.py b/backend/app/services/workload/board_queries_b.py new file mode 100644 index 00000000..b47f9320 --- /dev/null +++ b/backend/app/services/workload/board_queries_b.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from .shared import * + +async def get_user_workload_trend( + db: AsyncSession, + tenant_id: int, + user_id: int, + days: int = 7, +) -> list[dict]: + """ + 用户负载趋势。 + + 当前无历史快照表时,返回最近 N 天的平滑序列用于前端趋势图展示。 + """ + workload = ( + await db.execute( + select(UserWorkload).where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id == user_id, + ) + ) + ) + ).scalar_one_or_none() + ratio_now = float(workload.workload_ratio or 0) if workload else 0.0 + + days = max(1, min(days, 30)) + start = date.today() - timedelta(days=days - 1) + series: list[dict] = [] + for idx in range(days): + current_day = start + timedelta(days=idx) + # 缺少历史表时做线性平滑,保留可视化可用性。 + ratio = round(max(0.0, ratio_now * (0.92 + 0.08 * (idx + 1) / days)), 2) + series.append( + { + "date": current_day.isoformat(), + "workload_ratio": ratio, + } + ) + return series + +async def refresh_user_assigned_hours_from_tasks( + db: AsyncSession, + tenant_id: int, + user_id: int, +) -> Optional[UserWorkload]: + """从任务表汇总 assigned_hours 并重算 ratio。""" + workload = await ensure_user_workload(db, tenant_id, user_id) + + task_rows = ( + await db.execute( + select(Task).where( + and_( + Task.tenant_id == tenant_id, + Task.assignee_id == user_id, + func.lower(Task.status).notin_(["done", "completed", "closed"]), + ) + ) + ) + ).scalars().all() + + skill_rows = ( + await db.execute( + select( + UserSkillBinding.skill_tag_id, + UserSkillBinding.level, + UserSkillBinding.proficiency_level, + UserSkillBinding.proficiency, + ).where( + and_( + UserSkillBinding.tenant_id == tenant_id, + UserSkillBinding.user_id == user_id, + ) + ) + ) + ).all() + + def normalize_level( + level: Optional[str], + proficiency_level: Optional[str], + proficiency: Optional[int], + ) -> str: + raw = (level or proficiency_level or "").strip().lower() + if raw in LEVEL_HOURS_MULTIPLIER: + return raw + score = int(proficiency or 0) + if score >= 85: + return "expert" + if score >= 70: + return "advanced" + if score >= 50: + return "intermediate" + return "beginner" + + level_map: dict[int, str] = { + int(skill_tag_id): normalize_level(level, proficiency_level, proficiency) + for skill_tag_id, level, proficiency_level, proficiency in skill_rows + } + + def effective_estimated_hours(task: Task) -> float: + base = float(task.estimated_hours or 0.0) + if base <= 0: + return 0.0 + required_ids = _safe_int_list(task.required_skill_tag_ids) + if not required_ids: + return base + multipliers = [ + LEVEL_HOURS_MULTIPLIER.get(level_map[sid], 1.0) + for sid in required_ids + if sid in level_map + ] + if not multipliers: + return base + factor = sum(multipliers) / len(multipliers) + return round(base * factor, 2) + + workload.current_assigned_hours = round( + sum(effective_estimated_hours(task) for task in task_rows), + 2, + ) + + await recalculate_user_workload_ratio(db, tenant_id, user_id, commit=False) + await db.commit() + await db.refresh(workload) + return workload + diff --git a/backend/app/services/workload/core_ratio.py b/backend/app/services/workload/core_ratio.py new file mode 100644 index 00000000..45c3a6ec --- /dev/null +++ b/backend/app/services/workload/core_ratio.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from .shared import * + +def remaining_workdays_this_week(reference_date: Optional[date] = None) -> int: + """计算本周剩余工作日(含当天)。""" + today = reference_date or date.today() + weekday = today.weekday() # Monday = 0 + days_to_friday = max(0, 4 - weekday) + count = 0 + for offset in range(days_to_friday + 1): + d = today + timedelta(days=offset) + if d.weekday() < 5: + count += 1 + return max(count, 1) + +def calculate_workload_ratio( + assigned_hours: float, + max_weekly_hours: float, + reference_date: Optional[date] = None, +) -> float: + """ + 负载率公式(百分比): + ratio = assigned_hours / ((max_weekly_hours / 5) * remaining_workdays_this_week) + """ + remaining_days = remaining_workdays_this_week(reference_date) + denominator = (max_weekly_hours / 5.0) * remaining_days + if denominator <= 0: + return 0.0 + return round((assigned_hours / denominator) * 100.0, 2) + +async def ensure_user_workload( + db: AsyncSession, + tenant_id: int, + user_id: int, +) -> UserWorkload: + result = await db.execute( + select(UserWorkload).where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id == user_id, + ) + ) + ) + workload = result.scalar_one_or_none() + if workload: + return workload + + workload = UserWorkload( + tenant_id=tenant_id, + user_id=user_id, + current_assigned_hours=0.0, + max_available_hours_per_week=40.0, + workload_ratio=0.0, + ) + db.add(workload) + await db.flush() + return workload + +async def recalculate_user_workload_ratio( + db: AsyncSession, + tenant_id: int, + user_id: int, + *, + commit: bool = True, +) -> Optional[UserWorkload]: + """重算单用户负载率并同步 users.workload。""" + user_result = await db.execute( + select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) + ) + user = user_result.scalar_one_or_none() + if not user: + return None + + workload = await ensure_user_workload(db, tenant_id, user_id) + ratio = calculate_workload_ratio( + assigned_hours=float(workload.current_assigned_hours or 0), + max_weekly_hours=float(workload.max_available_hours_per_week or 40), + ) + workload.workload_ratio = ratio + user.workload = int(max(0, min(200, ratio))) + user.updated_at = now_cn_naive() + try: + from . import workload_alert_service + + await workload_alert_service.on_user_workload_changed( + db, + tenant_id=tenant_id, + user_id=user_id, + workload_ratio=ratio, + ) + except Exception: + pass + + if commit: + await db.commit() + await db.refresh(workload) + return workload + +async def recalculate_all_users_workload_ratio(db: AsyncSession, tenant_id: int) -> int: + """批量重算租户内所有用户负载率。""" + result = await db.execute(select(User.id).where(User.tenant_id == tenant_id)) + user_ids = [int(uid) for uid in result.scalars().all()] + for uid in user_ids: + await recalculate_user_workload_ratio(db, tenant_id, uid, commit=False) + await db.commit() + return len(user_ids) + +async def list_tenant_ids(db: AsyncSession) -> list[int]: + """获取当前系统存在用户数据的租户 ID 列表。""" + result = await db.execute(select(User.tenant_id).distinct()) + return [int(tid) for tid in result.scalars().all() if tid is not None] + diff --git a/backend/app/services/workload/shared.py b/backend/app/services/workload/shared.py new file mode 100644 index 00000000..39de2872 --- /dev/null +++ b/backend/app/services/workload/shared.py @@ -0,0 +1,24 @@ +"""PMFlow 团队负载服务。""" + +from __future__ import annotations + +import json +from datetime import date, datetime, timedelta +from typing import Optional + +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...config.ai_prompts import PROMPT_WORKLOAD_OPTIMIZATION +from ...models import AICallLog, Task, User, UserProject, UserSkillBinding, UserWorkload +from ..llm import llm_service + +from ...utils.timezone import now_cn_naive +LEVEL_HOURS_MULTIPLIER = { + "expert": 0.8, + "advanced": 0.9, + "intermediate": 1.0, + "beginner": 1.1, +} + + diff --git a/backend/app/services/workload_service.py b/backend/app/services/workload_service.py index 7022309c..7efd6cbd 100644 --- a/backend/app/services/workload_service.py +++ b/backend/app/services/workload_service.py @@ -1,740 +1,20 @@ -"""PMFlow 团队负载服务。""" - from __future__ import annotations -import json -from datetime import date, datetime, timedelta -from typing import Optional - -from sqlalchemy import and_, func, select -from sqlalchemy.ext.asyncio import AsyncSession - -from ..config.ai_prompts import PROMPT_WORKLOAD_OPTIMIZATION -from ..models import AICallLog, Task, User, UserProject, UserSkillBinding, UserWorkload -from .llm import llm_service - -from ..utils.timezone import now_cn_naive -LEVEL_HOURS_MULTIPLIER = { - "expert": 0.8, - "advanced": 0.9, - "intermediate": 1.0, - "beginner": 1.1, -} - - -def remaining_workdays_this_week(reference_date: Optional[date] = None) -> int: - """计算本周剩余工作日(含当天)。""" - today = reference_date or date.today() - weekday = today.weekday() # Monday = 0 - days_to_friday = max(0, 4 - weekday) - count = 0 - for offset in range(days_to_friday + 1): - d = today + timedelta(days=offset) - if d.weekday() < 5: - count += 1 - return max(count, 1) - - -def calculate_workload_ratio( - assigned_hours: float, - max_weekly_hours: float, - reference_date: Optional[date] = None, -) -> float: - """ - 负载率公式(百分比): - ratio = assigned_hours / ((max_weekly_hours / 5) * remaining_workdays_this_week) - """ - remaining_days = remaining_workdays_this_week(reference_date) - denominator = (max_weekly_hours / 5.0) * remaining_days - if denominator <= 0: - return 0.0 - return round((assigned_hours / denominator) * 100.0, 2) - - -async def ensure_user_workload( - db: AsyncSession, - tenant_id: int, - user_id: int, -) -> UserWorkload: - result = await db.execute( - select(UserWorkload).where( - and_( - UserWorkload.tenant_id == tenant_id, - UserWorkload.user_id == user_id, - ) - ) - ) - workload = result.scalar_one_or_none() - if workload: - return workload - - workload = UserWorkload( - tenant_id=tenant_id, - user_id=user_id, - current_assigned_hours=0.0, - max_available_hours_per_week=40.0, - workload_ratio=0.0, - ) - db.add(workload) - await db.flush() - return workload - - -async def recalculate_user_workload_ratio( - db: AsyncSession, - tenant_id: int, - user_id: int, - *, - commit: bool = True, -) -> Optional[UserWorkload]: - """重算单用户负载率并同步 users.workload。""" - user_result = await db.execute( - select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) - ) - user = user_result.scalar_one_or_none() - if not user: - return None - - workload = await ensure_user_workload(db, tenant_id, user_id) - ratio = calculate_workload_ratio( - assigned_hours=float(workload.current_assigned_hours or 0), - max_weekly_hours=float(workload.max_available_hours_per_week or 40), - ) - workload.workload_ratio = ratio - user.workload = int(max(0, min(200, ratio))) - user.updated_at = now_cn_naive() - try: - from . import workload_alert_service - - await workload_alert_service.on_user_workload_changed( - db, - tenant_id=tenant_id, - user_id=user_id, - workload_ratio=ratio, - ) - except Exception: - pass - - if commit: - await db.commit() - await db.refresh(workload) - return workload - - -async def recalculate_all_users_workload_ratio(db: AsyncSession, tenant_id: int) -> int: - """批量重算租户内所有用户负载率。""" - result = await db.execute(select(User.id).where(User.tenant_id == tenant_id)) - user_ids = [int(uid) for uid in result.scalars().all()] - for uid in user_ids: - await recalculate_user_workload_ratio(db, tenant_id, uid, commit=False) - await db.commit() - return len(user_ids) - - -async def list_tenant_ids(db: AsyncSession) -> list[int]: - """获取当前系统存在用户数据的租户 ID 列表。""" - result = await db.execute(select(User.tenant_id).distinct()) - return [int(tid) for tid in result.scalars().all() if tid is not None] - - -async def get_team_workload_board( - db: AsyncSession, - tenant_id: int = 1, - project_id: Optional[int] = None, -) -> list[dict]: - """团队负载看板数据。""" - query = ( - select(UserWorkload, User) - .join(User, UserWorkload.user_id == User.id) - .where( - and_( - UserWorkload.tenant_id == tenant_id, - User.tenant_id == tenant_id, - ) - ) - ) - if project_id is not None: - query = query.join( - UserProject, - and_( - UserProject.tenant_id == tenant_id, - UserProject.user_id == User.id, - ), - ).where(UserProject.project_id == project_id) - - query = query.order_by(UserWorkload.workload_ratio.desc(), User.id.asc()) - rows = (await db.execute(query)).all() - - return [ - { - "user_id": str(w.user_id), - "user_name": u.name, - "username": u.username, - "role": u.role, - "is_active": u.is_active, - "current_assigned_hours": float(w.current_assigned_hours or 0), - "max_weekly_hours": float(w.max_available_hours_per_week or 40), - "workload_ratio": float(w.workload_ratio or 0), - "remaining_hours": round( - max( - 0.0, - float(w.max_available_hours_per_week or 40) - - float(w.current_assigned_hours or 0), - ), - 2, - ), - "updated_at": w.updated_at.isoformat() if w.updated_at else None, - } - for w, u in rows - ] - - -async def get_user_personal_workload( - db: AsyncSession, - tenant_id: int, - user_id: int, -) -> Optional[dict]: - """个人负载详情。""" - row = ( - await db.execute( - select(UserWorkload, User) - .join(User, UserWorkload.user_id == User.id) - .where( - and_( - UserWorkload.tenant_id == tenant_id, - UserWorkload.user_id == user_id, - User.tenant_id == tenant_id, - ) - ) - ) - ).first() - if row: - w, u = row - return { - "user_id": str(u.id), - "user_name": u.name, - "username": u.username, - "is_active": u.is_active, - "current_assigned_hours": float(w.current_assigned_hours or 0), - "max_weekly_hours": float(w.max_available_hours_per_week or 40), - "workload_ratio": float(w.workload_ratio or 0), - "remaining_hours": round( - max( - 0.0, - float(w.max_available_hours_per_week or 40) - - float(w.current_assigned_hours or 0), - ), - 2, - ), - "updated_at": w.updated_at.isoformat() if w.updated_at else None, - } - - user = ( - await db.execute( - select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) - ) - ).scalar_one_or_none() - if not user: - return None - - workload = await ensure_user_workload(db, tenant_id, user_id) - await db.commit() - return { - "user_id": str(user.id), - "user_name": user.name, - "username": user.username, - "is_active": user.is_active, - "current_assigned_hours": float(workload.current_assigned_hours or 0), - "max_weekly_hours": float(workload.max_available_hours_per_week or 40), - "workload_ratio": float(workload.workload_ratio or 0), - "remaining_hours": round( - max( - 0.0, - float(workload.max_available_hours_per_week or 40) - - float(workload.current_assigned_hours or 0), - ), - 2, - ), - "updated_at": workload.updated_at.isoformat() if workload.updated_at else None, - } - - -async def update_user_max_weekly_hours( - db: AsyncSession, - tenant_id: int, - user_id: int, - max_weekly_hours: float, -) -> Optional[dict]: - """更新每周可用工时并重算负载率。""" - user = ( - await db.execute( - select(User).where(and_(User.tenant_id == tenant_id, User.id == user_id)) - ) - ).scalar_one_or_none() - if not user: - return None - - workload = await ensure_user_workload(db, tenant_id, user_id) - workload.max_available_hours_per_week = max(1.0, float(max_weekly_hours)) - await recalculate_user_workload_ratio(db, tenant_id, user_id, commit=False) - await db.commit() - await db.refresh(workload) - - return { - "user_id": str(user.id), - "max_weekly_hours": float(workload.max_available_hours_per_week), - "workload_ratio": float(workload.workload_ratio or 0), - } - - -async def list_user_assigned_tasks( - db: AsyncSession, - tenant_id: int, - user_id: int, - *, - status: Optional[str] = None, - priority: Optional[str] = None, -) -> list[dict]: - """查询用户任务列表。""" - query = select(Task).where( - and_( - Task.tenant_id == tenant_id, - Task.assignee_id == user_id, - ) - ) - if status: - query = query.where(Task.status == status) - if priority: - query = query.where(Task.priority == priority) - - rows = (await db.execute(query.order_by(Task.updated_at.desc()))).scalars().all() - return [ - { - "id": str(t.id), - "title": t.title, - "project_id": str(t.project_id), - "priority": t.priority, - "status": t.status, - "estimated_hours": float(t.estimated_hours or 0), - "due_date": t.due_date.isoformat() if t.due_date else None, - } - for t in rows - ] - - -async def get_user_workload_trend( - db: AsyncSession, - tenant_id: int, - user_id: int, - days: int = 7, -) -> list[dict]: - """ - 用户负载趋势。 - - 当前无历史快照表时,返回最近 N 天的平滑序列用于前端趋势图展示。 - """ - workload = ( - await db.execute( - select(UserWorkload).where( - and_( - UserWorkload.tenant_id == tenant_id, - UserWorkload.user_id == user_id, - ) - ) - ) - ).scalar_one_or_none() - ratio_now = float(workload.workload_ratio or 0) if workload else 0.0 - - days = max(1, min(days, 30)) - start = date.today() - timedelta(days=days - 1) - series: list[dict] = [] - for idx in range(days): - current_day = start + timedelta(days=idx) - # 缺少历史表时做线性平滑,保留可视化可用性。 - ratio = round(max(0.0, ratio_now * (0.92 + 0.08 * (idx + 1) / days)), 2) - series.append( - { - "date": current_day.isoformat(), - "workload_ratio": ratio, - } - ) - return series - - -async def refresh_user_assigned_hours_from_tasks( - db: AsyncSession, - tenant_id: int, - user_id: int, -) -> Optional[UserWorkload]: - """从任务表汇总 assigned_hours 并重算 ratio。""" - workload = await ensure_user_workload(db, tenant_id, user_id) - - task_rows = ( - await db.execute( - select(Task).where( - and_( - Task.tenant_id == tenant_id, - Task.assignee_id == user_id, - func.lower(Task.status).notin_(["done", "completed", "closed"]), - ) - ) - ) - ).scalars().all() - - skill_rows = ( - await db.execute( - select( - UserSkillBinding.skill_tag_id, - UserSkillBinding.level, - UserSkillBinding.proficiency_level, - UserSkillBinding.proficiency, - ).where( - and_( - UserSkillBinding.tenant_id == tenant_id, - UserSkillBinding.user_id == user_id, - ) - ) - ) - ).all() - - def normalize_level( - level: Optional[str], - proficiency_level: Optional[str], - proficiency: Optional[int], - ) -> str: - raw = (level or proficiency_level or "").strip().lower() - if raw in LEVEL_HOURS_MULTIPLIER: - return raw - score = int(proficiency or 0) - if score >= 85: - return "expert" - if score >= 70: - return "advanced" - if score >= 50: - return "intermediate" - return "beginner" - - level_map: dict[int, str] = { - int(skill_tag_id): normalize_level(level, proficiency_level, proficiency) - for skill_tag_id, level, proficiency_level, proficiency in skill_rows - } - - def effective_estimated_hours(task: Task) -> float: - base = float(task.estimated_hours or 0.0) - if base <= 0: - return 0.0 - required_ids = _safe_int_list(task.required_skill_tag_ids) - if not required_ids: - return base - multipliers = [ - LEVEL_HOURS_MULTIPLIER.get(level_map[sid], 1.0) - for sid in required_ids - if sid in level_map - ] - if not multipliers: - return base - factor = sum(multipliers) / len(multipliers) - return round(base * factor, 2) - - workload.current_assigned_hours = round( - sum(effective_estimated_hours(task) for task in task_rows), - 2, - ) - - await recalculate_user_workload_ratio(db, tenant_id, user_id, commit=False) - await db.commit() - await db.refresh(workload) - return workload - - -def _skill_match_score(task_skill_ids: list[int], user_skill_ids: set[int]) -> float: - if not task_skill_ids: - return 0.0 - matched = sum(1 for sid in task_skill_ids if sid in user_skill_ids) - return matched / len(task_skill_ids) - - -def _safe_int_list(values: object) -> list[int]: - """兼容历史脏数据:数组中可能混入技能名称等非数字值。""" - source = values - if source is None: - return [] - if isinstance(source, str): - text = source.strip() - if not text: - return [] - try: - source = json.loads(text) - except (TypeError, ValueError, json.JSONDecodeError): - return [] - if not isinstance(source, list): - return [] - - parsed: list[int] = [] - for item in source: - if item is None or isinstance(item, bool): - continue - if isinstance(item, int): - parsed.append(item) - continue - if isinstance(item, float): - if item.is_integer(): - parsed.append(int(item)) - continue - if isinstance(item, str): - text = item.strip() - if text and text.lstrip("-").isdigit(): - parsed.append(int(text)) - return parsed - - -def _parse_json_response(text: str) -> dict: - body = (text or "").strip() - if body.startswith("```json"): - body = body[7:].strip() - if body.startswith("```"): - body = body[3:].strip() - if body.endswith("```"): - body = body[:-3].strip() - return json.loads(body) - - -async def build_ai_optimize_preview( - db: AsyncSession, - tenant_id: int, - *, - project_id: Optional[int] = None, - max_moves: int = 20, - unassigned_only: bool = False, -) -> list[dict]: - """生成任务重分配建议(预览,不落库)。""" - task_query = select(Task).where( - and_( - Task.tenant_id == tenant_id, - func.lower(Task.status).notin_(["done", "completed", "closed"]), - ) - ) - if unassigned_only: - task_query = task_query.where(Task.assignee_id.is_(None)) - if project_id is not None: - task_query = task_query.where(Task.project_id == project_id) - - task_rows = (await db.execute(task_query.order_by(Task.updated_at.desc()))).scalars().all() - if not task_rows: - return [] - - active_users_query = select(User).where( - and_( - User.tenant_id == tenant_id, - User.status == "active", - User.is_active.is_(True), - ) - ) - users = (await db.execute(active_users_query)).scalars().all() - if not users: - return [] - - workload_rows = ( - await db.execute( - select(UserWorkload).where( - and_( - UserWorkload.tenant_id == tenant_id, - UserWorkload.user_id.in_([int(u.id) for u in users]), - ) - ) - ) - ).scalars().all() - workload_map = {int(w.user_id): float(w.workload_ratio or 0.0) for w in workload_rows} - - skill_rows = ( - await db.execute( - select(UserSkillBinding.user_id, UserSkillBinding.skill_tag_id).where( - UserSkillBinding.tenant_id == tenant_id - ) - ) - ).all() - user_skill_map: dict[int, set[int]] = {} - for uid, sid in skill_rows: - user_skill_map.setdefault(int(uid), set()).add(int(sid)) - - users_by_project: dict[int, list[User]] = {} - for task in task_rows: - if int(task.project_id) in users_by_project: - continue - links = ( - await db.execute( - select(UserProject.user_id).where( - and_( - UserProject.tenant_id == tenant_id, - UserProject.project_id == int(task.project_id), - ) - ) - ) - ).scalars().all() - allowed_ids = {int(uid) for uid in links} - users_by_project[int(task.project_id)] = [u for u in users if int(u.id) in allowed_ids] - - llm_suggestion_map: dict[int, dict] = {} - members_payload = [] - for user in users: - uid = int(user.id) - members_payload.append( - { - "user_id": uid, - "user_name": user.name, - "workload_ratio": round(workload_map.get(uid, float(user.workload or 0)), 2), - "skill_tag_ids": sorted(list(user_skill_map.get(uid, set()))), - } - ) - tasks_payload = [ - { - "task_id": int(task.id), - "project_id": int(task.project_id), - "title": task.title, - "assignee_id": int(task.assignee_id) if task.assignee_id else None, - "required_skill_tag_ids": _safe_int_list(task.required_skill_tag_ids), - "estimated_hours": float(task.estimated_hours or 0), - "status": task.status, - } - for task in task_rows - ] - llm_prompt = PROMPT_WORKLOAD_OPTIMIZATION.format( - members_json=json.dumps(members_payload, ensure_ascii=False), - tasks_json=json.dumps(tasks_payload, ensure_ascii=False), - ) - llm_text = "" - llm_status = "success" - try: - llm_text = await llm_service.generate( - llm_prompt, - function_name="workload_optimize_prompt", - tenant_id=tenant_id, - ) - parsed = _parse_json_response(llm_text) - for item in parsed.get("proposals", []) if isinstance(parsed, dict) else []: - try: - tid = int(item.get("task_id")) - llm_suggestion_map[tid] = { - "new_assignee_id": int(item.get("new_assignee_id")), - "reason": str(item.get("reason") or "llm_suggested"), - } - except (TypeError, ValueError): - continue - except Exception as exc: - llm_status = "failed" - llm_text = str(exc) - - db.add( - AICallLog( - tenant_id=tenant_id, - user_id=None, - function_name="workload_optimize_prompt", - prompt=llm_prompt, - response=(llm_text or "")[:8000], - tokens_used=max(len(llm_prompt) // 4, 1) + max(len(llm_text) // 4, 0), - cost=((max(len(llm_prompt) // 4, 1) * 0.00015 + max(len(llm_text) // 4, 0) * 0.0006) / 1000), - status=llm_status, - ) - ) - await db.commit() - - proposals: list[dict] = [] - for task in task_rows: - current_assignee = int(task.assignee_id or 0) - is_unassigned = current_assignee <= 0 - candidates = users_by_project.get(int(task.project_id), []) - if not candidates: - continue - - required_skill_ids = _safe_int_list(task.required_skill_tag_ids) - ranked = [] - llm_target_id = llm_suggestion_map.get(int(task.id), {}).get("new_assignee_id") - llm_reason = llm_suggestion_map.get(int(task.id), {}).get("reason") - for candidate in candidates: - uid = int(candidate.id) - if not is_unassigned and uid == current_assignee: - continue - ratio = workload_map.get(uid, float(candidate.workload or 0)) - skill_score = _skill_match_score(required_skill_ids, user_skill_map.get(uid, set())) - # workload 越低越优,skill_score 越高越优 - total_score = (skill_score * 0.7) + ((max(0.0, 120.0 - ratio) / 120.0) * 0.3) - if llm_target_id and uid == int(llm_target_id): - total_score += 0.2 - ranked.append((total_score, ratio, candidate)) - - ranked.sort(key=lambda x: (-x[0], x[1])) - if not ranked: - continue - - best_score, _best_ratio, best_user = ranked[0] - current_ratio = workload_map.get(current_assignee, 0.0) if not is_unassigned else 0.0 - if is_unassigned: - if best_score < 0.2: - continue - reason = f"initial_assignment_score={best_score:.2f}" - else: - if current_ratio <= 100 and best_score < 0.5: - continue - reason = f"skill_match+workload_score={best_score:.2f}" - if llm_target_id and int(best_user.id) == int(llm_target_id): - reason = f"{reason};llm={llm_reason or 'suggested'}" - - proposals.append( - { - "task_id": int(task.id), - "task_title": task.title, - "project_id": int(task.project_id), - "old_assignee_id": current_assignee if not is_unassigned else None, - "new_assignee_id": int(best_user.id), - "old_ratio": round(current_ratio, 2) if not is_unassigned else None, - "new_ratio": round(workload_map.get(int(best_user.id), 0.0), 2), - "reason": reason, - } - ) - if len(proposals) >= max_moves: - break - - return proposals - - -async def apply_ai_optimize_plan( - db: AsyncSession, - tenant_id: int, - proposals: list[dict], -) -> dict: - """应用优化方案并重算涉及成员负载。""" - touched_user_ids: set[int] = set() - applied = 0 - - for item in proposals: - task_id = int(item.get("task_id", 0)) - new_assignee_id = int(item.get("new_assignee_id", 0)) - if task_id <= 0 or new_assignee_id <= 0: - continue - - task = ( - await db.execute( - select(Task).where( - and_( - Task.tenant_id == tenant_id, - Task.id == task_id, - ) - ) - ) - ).scalar_one_or_none() - if not task: - continue - - if task.assignee_id: - touched_user_ids.add(int(task.assignee_id)) - task.assignee_id = new_assignee_id - touched_user_ids.add(new_assignee_id) - applied += 1 - - if touched_user_ids: - for uid in touched_user_ids: - await refresh_user_assigned_hours_from_tasks(db, tenant_id, uid) - else: - await db.commit() - - return {"applied_count": applied, "affected_users": len(touched_user_ids)} +from .workload import * + +__all__ = [ + "remaining_workdays_this_week", + "calculate_workload_ratio", + "ensure_user_workload", + "recalculate_user_workload_ratio", + "recalculate_all_users_workload_ratio", + "list_tenant_ids", + "get_team_workload_board", + "get_user_personal_workload", + "update_user_max_weekly_hours", + "list_user_assigned_tasks", + "get_user_workload_trend", + "refresh_user_assigned_hours_from_tasks", + "build_ai_optimize_preview", + "apply_ai_optimize_plan", +]