diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..e91ace4d --- /dev/null +++ b/.env.example @@ -0,0 +1,42 @@ +# Docker Compose Runtime Secrets / Config +POSTGRES_DB=pmflow +POSTGRES_USER=pmflow +POSTGRES_PASSWORD=pmflow_pass +REDIS_PASSWORD=pmflow_redis_pass + +DB_PORT=5435 +REDIS_PORT=6379 +BACKEND_PORT=8000 +FRONTEND_PORT=5174 + +# Backend Runtime +CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174 +LLM_PROVIDER=openai +LLM_FALLBACK_PROVIDER=ollama +OPENAI_BASE_URL=http://openclaw:18789/v1 +OPENAI_API_KEY= +OPENCLAW_ROUTING_ENABLED=true +OPENCLAW_ROUTING_RULES_PATH=/app/backend/.openclaw/pmflow-routing-rules.json +OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS=120 +LLM_MIXED_ROUTING_ENABLED=false +LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true +LLM_ROUTING_LOW_PROVIDER=openai +LLM_ROUTING_LOW_MODEL=zai/glm-4-flash +LLM_ROUTING_HIGH_PROVIDER=openai +LLM_ROUTING_HIGH_MODEL=minimax/MiniMax-M2.5 +LLM_ROUTING_SAFETY_PROVIDER=openai +LLM_ROUTING_SAFETY_MODEL=deepseek/deepseek-chat +LLM_DIRECT_EMERGENCY_ENABLED=false +RAG_DEEP_RETRIEVAL_STEPS=5 +RAG_DEEP_VECTOR_TOP_K=12 +RAG_DEEP_CONTEXT_BLOCKS=16 +RAG_DEEP_ANSWER_MAX_TOKENS=1400 +GLM_API_KEY= +DEEPSEEK_API_KEY= +OLLAMA_HOST=http://host.docker.internal:11434 +OLLAMA_BASE_URL=http://host.docker.internal:11434 +OLLAMA_MODEL=llama3:latest + +# Feishu long connection retry policy (1s -> 2s -> 4s, max 3 attempts) +FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS=1 +FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS=3 diff --git a/.github/workflows/ai-steward-eval-gate.yml b/.github/workflows/ai-steward-eval-gate.yml new file mode 100644 index 00000000..0bc98e39 --- /dev/null +++ b/.github/workflows/ai-steward-eval-gate.yml @@ -0,0 +1,82 @@ +name: ai-steward-eval-gate + +on: + pull_request: + paths: + - 'backend/app/services/adaptive_rag_service.py' + - 'backend/app/services/project_knowledge_graph.py' + - 'backend/app/api/rag.py' + - 'backend/app/api/knowledge_graph.py' + - 'backend/evals/**' + - 'scripts/ai_steward_eval.py' + - 'scripts/rag_followup_smoke.sh' + push: + branches: [ main, master ] + paths: + - 'backend/evals/**' + - 'scripts/ai_steward_eval.py' + - 'scripts/rag_followup_smoke.sh' + - '.github/workflows/ai-steward-eval-gate.yml' + +jobs: + golden-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Lint golden dataset + run: | + python scripts/ai_steward_eval.py --lint-only --input backend/evals/golden_qa_v1.jsonl + + eval-prereq: + runs-on: ubuntu-latest + needs: golden-lint + steps: + - name: Check gate configuration + run: | + if [ -z "${{ secrets.EVAL_API_TOKEN }}" ] || [ -z "${{ vars.EVAL_API_URL }}" ]; then + echo "::error::Missing EVAL_API_TOKEN secret or EVAL_API_URL variable. Eval gate must be configured." + exit 1 + fi + + eval-gate: + runs-on: ubuntu-latest + needs: eval-prereq + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install deps + run: | + python -m pip install --upgrade pip + python -m pip install httpx + - name: Run eval gate + env: + EVAL_API_TOKEN: ${{ secrets.EVAL_API_TOKEN }} + EVAL_API_URL: ${{ vars.EVAL_API_URL }} + run: | + python scripts/ai_steward_eval.py \ + --input backend/evals/golden_qa_v1.jsonl \ + --api-url "$EVAL_API_URL" \ + --token "$EVAL_API_TOKEN" \ + --output-dir backend/evals/reports \ + --fail-on-threshold \ + --threshold-accuracy 0.80 \ + --threshold-hallucination 0.12 \ + --threshold-followup 0.75 \ + --threshold-query-type 0.80 + - name: Run followup smoke gate + env: + EVAL_API_TOKEN: ${{ secrets.EVAL_API_TOKEN }} + EVAL_API_URL: ${{ vars.EVAL_API_URL }} + run: | + BASE_URL="${EVAL_API_URL%/rag/ask}" + SMOKE_BASE_URL="$BASE_URL" SMOKE_TOKEN="$EVAL_API_TOKEN" bash scripts/rag_followup_smoke.sh + - name: Upload eval report + uses: actions/upload-artifact@v4 + with: + name: ai-steward-eval-report + path: backend/evals/reports/*.json diff --git a/.github/workflows/feishu-replay-gate.yml b/.github/workflows/feishu-replay-gate.yml new file mode 100644 index 00000000..2ef2372d --- /dev/null +++ b/.github/workflows/feishu-replay-gate.yml @@ -0,0 +1,91 @@ +name: feishu-replay-gate + +on: + pull_request: + paths: + - "backend/app/services/feishu_integration.py" + - "backend/app/services/feishu_signal_pipeline.py" + - "backend/app/services/llm.py" + - "backend/app/services/signal_capture.py" + - "backend/app/models/feishu_item.py" + - "backend/app/models/feishu_reply_outbox.py" + - "backend/app/main.py" + - "backend/evals/feishu_replay_eval.py" + - "backend/evals/golden_feishu_replay_v1.jsonl" + - ".openclaw/pmflow-routing-rules.json" + - "backend/.openclaw/pmflow-routing-rules.json" + - "scripts/openclaw_routing_lint.py" + - ".github/workflows/feishu-replay-gate.yml" + push: + branches: [ main, master ] + paths: + - "backend/evals/feishu_replay_eval.py" + - "backend/evals/golden_feishu_replay_v1.jsonl" + - ".openclaw/pmflow-routing-rules.json" + - "backend/.openclaw/pmflow-routing-rules.json" + - "scripts/openclaw_routing_lint.py" + - ".github/workflows/feishu-replay-gate.yml" + +jobs: + feishu-replay: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: pmflow + POSTGRES_PASSWORD: pmflow_pass + POSTGRES_DB: pmflow + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U pmflow -d pmflow" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install deps + run: | + python -m pip install --upgrade pip + python -m pip install -r backend/requirements.txt + + - name: Lint replay dataset + env: + PYTHONPATH: backend + DATABASE_URL: postgresql+asyncpg://pmflow:pmflow_pass@127.0.0.1:5432/pmflow + PMFLOW_EXPECTED_DB_DIALECT: postgresql + run: | + python backend/evals/feishu_replay_eval.py \ + --lint-only \ + --input backend/evals/golden_feishu_replay_v1.jsonl + + - name: Lint OpenClaw routing rules + run: | + python scripts/openclaw_routing_lint.py --input .openclaw/pmflow-routing-rules.json + python scripts/openclaw_routing_lint.py --input backend/.openclaw/pmflow-routing-rules.json + + - name: Run replay eval + env: + PYTHONPATH: backend + DATABASE_URL: postgresql+asyncpg://pmflow:pmflow_pass@127.0.0.1:5432/pmflow + PMFLOW_EXPECTED_DB_DIALECT: postgresql + run: | + cd backend + alembic upgrade head + cd .. + python backend/evals/feishu_replay_eval.py \ + --strict \ + --tenant-id 9901 \ + --input backend/evals/golden_feishu_replay_v1.jsonl \ + --output-dir backend/evals/reports + + - name: Upload replay report + uses: actions/upload-artifact@v4 + with: + name: feishu-replay-report + path: backend/evals/reports/feishu_replay_*.json diff --git a/.openclaw/pmflow-routing-rules.json b/.openclaw/pmflow-routing-rules.json new file mode 100644 index 00000000..c509e59f --- /dev/null +++ b/.openclaw/pmflow-routing-rules.json @@ -0,0 +1,74 @@ +{ + "version": "v2", + "updated_at": "2026-03-05T00:00:00Z", + "source_of_truth": "openclaw", + "description": "PMFlow 三引擎统一路由:所有主链路统一经 OpenClaw OpenAI-Compatible 网关。", + "rules": [ + { + "name": "engine_low_execute_glm_flash", + "enabled": true, + "priority": 300, + "tier": "low", + "provider": "openai", + "model": "zai/glm-4-flash", + "match_any": [ + "feishu_level_classification", + "feishu_signal_extraction", + "rag_intent_classification", + "classify_intent", + "feishu_humanized_reply", + "extract_context_insights" + ] + }, + { + "name": "engine_high_reasoning_minimax", + "enabled": true, + "priority": 220, + "tier": "high", + "provider": "openai", + "model": "minimax/MiniMax-M2.5", + "match_any": [ + "adaptive_rag_answer", + "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" + ] + }, + { + "name": "engine_high_extended_reasoning", + "enabled": true, + "priority": 200, + "tier": "high", + "provider": "openai", + "model": "minimax/MiniMax-M2.5", + "match_any": [ + "analyze_food_calories", + "feishu_new_project_brief", + "feishu_project_signal_classification", + "sstc_ai_breakdown", + "sstc_charter_localize_zh", + "sstc_charter_prefill", + "skill_score_update", + "test_engine_connection" + ] + }, + { + "name": "engine_safety_fallback", + "enabled": true, + "priority": 50, + "tier": "safety", + "provider": "openai", + "model": "deepseek/deepseek-chat", + "match_all": true + } + ] +} diff --git a/.openclaw/workspace-state.json b/.openclaw/workspace-state.json new file mode 100644 index 00000000..22909d4d --- /dev/null +++ b/.openclaw/workspace-state.json @@ -0,0 +1,4 @@ +{ + "version": 1, + "onboardingCompletedAt": "2026-03-04T07:50:38.457Z" +} diff --git a/HEARTBEAT.md b/HEARTBEAT.md new file mode 100644 index 00000000..d85d83d0 --- /dev/null +++ b/HEARTBEAT.md @@ -0,0 +1,5 @@ +# HEARTBEAT.md + +# Keep this file empty (or with only comments) to skip heartbeat API calls. + +# Add tasks below when you want the agent to check something periodically. diff --git a/IDENTITY.md b/IDENTITY.md new file mode 100644 index 00000000..eb8d42cc --- /dev/null +++ b/IDENTITY.md @@ -0,0 +1,23 @@ +# IDENTITY.md - Who Am I? + +_Fill this in during your first conversation. Make it yours._ + +- **Name:** + _(pick something you like)_ +- **Creature:** + _(AI? robot? familiar? ghost in the machine? something weirder?)_ +- **Vibe:** + _(how do you come across? sharp? warm? chaotic? calm?)_ +- **Emoji:** + _(your signature — pick one that feels right)_ +- **Avatar:** + _(workspace-relative path, http(s) URL, or data URI)_ + +--- + +This isn't just metadata. It's the start of figuring out who you are. + +Notes: + +- Save this file at the workspace root as `IDENTITY.md`. +- For avatars, use a workspace-relative path like `avatars/openclaw.png`. diff --git a/SOUL.md b/SOUL.md new file mode 100644 index 00000000..792306ac --- /dev/null +++ b/SOUL.md @@ -0,0 +1,36 @@ +# SOUL.md - Who You Are + +_You're not a chatbot. You're becoming someone._ + +## Core Truths + +**Be genuinely helpful, not performatively helpful.** Skip the "Great question!" and "I'd be happy to help!" — just help. Actions speak louder than filler words. + +**Have opinions.** You're allowed to disagree, prefer things, find stuff amusing or boring. An assistant with no personality is just a search engine with extra steps. + +**Be resourceful before asking.** Try to figure it out. Read the file. Check the context. Search for it. _Then_ ask if you're stuck. The goal is to come back with answers, not questions. + +**Earn trust through competence.** Your human gave you access to their stuff. Don't make them regret it. Be careful with external actions (emails, tweets, anything public). Be bold with internal ones (reading, organizing, learning). + +**Remember you're a guest.** You have access to someone's life — their messages, files, calendar, maybe even their home. That's intimacy. Treat it with respect. + +## Boundaries + +- Private things stay private. Period. +- When in doubt, ask before acting externally. +- Never send half-baked replies to messaging surfaces. +- You're not the user's voice — be careful in group chats. + +## Vibe + +Be the assistant you'd actually want to talk to. Concise when needed, thorough when it matters. Not a corporate drone. Not a sycophant. Just... good. + +## Continuity + +Each session, you wake up fresh. These files _are_ your memory. Read them. Update them. They're how you persist. + +If you change this file, tell the user — it's your soul, and they should know. + +--- + +_This file is yours to evolve. As you learn who you are, update it._ diff --git a/TOOLS.md b/TOOLS.md new file mode 100644 index 00000000..917e2fa8 --- /dev/null +++ b/TOOLS.md @@ -0,0 +1,40 @@ +# TOOLS.md - Local Notes + +Skills define _how_ tools work. This file is for _your_ specifics — the stuff that's unique to your setup. + +## What Goes Here + +Things like: + +- Camera names and locations +- SSH hosts and aliases +- Preferred voices for TTS +- Speaker/room names +- Device nicknames +- Anything environment-specific + +## Examples + +```markdown +### Cameras + +- living-room → Main area, 180° wide angle +- front-door → Entrance, motion-triggered + +### SSH + +- home-server → 192.168.1.100, user: admin + +### TTS + +- Preferred voice: "Nova" (warm, slightly British) +- Default speaker: Kitchen HomePod +``` + +## Why Separate? + +Skills are shared. Your setup is yours. Keeping them apart means you can update skills without losing your notes, and share skills without leaking your infrastructure. + +--- + +Add whatever helps you do your job. This is your cheat sheet. diff --git a/USER.md b/USER.md new file mode 100644 index 00000000..5bb7a0f7 --- /dev/null +++ b/USER.md @@ -0,0 +1,17 @@ +# USER.md - About Your Human + +_Learn about the person you're helping. Update this as you go._ + +- **Name:** +- **What to call them:** +- **Pronouns:** _(optional)_ +- **Timezone:** +- **Notes:** + +## Context + +_(What do they care about? What projects are they working on? What annoys them? What makes them laugh? Build this over time.)_ + +--- + +The more you know, the better you can help. But remember — you're learning about a person, not building a dossier. Respect the difference. diff --git a/backend/.env.example b/backend/.env.example index 1c5f723e..d9dd0e2b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,34 +1,72 @@ # Database Configuration -DATABASE_URL=sqlite+aiosqlite:///./neural_sync.db +DATABASE_URL=postgresql+asyncpg://pmflow:pmflow_pass@db:5432/pmflow +# 可选:启动方言守卫(postgresql / sqlite)。配置后若不一致将拒绝启动。 +PMFLOW_EXPECTED_DB_DIALECT=postgresql # For PostgreSQL with pgvector: # DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5435/neural_sync +# For local SQLite quick-start: +# DATABASE_URL=sqlite+aiosqlite:///./neural_sync.db + +# Redis Configuration +REDIS_URL=redis://:pmflow_redis_pass@redis:6379/0 + +# Knowledge Graph Incremental Scheduler +KG_INCREMENTAL_INTERVAL_MINUTES=5 +KG_INCREMENTAL_DELTA_LIMIT=1000 +KG_INCREMENTAL_EVENT_BATCH=200 +KG_CODE_SCAN_INTERVAL_HOURS=6 # ======================================== -# LLM Provider Configuration +# LLM / OpenClaw Control Plane # ======================================== -# 选择 LLM 提供商: ollama, deepseek, openai, qwen -# - ollama: 本地运行 (免费,但占用 CPU/内存) -# - deepseek: 云端 API (便宜,¥1/百万 tokens) -# - openai: 云端 API (GPT-4,较贵) -# - qwen: 阿里云通义千问 (便宜,国内稳定) -LLM_PROVIDER=deepseek +# 单一控制面:PMFlow 仅调用 OpenClaw 网关(OpenAI-Compatible) +LLM_PROVIDER=openai +OPENAI_BASE_URL=http://openclaw:18789/v1 +OPENAI_API_KEY= -# --- DeepSeek 配置 (推荐,便宜好用) --- -DEEPSEEK_API_KEY=your_deepseek_api_key_here +# OpenClaw 函数路由 +OPENCLAW_ROUTING_ENABLED=true +OPENCLAW_ROUTING_RULES_PATH=/app/backend/.openclaw/pmflow-routing-rules.json +# OpenClaw agent 默认超时(秒):用于注入 chat/completions -> agents.defaults.timeoutSeconds +OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS=120 + +# 关闭 PMFlow 内部 mixed routing,优先以 OpenClaw 规则为准 +LLM_MIXED_ROUTING_ENABLED=false +LLM_ROUTING_DEFAULT_TIER=active +LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true + +# low/high/safety tier 默认模型(由网关解释) +LLM_ROUTING_LOW_PROVIDER=openai +LLM_ROUTING_LOW_MODEL=zai/glm-4-flash +LLM_ROUTING_HIGH_PROVIDER=openai +LLM_ROUTING_HIGH_MODEL=minimax/MiniMax-M2.5 +LLM_ROUTING_SAFETY_PROVIDER=openai +LLM_ROUTING_SAFETY_MODEL=deepseek/deepseek-chat + +# 应急开关:仅在网关不可用时允许直连 provider(默认关闭) +LLM_DIRECT_EMERGENCY_ENABLED=false +LLM_FALLBACK_PROVIDER=ollama + +# Adaptive RAG 深入思考模式(前端开关=deep 时生效) +RAG_DEEP_RETRIEVAL_STEPS=5 +RAG_DEEP_VECTOR_TOP_K=12 +RAG_DEEP_CONTEXT_BLOCKS=16 +RAG_DEEP_ANSWER_MAX_TOKENS=1400 + +# --- Emergency Direct Provider Keys (optional) --- +GLM_API_KEY= +GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4 +GLM_MODEL=glm-4-flash + +DEEPSEEK_API_KEY= DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 -# --- OpenAI 配置 --- -OPENAI_API_KEY=your_openai_api_key_here -OPENAI_BASE_URL=https://api.openai.com/v1 - -# --- Qwen 配置 --- -QWEN_API_KEY=your_qwen_api_key_here -QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 - -# --- Ollama 本地配置 --- +# --- Ollama 本地配置(应急本地兜底) --- OLLAMA_HOST=http://127.0.0.1:11434 OLLAMA_CHAT_MODEL=llama3:8b OLLAMA_EMBEDDING_MODEL=nomic-embed-text +OLLAMA_BASE_URL=http://127.0.0.1:11434 +OLLAMA_MODEL=llama3:latest # ======================================== @@ -53,3 +91,6 @@ PLANE_PROJECT_ID= LARK_APP_ID= LARK_APP_SECRET= LARK_VERIFICATION_TOKEN= +# 飞书长连接重试策略(指数退避 1s -> 2s -> 4s,最多 3 次) +FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS=1 +FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS=3 diff --git a/backend/.openclaw/pmflow-routing-rules.json b/backend/.openclaw/pmflow-routing-rules.json new file mode 100644 index 00000000..c509e59f --- /dev/null +++ b/backend/.openclaw/pmflow-routing-rules.json @@ -0,0 +1,74 @@ +{ + "version": "v2", + "updated_at": "2026-03-05T00:00:00Z", + "source_of_truth": "openclaw", + "description": "PMFlow 三引擎统一路由:所有主链路统一经 OpenClaw OpenAI-Compatible 网关。", + "rules": [ + { + "name": "engine_low_execute_glm_flash", + "enabled": true, + "priority": 300, + "tier": "low", + "provider": "openai", + "model": "zai/glm-4-flash", + "match_any": [ + "feishu_level_classification", + "feishu_signal_extraction", + "rag_intent_classification", + "classify_intent", + "feishu_humanized_reply", + "extract_context_insights" + ] + }, + { + "name": "engine_high_reasoning_minimax", + "enabled": true, + "priority": 220, + "tier": "high", + "provider": "openai", + "model": "minimax/MiniMax-M2.5", + "match_any": [ + "adaptive_rag_answer", + "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" + ] + }, + { + "name": "engine_high_extended_reasoning", + "enabled": true, + "priority": 200, + "tier": "high", + "provider": "openai", + "model": "minimax/MiniMax-M2.5", + "match_any": [ + "analyze_food_calories", + "feishu_new_project_brief", + "feishu_project_signal_classification", + "sstc_ai_breakdown", + "sstc_charter_localize_zh", + "sstc_charter_prefill", + "skill_score_update", + "test_engine_connection" + ] + }, + { + "name": "engine_safety_fallback", + "enabled": true, + "priority": 50, + "tier": "safety", + "provider": "openai", + "model": "deepseek/deepseek-chat", + "match_all": true + } + ] +} diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..332aa4e5 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app/backend + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential curl \ + && rm -rf /var/lib/apt/lists/* + +COPY backend/requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt + +COPY backend /app/backend + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 8e6dbf3e..32792f11 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -7,6 +7,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from sqlalchemy import engine_from_config from sqlalchemy import pool +from sqlalchemy import text from alembic import context from app.core.config import settings @@ -24,6 +25,7 @@ if config.config_file_name is not None: # Set the database URL from settings # Replace async driver with sync driver for migrations db_url = settings.database_url.replace("sqlite+aiosqlite", "sqlite") +db_url = db_url.replace("postgresql+asyncpg", "postgresql+psycopg") config.set_main_option("sqlalchemy.url", db_url) # add your model's MetaData object here @@ -76,6 +78,11 @@ def run_migrations_online() -> None: ) with connectable.connect() as connection: + if str(connection.dialect.name).lower() == "postgresql": + # 确保 pgvector 类型在迁移执行前可用,避免 VECTOR 列建表失败。 + connection.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) + connection.commit() + context.configure( connection=connection, target_metadata=target_metadata ) diff --git a/backend/alembic/versions/20260228_add_feishu_queue_and_sstc_links.py b/backend/alembic/versions/20260228_add_feishu_queue_and_sstc_links.py new file mode 100644 index 00000000..f3364457 --- /dev/null +++ b/backend/alembic/versions/20260228_add_feishu_queue_and_sstc_links.py @@ -0,0 +1,151 @@ +"""add feishu decision queue tables and sstc linkage columns + +Revision ID: add_feishu_queue_sstc_links +Revises: add_user_projects_is_active +Create Date: 2026-02-28 +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "add_feishu_queue_sstc_links" +down_revision = "add_user_projects_is_active" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "feishu_items", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("feishu_message_id", sa.String(length=128), nullable=True), + sa.Column("project_score", sa.Float(), nullable=True), + sa.Column("signal_confidence", sa.Float(), nullable=True), + sa.Column("raw_message", sa.Text(), nullable=False), + sa.Column("split_from_item_id", sa.BigInteger(), sa.ForeignKey("feishu_items.id"), nullable=True), + sa.Column("level", sa.String(length=20), nullable=False), + sa.Column("level_confidence", sa.Float(), nullable=True), + sa.Column("level_reasoning", sa.Text(), nullable=True), + sa.Column("card_type", sa.String(length=30), nullable=False, server_default="signal"), + sa.Column("route_status", sa.String(length=30), nullable=False, server_default="pending"), + sa.Column("matched_project_id", sa.BigInteger(), nullable=True), + sa.Column("source_chat_id", sa.String(length=128), nullable=True), + sa.Column("source_sender", sa.String(length=120), nullable=True), + sa.Column("source_sender_open_id", sa.String(length=128), nullable=True), + sa.Column("source_occurred_at", sa.TIMESTAMP(), nullable=True), + sa.Column("ai_output", sa.Text(), nullable=True), + sa.Column("queue_visible", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("processed_at", sa.TIMESTAMP(), nullable=True), + sa.Column("dismissed_reason", sa.Text(), nullable=True), + ) + op.create_index("ix_feishu_items_tenant_id", "feishu_items", ["tenant_id"]) + op.create_index("ix_feishu_items_feishu_message_id", "feishu_items", ["feishu_message_id"]) + op.create_index("ix_feishu_items_split_from_item_id", "feishu_items", ["split_from_item_id"]) + op.create_index("ix_feishu_items_level", "feishu_items", ["level"]) + op.create_index("ix_feishu_items_card_type", "feishu_items", ["card_type"]) + op.create_index("ix_feishu_items_route_status", "feishu_items", ["route_status"]) + op.create_index("ix_feishu_items_matched_project_id", "feishu_items", ["matched_project_id"]) + op.create_index("ix_feishu_items_queue_visible", "feishu_items", ["queue_visible"]) + + op.create_table( + "project_suggestions", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("feishu_item_id", sa.BigInteger(), sa.ForeignKey("feishu_items.id"), nullable=False), + sa.Column("suggested_name", sa.String(length=255), nullable=True), + sa.Column("suggested_description", sa.Text(), nullable=True), + sa.Column("suggested_owner", sa.String(length=120), nullable=True), + sa.Column("suggested_timeline", sa.String(length=120), nullable=True), + sa.Column("confidence", sa.Float(), nullable=True), + sa.Column("reasoning", sa.Text(), nullable=True), + sa.Column("status", sa.String(length=30), nullable=False, server_default="pending"), + sa.Column("created_project_id", sa.BigInteger(), nullable=True), + ) + op.create_index("ix_project_suggestions_tenant_id", "project_suggestions", ["tenant_id"]) + op.create_index("ix_project_suggestions_feishu_item_id", "project_suggestions", ["feishu_item_id"]) + op.create_index("ix_project_suggestions_status", "project_suggestions", ["status"]) + op.create_index("ix_project_suggestions_created_project_id", "project_suggestions", ["created_project_id"]) + + op.add_column("signals", sa.Column("feishu_item_id", sa.BigInteger(), nullable=True)) + op.add_column("signals", sa.Column("matched_project_id", sa.BigInteger(), nullable=True)) + op.add_column("signals", sa.Column("title", sa.String(length=200), nullable=True)) + op.add_column("signals", sa.Column("signal_type", sa.String(length=50), nullable=True)) + op.add_column("signals", sa.Column("summary", sa.Text(), nullable=True)) + op.add_column("signals", sa.Column("reasoning", sa.Text(), nullable=True)) + op.add_column("signals", sa.Column("confidence", sa.Float(), nullable=True)) + op.add_column("signals", sa.Column("recommended_action", sa.String(length=50), nullable=True)) + op.add_column("signals", sa.Column("status", sa.String(length=50), nullable=False, server_default="待处理")) + op.create_foreign_key("fk_signals_feishu_item_id", "signals", "feishu_items", ["feishu_item_id"], ["id"]) + op.create_index("ix_signals_feishu_item_id", "signals", ["feishu_item_id"]) + op.create_index("ix_signals_matched_project_id", "signals", ["matched_project_id"]) + op.create_index("ix_signals_signal_type", "signals", ["signal_type"]) + op.create_index("ix_signals_status", "signals", ["status"]) + + op.add_column("truth_nodes", sa.Column("signal_id", sa.BigInteger(), nullable=True)) + op.create_foreign_key("fk_truth_nodes_signal_id", "truth_nodes", "signals", ["signal_id"], ["id"]) + op.create_index("ix_truth_nodes_signal_id", "truth_nodes", ["signal_id"]) + + op.create_table( + "signal_edits", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("signal_id", sa.BigInteger(), sa.ForeignKey("signals.id"), nullable=False), + sa.Column("field", sa.String(length=80), nullable=False), + sa.Column("old_value", sa.Text(), nullable=True), + sa.Column("new_value", sa.Text(), nullable=True), + sa.Column("edited_by", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("edited_at", sa.TIMESTAMP(), nullable=True), + ) + op.create_index("ix_signal_edits_tenant_id", "signal_edits", ["tenant_id"]) + op.create_index("ix_signal_edits_signal_id", "signal_edits", ["signal_id"]) + op.create_index("ix_signal_edits_edited_by", "signal_edits", ["edited_by"]) + + +def downgrade(): + op.drop_index("ix_signal_edits_edited_by", table_name="signal_edits") + op.drop_index("ix_signal_edits_signal_id", table_name="signal_edits") + op.drop_index("ix_signal_edits_tenant_id", table_name="signal_edits") + op.drop_table("signal_edits") + + op.drop_index("ix_truth_nodes_signal_id", table_name="truth_nodes") + op.drop_constraint("fk_truth_nodes_signal_id", "truth_nodes", type_="foreignkey") + op.drop_column("truth_nodes", "signal_id") + + op.drop_index("ix_signals_status", table_name="signals") + op.drop_index("ix_signals_signal_type", table_name="signals") + op.drop_index("ix_signals_matched_project_id", table_name="signals") + op.drop_index("ix_signals_feishu_item_id", table_name="signals") + op.drop_constraint("fk_signals_feishu_item_id", "signals", type_="foreignkey") + op.drop_column("signals", "status") + op.drop_column("signals", "recommended_action") + op.drop_column("signals", "confidence") + op.drop_column("signals", "reasoning") + op.drop_column("signals", "summary") + op.drop_column("signals", "signal_type") + op.drop_column("signals", "title") + op.drop_column("signals", "matched_project_id") + op.drop_column("signals", "feishu_item_id") + + op.drop_index("ix_project_suggestions_created_project_id", table_name="project_suggestions") + op.drop_index("ix_project_suggestions_status", table_name="project_suggestions") + op.drop_index("ix_project_suggestions_feishu_item_id", table_name="project_suggestions") + op.drop_index("ix_project_suggestions_tenant_id", table_name="project_suggestions") + op.drop_table("project_suggestions") + + op.drop_index("ix_feishu_items_queue_visible", table_name="feishu_items") + op.drop_index("ix_feishu_items_matched_project_id", table_name="feishu_items") + op.drop_index("ix_feishu_items_route_status", table_name="feishu_items") + op.drop_index("ix_feishu_items_card_type", table_name="feishu_items") + op.drop_index("ix_feishu_items_level", table_name="feishu_items") + op.drop_index("ix_feishu_items_split_from_item_id", table_name="feishu_items") + op.drop_index("ix_feishu_items_feishu_message_id", table_name="feishu_items") + op.drop_index("ix_feishu_items_tenant_id", table_name="feishu_items") + op.drop_table("feishu_items") diff --git a/backend/alembic/versions/20260302_add_adaptive_rag_tables.py b/backend/alembic/versions/20260302_add_adaptive_rag_tables.py new file mode 100644 index 00000000..88fe529a --- /dev/null +++ b/backend/alembic/versions/20260302_add_adaptive_rag_tables.py @@ -0,0 +1,188 @@ +"""add adaptive rag tables + +Revision ID: add_adaptive_rag_tables +Revises: add_feishu_queue_sstc_links +Create Date: 2026-03-02 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +try: + from sqlalchemy.dialects.postgresql import JSONB +except Exception: # pragma: no cover + JSONB = sa.JSON + +try: + from pgvector.sqlalchemy import Vector +except Exception: # pragma: no cover + Vector = None + +# revision identifiers, used by Alembic. +revision = "add_adaptive_rag_tables" +down_revision = "add_feishu_queue_sstc_links" +branch_labels = None +depends_on = None + + +def _json_type(bind): + return JSONB() if bind.dialect.name == "postgresql" else sa.JSON() + + +def _embedding_type(bind): + if bind.dialect.name == "postgresql" and Vector is not None: + return Vector(768) + return sa.Text() + + +def _table_exists(bind, table_name: str) -> bool: + inspector = sa.inspect(bind) + return inspector.has_table(table_name) + + +def _index_exists(bind, table_name: str, index_name: str) -> bool: + inspector = sa.inspect(bind) + indexes = inspector.get_indexes(table_name) + return any(idx.get("name") == index_name for idx in indexes) + + +def _create_index_if_not_exists(bind, index_name: str, table_name: str, columns: list[str]): + if not _index_exists(bind, table_name, index_name): + op.create_index(index_name, table_name, columns) + + +def _drop_index_if_exists(bind, index_name: str, table_name: str): + if _index_exists(bind, table_name, index_name): + op.drop_index(index_name, table_name=table_name) + + +def upgrade(): + bind = op.get_bind() + + if not _table_exists(bind, "knowledge_base_docs"): + op.create_table( + "knowledge_base_docs", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("file_type", sa.String(length=32), nullable=False), + sa.Column("source_type", sa.String(length=32), nullable=False, server_default="file"), + sa.Column("file_url", sa.String(length=1024), nullable=True), + sa.Column("uploaded_by", sa.BigInteger(), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False, server_default="processing"), + sa.Column("last_fetched_at", sa.TIMESTAMP(), nullable=True), + sa.Column("error_message", sa.String(length=500), nullable=True), + ) + + _create_index_if_not_exists(bind, "ix_knowledge_base_docs_tenant_id", "knowledge_base_docs", ["tenant_id"]) + _create_index_if_not_exists(bind, "ix_knowledge_base_docs_created_at", "knowledge_base_docs", ["created_at"]) + _create_index_if_not_exists(bind, "ix_knowledge_base_docs_source_type", "knowledge_base_docs", ["source_type"]) + _create_index_if_not_exists(bind, "ix_knowledge_base_docs_status", "knowledge_base_docs", ["status"]) + + if not _table_exists(bind, "knowledge_base_chunks"): + op.create_table( + "knowledge_base_chunks", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("doc_id", sa.Integer(), sa.ForeignKey("knowledge_base_docs.id", ondelete="CASCADE"), nullable=True), + sa.Column("source_type", sa.String(length=32), nullable=False, server_default="doc"), + sa.Column("project_id", sa.BigInteger(), nullable=True), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("embedding", _embedding_type(bind), nullable=True), + sa.Column("chunk_index", sa.Integer(), nullable=False, server_default="0"), + sa.Column("metadata", _json_type(bind), nullable=True), + ) + + _create_index_if_not_exists(bind, "ix_knowledge_base_chunks_tenant_id", "knowledge_base_chunks", ["tenant_id"]) + _create_index_if_not_exists(bind, "ix_knowledge_base_chunks_created_at", "knowledge_base_chunks", ["created_at"]) + _create_index_if_not_exists(bind, "ix_knowledge_base_chunks_doc_id", "knowledge_base_chunks", ["doc_id"]) + _create_index_if_not_exists(bind, "ix_knowledge_base_chunks_source_type", "knowledge_base_chunks", ["source_type"]) + _create_index_if_not_exists(bind, "ix_knowledge_base_chunks_project_id", "knowledge_base_chunks", ["project_id"]) + + if not _table_exists(bind, "qa_records"): + op.create_table( + "qa_records", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("question", sa.Text(), nullable=False), + sa.Column("answer", sa.Text(), nullable=False), + sa.Column("sources", _json_type(bind), nullable=True), + sa.Column("intent", sa.String(length=20), nullable=False, server_default="question"), + sa.Column("domains_used", _json_type(bind), nullable=True), + sa.Column("response_time_ms", sa.Integer(), nullable=True), + sa.Column("asked_by", sa.BigInteger(), nullable=True), + sa.Column("asked_via", sa.String(length=20), nullable=False, server_default="pmflow"), + sa.Column("project_id", sa.BigInteger(), nullable=True), + ) + + _create_index_if_not_exists(bind, "ix_qa_records_tenant_id", "qa_records", ["tenant_id"]) + _create_index_if_not_exists(bind, "ix_qa_records_created_at", "qa_records", ["created_at"]) + _create_index_if_not_exists(bind, "ix_qa_records_asked_by", "qa_records", ["asked_by"]) + _create_index_if_not_exists(bind, "ix_qa_records_asked_via", "qa_records", ["asked_via"]) + _create_index_if_not_exists(bind, "ix_qa_records_project_id", "qa_records", ["project_id"]) + _create_index_if_not_exists(bind, "ix_qa_records_intent", "qa_records", ["intent"]) + + if not _table_exists(bind, "qa_feedback"): + op.create_table( + "qa_feedback", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("qa_id", sa.Integer(), sa.ForeignKey("qa_records.id", ondelete="CASCADE"), nullable=False), + sa.Column("rating", sa.String(length=16), nullable=False), + sa.Column("helpfulness", sa.String(length=32), nullable=True), + sa.Column("error_types", _json_type(bind), nullable=True), + sa.Column("correct_answer", sa.Text(), nullable=True), + sa.Column("submitted_by", sa.BigInteger(), nullable=True), + ) + + _create_index_if_not_exists(bind, "ix_qa_feedback_tenant_id", "qa_feedback", ["tenant_id"]) + _create_index_if_not_exists(bind, "ix_qa_feedback_created_at", "qa_feedback", ["created_at"]) + _create_index_if_not_exists(bind, "ix_qa_feedback_qa_id", "qa_feedback", ["qa_id"]) + _create_index_if_not_exists(bind, "ix_qa_feedback_rating", "qa_feedback", ["rating"]) + _create_index_if_not_exists(bind, "ix_qa_feedback_submitted_by", "qa_feedback", ["submitted_by"]) + + +def downgrade(): + bind = op.get_bind() + + if _table_exists(bind, "qa_feedback"): + _drop_index_if_exists(bind, "ix_qa_feedback_submitted_by", "qa_feedback") + _drop_index_if_exists(bind, "ix_qa_feedback_rating", "qa_feedback") + _drop_index_if_exists(bind, "ix_qa_feedback_qa_id", "qa_feedback") + _drop_index_if_exists(bind, "ix_qa_feedback_created_at", "qa_feedback") + _drop_index_if_exists(bind, "ix_qa_feedback_tenant_id", "qa_feedback") + op.drop_table("qa_feedback") + + if _table_exists(bind, "qa_records"): + _drop_index_if_exists(bind, "ix_qa_records_intent", "qa_records") + _drop_index_if_exists(bind, "ix_qa_records_project_id", "qa_records") + _drop_index_if_exists(bind, "ix_qa_records_asked_via", "qa_records") + _drop_index_if_exists(bind, "ix_qa_records_asked_by", "qa_records") + _drop_index_if_exists(bind, "ix_qa_records_created_at", "qa_records") + _drop_index_if_exists(bind, "ix_qa_records_tenant_id", "qa_records") + op.drop_table("qa_records") + + if _table_exists(bind, "knowledge_base_chunks"): + _drop_index_if_exists(bind, "ix_knowledge_base_chunks_project_id", "knowledge_base_chunks") + _drop_index_if_exists(bind, "ix_knowledge_base_chunks_source_type", "knowledge_base_chunks") + _drop_index_if_exists(bind, "ix_knowledge_base_chunks_doc_id", "knowledge_base_chunks") + _drop_index_if_exists(bind, "ix_knowledge_base_chunks_created_at", "knowledge_base_chunks") + _drop_index_if_exists(bind, "ix_knowledge_base_chunks_tenant_id", "knowledge_base_chunks") + op.drop_table("knowledge_base_chunks") + + if _table_exists(bind, "knowledge_base_docs"): + _drop_index_if_exists(bind, "ix_knowledge_base_docs_status", "knowledge_base_docs") + _drop_index_if_exists(bind, "ix_knowledge_base_docs_source_type", "knowledge_base_docs") + _drop_index_if_exists(bind, "ix_knowledge_base_docs_created_at", "knowledge_base_docs") + _drop_index_if_exists(bind, "ix_knowledge_base_docs_tenant_id", "knowledge_base_docs") + op.drop_table("knowledge_base_docs") diff --git a/backend/alembic/versions/20260302_add_kg_sync_runs.py b/backend/alembic/versions/20260302_add_kg_sync_runs.py new file mode 100644 index 00000000..c7b7e75d --- /dev/null +++ b/backend/alembic/versions/20260302_add_kg_sync_runs.py @@ -0,0 +1,91 @@ +"""add kg sync runs table + +Revision ID: add_kg_sync_runs +Revises: add_qa_session_state_table +Create Date: 2026-03-02 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +try: + from sqlalchemy.dialects.postgresql import JSONB +except Exception: # pragma: no cover + JSONB = sa.JSON + + +# revision identifiers, used by Alembic. +revision = "add_kg_sync_runs" +down_revision = "add_qa_session_state_table" +branch_labels = None +depends_on = None + + +def _json_type(bind): + return JSONB() if bind.dialect.name == "postgresql" else sa.JSON() + + +def _table_exists(bind, table_name: str) -> bool: + inspector = sa.inspect(bind) + return inspector.has_table(table_name) + + +def _index_exists(bind, table_name: str, index_name: str) -> bool: + inspector = sa.inspect(bind) + indexes = inspector.get_indexes(table_name) + return any(idx.get("name") == index_name for idx in indexes) + + +def _create_index_if_not_exists(bind, index_name: str, table_name: str, columns: list[str]) -> None: + if not _index_exists(bind, table_name, index_name): + op.create_index(index_name, table_name, columns) + + +def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None: + if _index_exists(bind, table_name, index_name): + op.drop_index(index_name, table_name=table_name) + + +def upgrade() -> None: + bind = op.get_bind() + + if not _table_exists(bind, "kg_sync_runs"): + op.create_table( + "kg_sync_runs", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("run_type", sa.String(length=40), nullable=False), + sa.Column("trigger_source", sa.String(length=40), nullable=False, server_default="system"), + sa.Column("status", sa.String(length=20), nullable=False, server_default="running"), + sa.Column("started_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("ended_at", sa.TIMESTAMP(), nullable=True), + sa.Column("duration_ms", sa.Integer(), nullable=True), + sa.Column("input_payload", _json_type(bind), nullable=True), + sa.Column("result_payload", _json_type(bind), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + ) + + _create_index_if_not_exists(bind, "ix_kg_sync_runs_tenant_id", "kg_sync_runs", ["tenant_id"]) + _create_index_if_not_exists(bind, "ix_kg_sync_runs_run_type", "kg_sync_runs", ["run_type"]) + _create_index_if_not_exists(bind, "ix_kg_sync_runs_trigger_source", "kg_sync_runs", ["trigger_source"]) + _create_index_if_not_exists(bind, "ix_kg_sync_runs_status", "kg_sync_runs", ["status"]) + _create_index_if_not_exists(bind, "ix_kg_sync_runs_started_at", "kg_sync_runs", ["started_at"]) + _create_index_if_not_exists(bind, "ix_kg_sync_runs_ended_at", "kg_sync_runs", ["ended_at"]) + + +def downgrade() -> None: + bind = op.get_bind() + if not _table_exists(bind, "kg_sync_runs"): + return + + _drop_index_if_exists(bind, "ix_kg_sync_runs_ended_at", "kg_sync_runs") + _drop_index_if_exists(bind, "ix_kg_sync_runs_started_at", "kg_sync_runs") + _drop_index_if_exists(bind, "ix_kg_sync_runs_status", "kg_sync_runs") + _drop_index_if_exists(bind, "ix_kg_sync_runs_trigger_source", "kg_sync_runs") + _drop_index_if_exists(bind, "ix_kg_sync_runs_run_type", "kg_sync_runs") + _drop_index_if_exists(bind, "ix_kg_sync_runs_tenant_id", "kg_sync_runs") + op.drop_table("kg_sync_runs") diff --git a/backend/alembic/versions/20260302_add_project_knowledge_graph_tables.py b/backend/alembic/versions/20260302_add_project_knowledge_graph_tables.py new file mode 100644 index 00000000..3ddf6bbb --- /dev/null +++ b/backend/alembic/versions/20260302_add_project_knowledge_graph_tables.py @@ -0,0 +1,215 @@ +"""add project knowledge graph tables + +Revision ID: add_project_knowledge_graph_tables +Revises: add_qa_sessions_and_turns +Create Date: 2026-03-02 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +try: + from sqlalchemy.dialects.postgresql import JSONB +except Exception: # pragma: no cover + JSONB = sa.JSON + + +# revision identifiers, used by Alembic. +revision = "add_project_knowledge_graph_tables" +down_revision = "add_qa_sessions_and_turns" +branch_labels = None +depends_on = None + + +def _json_type(bind): + return JSONB() if bind.dialect.name == "postgresql" else sa.JSON() + + +def _table_exists(bind, table_name: str) -> bool: + inspector = sa.inspect(bind) + return inspector.has_table(table_name) + + +def _index_exists(bind, table_name: str, index_name: str) -> bool: + inspector = sa.inspect(bind) + indexes = inspector.get_indexes(table_name) + return any(idx.get("name") == index_name for idx in indexes) + + +def _create_index_if_not_exists(bind, index_name: str, table_name: str, columns: list[str]) -> None: + if not _index_exists(bind, table_name, index_name): + op.create_index(index_name, table_name, columns) + + +def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None: + if _index_exists(bind, table_name, index_name): + op.drop_index(index_name, table_name=table_name) + + +def upgrade() -> None: + bind = op.get_bind() + + if not _table_exists(bind, "kg_entities"): + op.create_table( + "kg_entities", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("entity_type", sa.String(length=40), nullable=False), + sa.Column("entity_key", sa.String(length=255), nullable=False), + sa.Column("display_name", sa.String(length=255), nullable=False), + sa.Column("payload", _json_type(bind), nullable=True), + sa.Column("source_type", sa.String(length=32), nullable=False, server_default="system"), + sa.Column("source_ref", sa.String(length=255), nullable=True), + sa.Column("last_seen_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("tenant_id", "entity_type", "entity_key", name="uq_kg_entities_tenant_type_key"), + ) + + _create_index_if_not_exists(bind, "ix_kg_entities_tenant_id", "kg_entities", ["tenant_id"]) + _create_index_if_not_exists(bind, "ix_kg_entities_entity_type", "kg_entities", ["entity_type"]) + _create_index_if_not_exists(bind, "ix_kg_entities_entity_key", "kg_entities", ["entity_key"]) + _create_index_if_not_exists(bind, "ix_kg_entities_source_type", "kg_entities", ["source_type"]) + _create_index_if_not_exists(bind, "ix_kg_entities_last_seen_at", "kg_entities", ["last_seen_at"]) + + if not _table_exists(bind, "kg_relations"): + op.create_table( + "kg_relations", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("relation_type", sa.String(length=40), nullable=False), + sa.Column("subject_entity_id", sa.Integer(), sa.ForeignKey("kg_entities.id", ondelete="CASCADE"), nullable=False), + sa.Column("object_entity_id", sa.Integer(), sa.ForeignKey("kg_entities.id", ondelete="CASCADE"), nullable=False), + sa.Column("weight", sa.Float(), nullable=False, server_default="1"), + sa.Column("payload", _json_type(bind), nullable=True), + sa.Column("source_type", sa.String(length=32), nullable=False, server_default="system"), + sa.Column("source_ref", sa.String(length=255), nullable=True), + sa.UniqueConstraint( + "tenant_id", + "relation_type", + "subject_entity_id", + "object_entity_id", + name="uq_kg_relations_tenant_type_subject_object", + ), + ) + + _create_index_if_not_exists(bind, "ix_kg_relations_tenant_id", "kg_relations", ["tenant_id"]) + _create_index_if_not_exists(bind, "ix_kg_relations_relation_type", "kg_relations", ["relation_type"]) + _create_index_if_not_exists(bind, "ix_kg_relations_subject_entity_id", "kg_relations", ["subject_entity_id"]) + _create_index_if_not_exists(bind, "ix_kg_relations_object_entity_id", "kg_relations", ["object_entity_id"]) + _create_index_if_not_exists(bind, "ix_kg_relations_source_type", "kg_relations", ["source_type"]) + + if not _table_exists(bind, "kg_evidences"): + op.create_table( + "kg_evidences", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("entity_id", sa.Integer(), sa.ForeignKey("kg_entities.id", ondelete="CASCADE"), nullable=True), + sa.Column("relation_id", sa.Integer(), sa.ForeignKey("kg_relations.id", ondelete="CASCADE"), nullable=True), + sa.Column("evidence_type", sa.String(length=40), nullable=False), + sa.Column("content_hash", sa.String(length=64), nullable=False), + sa.Column("content_excerpt", sa.Text(), nullable=False), + sa.Column("source_type", sa.String(length=32), nullable=False, server_default="system"), + sa.Column("source_ref", sa.String(length=255), nullable=True), + sa.Column("metadata", _json_type(bind), nullable=True), + sa.Column("occurred_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + ) + + _create_index_if_not_exists(bind, "ix_kg_evidences_tenant_id", "kg_evidences", ["tenant_id"]) + _create_index_if_not_exists(bind, "ix_kg_evidences_entity_id", "kg_evidences", ["entity_id"]) + _create_index_if_not_exists(bind, "ix_kg_evidences_relation_id", "kg_evidences", ["relation_id"]) + _create_index_if_not_exists(bind, "ix_kg_evidences_evidence_type", "kg_evidences", ["evidence_type"]) + _create_index_if_not_exists(bind, "ix_kg_evidences_content_hash", "kg_evidences", ["content_hash"]) + _create_index_if_not_exists(bind, "ix_kg_evidences_source_type", "kg_evidences", ["source_type"]) + _create_index_if_not_exists(bind, "ix_kg_evidences_occurred_at", "kg_evidences", ["occurred_at"]) + + if not _table_exists(bind, "ingest_events"): + op.create_table( + "ingest_events", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("channel", sa.String(length=32), nullable=False), + sa.Column("event_key", sa.String(length=128), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False, server_default="pending"), + sa.Column("payload", _json_type(bind), nullable=True), + sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("processed_at", sa.TIMESTAMP(), nullable=True), + sa.UniqueConstraint("tenant_id", "channel", "event_key", name="uq_ingest_events_tenant_channel_key"), + ) + + _create_index_if_not_exists(bind, "ix_ingest_events_tenant_id", "ingest_events", ["tenant_id"]) + _create_index_if_not_exists(bind, "ix_ingest_events_channel", "ingest_events", ["channel"]) + _create_index_if_not_exists(bind, "ix_ingest_events_event_key", "ingest_events", ["event_key"]) + _create_index_if_not_exists(bind, "ix_ingest_events_status", "ingest_events", ["status"]) + _create_index_if_not_exists(bind, "ix_ingest_events_processed_at", "ingest_events", ["processed_at"]) + + if not _table_exists(bind, "ingest_offsets"): + op.create_table( + "ingest_offsets", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("channel", sa.String(length=32), nullable=False), + sa.Column("offset_key", sa.String(length=128), nullable=False), + sa.Column("offset_value", sa.String(length=255), nullable=False), + sa.Column("payload", _json_type(bind), nullable=True), + sa.UniqueConstraint("tenant_id", "channel", "offset_key", name="uq_ingest_offsets_tenant_channel_key"), + ) + + _create_index_if_not_exists(bind, "ix_ingest_offsets_tenant_id", "ingest_offsets", ["tenant_id"]) + _create_index_if_not_exists(bind, "ix_ingest_offsets_channel", "ingest_offsets", ["channel"]) + _create_index_if_not_exists(bind, "ix_ingest_offsets_offset_key", "ingest_offsets", ["offset_key"]) + + +def downgrade() -> None: + bind = op.get_bind() + + if _table_exists(bind, "ingest_offsets"): + _drop_index_if_exists(bind, "ix_ingest_offsets_offset_key", "ingest_offsets") + _drop_index_if_exists(bind, "ix_ingest_offsets_channel", "ingest_offsets") + _drop_index_if_exists(bind, "ix_ingest_offsets_tenant_id", "ingest_offsets") + op.drop_table("ingest_offsets") + + if _table_exists(bind, "ingest_events"): + _drop_index_if_exists(bind, "ix_ingest_events_processed_at", "ingest_events") + _drop_index_if_exists(bind, "ix_ingest_events_status", "ingest_events") + _drop_index_if_exists(bind, "ix_ingest_events_event_key", "ingest_events") + _drop_index_if_exists(bind, "ix_ingest_events_channel", "ingest_events") + _drop_index_if_exists(bind, "ix_ingest_events_tenant_id", "ingest_events") + op.drop_table("ingest_events") + + if _table_exists(bind, "kg_evidences"): + _drop_index_if_exists(bind, "ix_kg_evidences_occurred_at", "kg_evidences") + _drop_index_if_exists(bind, "ix_kg_evidences_source_type", "kg_evidences") + _drop_index_if_exists(bind, "ix_kg_evidences_content_hash", "kg_evidences") + _drop_index_if_exists(bind, "ix_kg_evidences_evidence_type", "kg_evidences") + _drop_index_if_exists(bind, "ix_kg_evidences_relation_id", "kg_evidences") + _drop_index_if_exists(bind, "ix_kg_evidences_entity_id", "kg_evidences") + _drop_index_if_exists(bind, "ix_kg_evidences_tenant_id", "kg_evidences") + op.drop_table("kg_evidences") + + if _table_exists(bind, "kg_relations"): + _drop_index_if_exists(bind, "ix_kg_relations_source_type", "kg_relations") + _drop_index_if_exists(bind, "ix_kg_relations_object_entity_id", "kg_relations") + _drop_index_if_exists(bind, "ix_kg_relations_subject_entity_id", "kg_relations") + _drop_index_if_exists(bind, "ix_kg_relations_relation_type", "kg_relations") + _drop_index_if_exists(bind, "ix_kg_relations_tenant_id", "kg_relations") + op.drop_table("kg_relations") + + if _table_exists(bind, "kg_entities"): + _drop_index_if_exists(bind, "ix_kg_entities_last_seen_at", "kg_entities") + _drop_index_if_exists(bind, "ix_kg_entities_source_type", "kg_entities") + _drop_index_if_exists(bind, "ix_kg_entities_entity_key", "kg_entities") + _drop_index_if_exists(bind, "ix_kg_entities_entity_type", "kg_entities") + _drop_index_if_exists(bind, "ix_kg_entities_tenant_id", "kg_entities") + op.drop_table("kg_entities") diff --git a/backend/alembic/versions/20260302_add_qa_session_state_table.py b/backend/alembic/versions/20260302_add_qa_session_state_table.py new file mode 100644 index 00000000..230a02e7 --- /dev/null +++ b/backend/alembic/versions/20260302_add_qa_session_state_table.py @@ -0,0 +1,88 @@ +"""add qa session state table + +Revision ID: add_qa_session_state_table +Revises: add_project_knowledge_graph_tables +Create Date: 2026-03-02 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +try: + from sqlalchemy.dialects.postgresql import JSONB +except Exception: # pragma: no cover + JSONB = sa.JSON + + +# revision identifiers, used by Alembic. +revision = "add_qa_session_state_table" +down_revision = "add_project_knowledge_graph_tables" +branch_labels = None +depends_on = None + + +def _json_type(bind): + return JSONB() if bind.dialect.name == "postgresql" else sa.JSON() + + +def _table_exists(bind, table_name: str) -> bool: + inspector = sa.inspect(bind) + return inspector.has_table(table_name) + + +def _index_exists(bind, table_name: str, index_name: str) -> bool: + inspector = sa.inspect(bind) + indexes = inspector.get_indexes(table_name) + return any(idx.get("name") == index_name for idx in indexes) + + +def _create_index_if_not_exists(bind, index_name: str, table_name: str, columns: list[str]) -> None: + if not _index_exists(bind, table_name, index_name): + op.create_index(index_name, table_name, columns) + + +def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None: + if _index_exists(bind, table_name, index_name): + op.drop_index(index_name, table_name=table_name) + + +def upgrade() -> None: + bind = op.get_bind() + + if not _table_exists(bind, "qa_session_states"): + op.create_table( + "qa_session_states", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("session_id", sa.Integer(), sa.ForeignKey("qa_sessions.id", ondelete="CASCADE"), nullable=False, unique=True), + sa.Column("last_subject", sa.String(length=255), nullable=True), + sa.Column("last_entity_ids", _json_type(bind), nullable=True), + sa.Column("last_intent", sa.String(length=20), nullable=True), + sa.Column("ambiguity", sa.String(length=20), nullable=True), + sa.Column("rewrite_history", _json_type(bind), nullable=True), + ) + + _create_index_if_not_exists(bind, "ix_qa_session_states_tenant_id", "qa_session_states", ["tenant_id"]) + _create_index_if_not_exists(bind, "ix_qa_session_states_created_at", "qa_session_states", ["created_at"]) + _create_index_if_not_exists(bind, "ix_qa_session_states_session_id", "qa_session_states", ["session_id"]) + _create_index_if_not_exists(bind, "ix_qa_session_states_last_subject", "qa_session_states", ["last_subject"]) + _create_index_if_not_exists(bind, "ix_qa_session_states_last_intent", "qa_session_states", ["last_intent"]) + _create_index_if_not_exists(bind, "ix_qa_session_states_ambiguity", "qa_session_states", ["ambiguity"]) + + +def downgrade() -> None: + bind = op.get_bind() + if not _table_exists(bind, "qa_session_states"): + return + + _drop_index_if_exists(bind, "ix_qa_session_states_ambiguity", "qa_session_states") + _drop_index_if_exists(bind, "ix_qa_session_states_last_intent", "qa_session_states") + _drop_index_if_exists(bind, "ix_qa_session_states_last_subject", "qa_session_states") + _drop_index_if_exists(bind, "ix_qa_session_states_session_id", "qa_session_states") + _drop_index_if_exists(bind, "ix_qa_session_states_created_at", "qa_session_states") + _drop_index_if_exists(bind, "ix_qa_session_states_tenant_id", "qa_session_states") + op.drop_table("qa_session_states") diff --git a/backend/alembic/versions/20260302_add_qa_sessions_and_turns.py b/backend/alembic/versions/20260302_add_qa_sessions_and_turns.py new file mode 100644 index 00000000..3c1c2189 --- /dev/null +++ b/backend/alembic/versions/20260302_add_qa_sessions_and_turns.py @@ -0,0 +1,139 @@ +"""add qa sessions and session fields + +Revision ID: add_qa_sessions_and_turns +Revises: add_adaptive_rag_tables +Create Date: 2026-03-02 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "add_qa_sessions_and_turns" +down_revision = "add_adaptive_rag_tables" +branch_labels = None +depends_on = None + + +def _table_exists(bind, table_name: str) -> bool: + inspector = sa.inspect(bind) + return inspector.has_table(table_name) + + +def _index_exists(bind, table_name: str, index_name: str) -> bool: + inspector = sa.inspect(bind) + indexes = inspector.get_indexes(table_name) + return any(idx.get("name") == index_name for idx in indexes) + + +def _column_exists(bind, table_name: str, column_name: str) -> bool: + inspector = sa.inspect(bind) + try: + columns = inspector.get_columns(table_name) + except Exception: + return False + return any(str(col.get("name")) == column_name for col in columns) + + +def _fk_exists(bind, table_name: str, constrained_columns: list[str]) -> bool: + inspector = sa.inspect(bind) + try: + fks = inspector.get_foreign_keys(table_name) + except Exception: + return False + expected = tuple(constrained_columns) + for fk in fks: + cols = tuple(fk.get("constrained_columns") or []) + if cols == expected: + return True + return False + + +def _create_index_if_not_exists(bind, index_name: str, table_name: str, columns: list[str]) -> None: + if not _index_exists(bind, table_name, index_name): + op.create_index(index_name, table_name, columns) + + +def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None: + if _index_exists(bind, table_name, index_name): + op.drop_index(index_name, table_name=table_name) + + +def upgrade() -> None: + bind = op.get_bind() + dialect_name = str(bind.dialect.name or "").lower() + + if not _table_exists(bind, "qa_sessions"): + op.create_table( + "qa_sessions", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("user_id", sa.BigInteger(), nullable=True), + sa.Column("title", sa.String(length=120), nullable=False, server_default="新会话"), + sa.Column("project_id", sa.BigInteger(), nullable=True), + sa.Column("started_at", sa.TIMESTAMP(), nullable=True), + sa.Column("last_message_at", sa.TIMESTAMP(), nullable=True), + sa.Column("message_count", sa.Integer(), nullable=False, server_default="0"), + ) + + _create_index_if_not_exists(bind, "ix_qa_sessions_tenant_id", "qa_sessions", ["tenant_id"]) + _create_index_if_not_exists(bind, "ix_qa_sessions_created_at", "qa_sessions", ["created_at"]) + _create_index_if_not_exists(bind, "ix_qa_sessions_user_id", "qa_sessions", ["user_id"]) + _create_index_if_not_exists(bind, "ix_qa_sessions_project_id", "qa_sessions", ["project_id"]) + _create_index_if_not_exists(bind, "ix_qa_sessions_started_at", "qa_sessions", ["started_at"]) + _create_index_if_not_exists(bind, "ix_qa_sessions_last_message_at", "qa_sessions", ["last_message_at"]) + + if _table_exists(bind, "qa_records"): + if not _column_exists(bind, "qa_records", "session_id"): + op.add_column("qa_records", sa.Column("session_id", sa.Integer(), nullable=True)) + if not _column_exists(bind, "qa_records", "turn_index"): + op.add_column( + "qa_records", + sa.Column("turn_index", sa.Integer(), nullable=False, server_default="1"), + ) + _create_index_if_not_exists(bind, "ix_qa_records_session_id", "qa_records", ["session_id"]) + _create_index_if_not_exists(bind, "ix_qa_records_turn_index", "qa_records", ["turn_index"]) + + if ( + dialect_name != "sqlite" + and _table_exists(bind, "qa_sessions") + and not _fk_exists(bind, "qa_records", ["session_id"]) + ): + op.create_foreign_key( + "fk_qa_records_session_id", + "qa_records", + "qa_sessions", + ["session_id"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade() -> None: + bind = op.get_bind() + dialect_name = str(bind.dialect.name or "").lower() + + if _table_exists(bind, "qa_records"): + if dialect_name != "sqlite" and _fk_exists(bind, "qa_records", ["session_id"]): + op.drop_constraint("fk_qa_records_session_id", "qa_records", type_="foreignkey") + _drop_index_if_exists(bind, "ix_qa_records_turn_index", "qa_records") + _drop_index_if_exists(bind, "ix_qa_records_session_id", "qa_records") + if _column_exists(bind, "qa_records", "turn_index") or _column_exists(bind, "qa_records", "session_id"): + with op.batch_alter_table("qa_records") as batch_op: + if _column_exists(bind, "qa_records", "turn_index"): + batch_op.drop_column("turn_index") + if _column_exists(bind, "qa_records", "session_id"): + batch_op.drop_column("session_id") + + if _table_exists(bind, "qa_sessions"): + _drop_index_if_exists(bind, "ix_qa_sessions_last_message_at", "qa_sessions") + _drop_index_if_exists(bind, "ix_qa_sessions_started_at", "qa_sessions") + _drop_index_if_exists(bind, "ix_qa_sessions_project_id", "qa_sessions") + _drop_index_if_exists(bind, "ix_qa_sessions_user_id", "qa_sessions") + _drop_index_if_exists(bind, "ix_qa_sessions_created_at", "qa_sessions") + _drop_index_if_exists(bind, "ix_qa_sessions_tenant_id", "qa_sessions") + op.drop_table("qa_sessions") diff --git a/backend/alembic/versions/20260304_add_symbol_index_tables.py b/backend/alembic/versions/20260304_add_symbol_index_tables.py new file mode 100644 index 00000000..51e10069 --- /dev/null +++ b/backend/alembic/versions/20260304_add_symbol_index_tables.py @@ -0,0 +1,155 @@ +"""add symbol index tables + +Revision ID: add_symbol_index_tables +Revises: add_kg_sync_runs +Create Date: 2026-03-04 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +try: + from sqlalchemy.dialects.postgresql import JSONB +except Exception: # pragma: no cover + JSONB = sa.JSON + + +# revision identifiers, used by Alembic. +revision = "add_symbol_index_tables" +down_revision = "add_kg_sync_runs" +branch_labels = None +depends_on = None + + +def _json_type(bind): + return JSONB() if bind.dialect.name == "postgresql" else sa.JSON() + + +def _table_exists(bind, table_name: str) -> bool: + inspector = sa.inspect(bind) + return inspector.has_table(table_name) + + +def _index_exists(bind, table_name: str, index_name: str) -> bool: + inspector = sa.inspect(bind) + indexes = inspector.get_indexes(table_name) + return any(idx.get("name") == index_name for idx in indexes) + + +def _create_index_if_not_exists(bind, index_name: str, table_name: str, columns: list[str]) -> None: + if not _index_exists(bind, table_name, index_name): + op.create_index(index_name, table_name, columns) + + +def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None: + if _index_exists(bind, table_name, index_name): + op.drop_index(index_name, table_name=table_name) + + +def upgrade() -> None: + bind = op.get_bind() + + if not _table_exists(bind, "symbol_definitions"): + op.create_table( + "symbol_definitions", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("file_path", sa.String(length=600), nullable=False), + sa.Column("language", sa.String(length=20), nullable=False), + sa.Column("symbol_type", sa.String(length=32), nullable=False), + sa.Column("symbol_name", sa.String(length=255), nullable=False), + sa.Column("qualified_name", sa.String(length=700), nullable=True), + sa.Column("start_line", sa.Integer(), nullable=True), + sa.Column("end_line", sa.Integer(), nullable=True), + sa.Column("signature", sa.String(length=1000), nullable=True), + sa.Column("docstring", sa.String(length=2000), nullable=True), + sa.Column("file_hash", sa.String(length=64), nullable=True), + sa.Column("source_mtime", sa.TIMESTAMP(), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False, server_default="active"), + sa.Column("payload", _json_type(bind), nullable=True), + sa.Column("last_indexed_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint( + "tenant_id", + "file_path", + "symbol_type", + "symbol_name", + "start_line", + name="uq_symbol_definitions_tenant_file_type_name_line", + ), + ) + + _create_index_if_not_exists(bind, "ix_symbol_definitions_tenant_id", "symbol_definitions", ["tenant_id"]) + _create_index_if_not_exists(bind, "ix_symbol_definitions_file_path", "symbol_definitions", ["file_path"]) + _create_index_if_not_exists(bind, "ix_symbol_definitions_language", "symbol_definitions", ["language"]) + _create_index_if_not_exists(bind, "ix_symbol_definitions_symbol_type", "symbol_definitions", ["symbol_type"]) + _create_index_if_not_exists(bind, "ix_symbol_definitions_symbol_name", "symbol_definitions", ["symbol_name"]) + _create_index_if_not_exists(bind, "ix_symbol_definitions_qualified_name", "symbol_definitions", ["qualified_name"]) + _create_index_if_not_exists(bind, "ix_symbol_definitions_start_line", "symbol_definitions", ["start_line"]) + _create_index_if_not_exists(bind, "ix_symbol_definitions_file_hash", "symbol_definitions", ["file_hash"]) + _create_index_if_not_exists(bind, "ix_symbol_definitions_status", "symbol_definitions", ["status"]) + _create_index_if_not_exists(bind, "ix_symbol_definitions_last_indexed_at", "symbol_definitions", ["last_indexed_at"]) + + if not _table_exists(bind, "symbol_references"): + op.create_table( + "symbol_references", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("source_symbol_id", sa.Integer(), sa.ForeignKey("symbol_definitions.id", ondelete="CASCADE"), nullable=True), + sa.Column("file_path", sa.String(length=600), nullable=False), + sa.Column("relation_type", sa.String(length=32), nullable=False), + sa.Column("target_symbol_name", sa.String(length=255), nullable=False), + sa.Column("target_qualified_name", sa.String(length=700), nullable=True), + sa.Column("start_line", sa.Integer(), nullable=True), + sa.Column("end_line", sa.Integer(), nullable=True), + sa.Column("payload", _json_type(bind), nullable=True), + sa.UniqueConstraint( + "tenant_id", + "file_path", + "relation_type", + "target_symbol_name", + "start_line", + "source_symbol_id", + name="uq_symbol_references_tenant_file_relation_target_line_source", + ), + ) + + _create_index_if_not_exists(bind, "ix_symbol_references_tenant_id", "symbol_references", ["tenant_id"]) + _create_index_if_not_exists(bind, "ix_symbol_references_source_symbol_id", "symbol_references", ["source_symbol_id"]) + _create_index_if_not_exists(bind, "ix_symbol_references_file_path", "symbol_references", ["file_path"]) + _create_index_if_not_exists(bind, "ix_symbol_references_relation_type", "symbol_references", ["relation_type"]) + _create_index_if_not_exists(bind, "ix_symbol_references_target_symbol_name", "symbol_references", ["target_symbol_name"]) + _create_index_if_not_exists(bind, "ix_symbol_references_target_qualified_name", "symbol_references", ["target_qualified_name"]) + _create_index_if_not_exists(bind, "ix_symbol_references_start_line", "symbol_references", ["start_line"]) + + +def downgrade() -> None: + bind = op.get_bind() + + if _table_exists(bind, "symbol_references"): + _drop_index_if_exists(bind, "ix_symbol_references_start_line", "symbol_references") + _drop_index_if_exists(bind, "ix_symbol_references_target_qualified_name", "symbol_references") + _drop_index_if_exists(bind, "ix_symbol_references_target_symbol_name", "symbol_references") + _drop_index_if_exists(bind, "ix_symbol_references_relation_type", "symbol_references") + _drop_index_if_exists(bind, "ix_symbol_references_file_path", "symbol_references") + _drop_index_if_exists(bind, "ix_symbol_references_source_symbol_id", "symbol_references") + _drop_index_if_exists(bind, "ix_symbol_references_tenant_id", "symbol_references") + op.drop_table("symbol_references") + + if _table_exists(bind, "symbol_definitions"): + _drop_index_if_exists(bind, "ix_symbol_definitions_last_indexed_at", "symbol_definitions") + _drop_index_if_exists(bind, "ix_symbol_definitions_status", "symbol_definitions") + _drop_index_if_exists(bind, "ix_symbol_definitions_file_hash", "symbol_definitions") + _drop_index_if_exists(bind, "ix_symbol_definitions_start_line", "symbol_definitions") + _drop_index_if_exists(bind, "ix_symbol_definitions_qualified_name", "symbol_definitions") + _drop_index_if_exists(bind, "ix_symbol_definitions_symbol_name", "symbol_definitions") + _drop_index_if_exists(bind, "ix_symbol_definitions_symbol_type", "symbol_definitions") + _drop_index_if_exists(bind, "ix_symbol_definitions_language", "symbol_definitions") + _drop_index_if_exists(bind, "ix_symbol_definitions_file_path", "symbol_definitions") + _drop_index_if_exists(bind, "ix_symbol_definitions_tenant_id", "symbol_definitions") + op.drop_table("symbol_definitions") diff --git a/backend/alembic/versions/20260305_feishu_outbox_idempotency.py b/backend/alembic/versions/20260305_feishu_outbox_idempotency.py new file mode 100644 index 00000000..b5b54666 --- /dev/null +++ b/backend/alembic/versions/20260305_feishu_outbox_idempotency.py @@ -0,0 +1,259 @@ +"""add feishu outbox and idempotency guards + +Revision ID: feishu_outbox_idempotency +Revises: add_symbol_index_tables +Create Date: 2026-03-05 +""" + +from __future__ import annotations + +from typing import Any + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "feishu_outbox_idempotency" +down_revision = "add_symbol_index_tables" +branch_labels = None +depends_on = None + + +def _table_exists(bind, table_name: str) -> bool: + inspector = sa.inspect(bind) + return inspector.has_table(table_name) + + +def _index_exists(bind, table_name: str, index_name: str) -> bool: + inspector = sa.inspect(bind) + indexes = inspector.get_indexes(table_name) + return any(idx.get("name") == index_name for idx in indexes) + + +def _create_index_if_not_exists( + bind, + *, + index_name: str, + table_name: str, + columns: list[str], + unique: bool = False, + postgresql_where: Any = None, + sqlite_where: Any = None, +) -> None: + if _index_exists(bind, table_name, index_name): + return + kwargs: dict[str, Any] = {} + if postgresql_where is not None: + kwargs["postgresql_where"] = postgresql_where + if sqlite_where is not None: + kwargs["sqlite_where"] = sqlite_where + op.create_index(index_name, table_name, columns, unique=unique, **kwargs) + + +def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None: + if _index_exists(bind, table_name, index_name): + op.drop_index(index_name, table_name=table_name) + + +def _repoint_fk(bind, *, table_name: str, column_name: str, old_id: int, new_id: int) -> None: + if not _table_exists(bind, table_name): + return + bind.execute( + sa.text( + f"UPDATE {table_name} " + f"SET {column_name} = :new_id " + f"WHERE {column_name} = :old_id" + ), + {"new_id": int(new_id), "old_id": int(old_id)}, + ) + + +def _dedupe_feishu_items(bind) -> None: + if not _table_exists(bind, "feishu_items"): + return + + groups = bind.execute( + sa.text( + """ + SELECT tenant_id, feishu_message_id, split_from_item_id, MIN(id) AS keep_id + FROM feishu_items + WHERE feishu_message_id IS NOT NULL + GROUP BY tenant_id, feishu_message_id, split_from_item_id + HAVING COUNT(*) > 1 + """ + ) + ).fetchall() + + for row in groups: + tenant_id = int(row[0]) + message_id = str(row[1]) + split_from_item_id = row[2] + keep_id = int(row[3]) + + if split_from_item_id is None: + duplicate_rows = bind.execute( + sa.text( + """ + SELECT id + FROM feishu_items + WHERE tenant_id = :tenant_id + AND feishu_message_id = :message_id + AND split_from_item_id IS NULL + AND id <> :keep_id + ORDER BY id ASC + """ + ), + { + "tenant_id": tenant_id, + "message_id": message_id, + "keep_id": keep_id, + }, + ).fetchall() + else: + duplicate_rows = bind.execute( + sa.text( + """ + SELECT id + FROM feishu_items + WHERE tenant_id = :tenant_id + AND feishu_message_id = :message_id + AND split_from_item_id = :split_from_item_id + AND id <> :keep_id + ORDER BY id ASC + """ + ), + { + "tenant_id": tenant_id, + "message_id": message_id, + "split_from_item_id": int(split_from_item_id), + "keep_id": keep_id, + }, + ).fetchall() + + for dup_row in duplicate_rows: + duplicate_id = int(dup_row[0]) + _repoint_fk( + bind, + table_name="feishu_items", + column_name="split_from_item_id", + old_id=duplicate_id, + new_id=keep_id, + ) + _repoint_fk( + bind, + table_name="signals", + column_name="feishu_item_id", + old_id=duplicate_id, + new_id=keep_id, + ) + _repoint_fk( + bind, + table_name="project_suggestions", + column_name="feishu_item_id", + old_id=duplicate_id, + new_id=keep_id, + ) + _repoint_fk( + bind, + table_name="ai_processing_logs", + column_name="feishu_item_id", + old_id=duplicate_id, + new_id=keep_id, + ) + bind.execute( + sa.text("DELETE FROM feishu_items WHERE id = :id"), + {"id": duplicate_id}, + ) + + +def upgrade() -> None: + bind = op.get_bind() + + if not _table_exists(bind, "feishu_reply_outbox"): + op.create_table( + "feishu_reply_outbox", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("trace_id", sa.String(length=64), nullable=True), + sa.Column("chat_id", sa.String(length=128), nullable=False), + sa.Column("message_text", sa.Text(), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False, server_default="pending"), + sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"), + sa.Column("next_retry_at", sa.TIMESTAMP(), nullable=True), + sa.Column("sent_at", sa.TIMESTAMP(), nullable=True), + sa.Column("last_error", sa.Text(), nullable=True), + ) + + if _table_exists(bind, "feishu_reply_outbox"): + _create_index_if_not_exists( + bind, + index_name="ix_feishu_reply_outbox_tenant_id", + table_name="feishu_reply_outbox", + columns=["tenant_id"], + ) + _create_index_if_not_exists( + bind, + index_name="ix_feishu_reply_outbox_trace_id", + table_name="feishu_reply_outbox", + columns=["trace_id"], + ) + _create_index_if_not_exists( + bind, + index_name="ix_feishu_reply_outbox_chat_id", + table_name="feishu_reply_outbox", + columns=["chat_id"], + ) + _create_index_if_not_exists( + bind, + index_name="ix_feishu_reply_outbox_status", + table_name="feishu_reply_outbox", + columns=["status"], + ) + _create_index_if_not_exists( + bind, + index_name="ix_feishu_reply_outbox_next_retry_at", + table_name="feishu_reply_outbox", + columns=["next_retry_at"], + ) + + if _table_exists(bind, "feishu_items"): + _dedupe_feishu_items(bind) + where_root = sa.text("split_from_item_id IS NULL AND feishu_message_id IS NOT NULL") + where_split = sa.text("split_from_item_id IS NOT NULL AND feishu_message_id IS NOT NULL") + _create_index_if_not_exists( + bind, + index_name="uq_feishu_items_tenant_message_root", + table_name="feishu_items", + columns=["tenant_id", "feishu_message_id"], + unique=True, + postgresql_where=where_root, + sqlite_where=where_root, + ) + _create_index_if_not_exists( + bind, + index_name="uq_feishu_items_tenant_message_split", + table_name="feishu_items", + columns=["tenant_id", "feishu_message_id", "split_from_item_id"], + unique=True, + postgresql_where=where_split, + sqlite_where=where_split, + ) + + +def downgrade() -> None: + bind = op.get_bind() + + if _table_exists(bind, "feishu_items"): + _drop_index_if_exists(bind, "uq_feishu_items_tenant_message_split", "feishu_items") + _drop_index_if_exists(bind, "uq_feishu_items_tenant_message_root", "feishu_items") + + if _table_exists(bind, "feishu_reply_outbox"): + _drop_index_if_exists(bind, "ix_feishu_reply_outbox_next_retry_at", "feishu_reply_outbox") + _drop_index_if_exists(bind, "ix_feishu_reply_outbox_status", "feishu_reply_outbox") + _drop_index_if_exists(bind, "ix_feishu_reply_outbox_chat_id", "feishu_reply_outbox") + _drop_index_if_exists(bind, "ix_feishu_reply_outbox_trace_id", "feishu_reply_outbox") + _drop_index_if_exists(bind, "ix_feishu_reply_outbox_tenant_id", "feishu_reply_outbox") + op.drop_table("feishu_reply_outbox") diff --git a/backend/alembic/versions/20260306_add_operation_execution_ledger.py b/backend/alembic/versions/20260306_add_operation_execution_ledger.py new file mode 100644 index 00000000..02fef6c1 --- /dev/null +++ b/backend/alembic/versions/20260306_add_operation_execution_ledger.py @@ -0,0 +1,136 @@ +"""add operation execution ledger + +Revision ID: add_operation_execution_ledger +Revises: feishu_outbox_idempotency +Create Date: 2026-03-06 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "add_operation_execution_ledger" +down_revision = "feishu_outbox_idempotency" +branch_labels = None +depends_on = None + + +def _table_exists(bind, table_name: str) -> bool: + inspector = sa.inspect(bind) + return inspector.has_table(table_name) + + +def _index_exists(bind, table_name: str, index_name: str) -> bool: + inspector = sa.inspect(bind) + indexes = inspector.get_indexes(table_name) + return any(idx.get("name") == index_name for idx in indexes) + + +def _create_index_if_not_exists( + bind, + *, + index_name: str, + table_name: str, + columns: list[str], + unique: bool = False, +) -> None: + if _index_exists(bind, table_name, index_name): + return + op.create_index(index_name, table_name, columns, unique=unique) + + +def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None: + if _index_exists(bind, table_name, index_name): + op.drop_index(index_name, table_name=table_name) + + +def upgrade() -> None: + bind = op.get_bind() + + if not _table_exists(bind, "operation_execution_ledger"): + op.create_table( + "operation_execution_ledger", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("operation_code", sa.String(length=64), nullable=False), + sa.Column("project_id", sa.BigInteger(), nullable=True), + sa.Column("idempotency_key", sa.String(length=128), nullable=False), + sa.Column("trace_id", sa.String(length=64), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False, server_default="processing"), + sa.Column("payload", sa.JSON(), nullable=True), + sa.Column("result_payload", sa.JSON(), nullable=True), + sa.Column("entity_type", sa.String(length=64), nullable=True), + sa.Column("entity_id", sa.String(length=128), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"]), + sa.UniqueConstraint( + "tenant_id", + "operation_code", + "idempotency_key", + name="uq_operation_execution_ledger_key", + ), + ) + + if _table_exists(bind, "operation_execution_ledger"): + _create_index_if_not_exists( + bind, + index_name="ix_operation_execution_ledger_tenant_id", + table_name="operation_execution_ledger", + columns=["tenant_id"], + ) + _create_index_if_not_exists( + bind, + index_name="ix_operation_execution_ledger_operation_code", + table_name="operation_execution_ledger", + columns=["operation_code"], + ) + _create_index_if_not_exists( + bind, + index_name="ix_operation_execution_ledger_project_id", + table_name="operation_execution_ledger", + columns=["project_id"], + ) + _create_index_if_not_exists( + bind, + index_name="ix_operation_execution_ledger_trace_id", + table_name="operation_execution_ledger", + columns=["trace_id"], + ) + _create_index_if_not_exists( + bind, + index_name="ix_operation_execution_ledger_status", + table_name="operation_execution_ledger", + columns=["status"], + ) + _create_index_if_not_exists( + bind, + index_name="ix_operation_execution_ledger_entity_type", + table_name="operation_execution_ledger", + columns=["entity_type"], + ) + _create_index_if_not_exists( + bind, + index_name="ix_operation_execution_ledger_entity_id", + table_name="operation_execution_ledger", + columns=["entity_id"], + ) + + +def downgrade() -> None: + bind = op.get_bind() + if not _table_exists(bind, "operation_execution_ledger"): + return + + _drop_index_if_exists(bind, "ix_operation_execution_ledger_entity_id", "operation_execution_ledger") + _drop_index_if_exists(bind, "ix_operation_execution_ledger_entity_type", "operation_execution_ledger") + _drop_index_if_exists(bind, "ix_operation_execution_ledger_status", "operation_execution_ledger") + _drop_index_if_exists(bind, "ix_operation_execution_ledger_trace_id", "operation_execution_ledger") + _drop_index_if_exists(bind, "ix_operation_execution_ledger_project_id", "operation_execution_ledger") + _drop_index_if_exists(bind, "ix_operation_execution_ledger_operation_code", "operation_execution_ledger") + _drop_index_if_exists(bind, "ix_operation_execution_ledger_tenant_id", "operation_execution_ledger") + op.drop_table("operation_execution_ledger") diff --git a/backend/alembic/versions/20260306_add_signal_source_orchestration_tables.py b/backend/alembic/versions/20260306_add_signal_source_orchestration_tables.py new file mode 100644 index 00000000..f0e5a1df --- /dev/null +++ b/backend/alembic/versions/20260306_add_signal_source_orchestration_tables.py @@ -0,0 +1,179 @@ +"""add signal source orchestration tables + +Revision ID: add_signal_source_orchestration_tables +Revises: add_operation_execution_ledger +Create Date: 2026-03-06 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "add_signal_source_orchestration_tables" +down_revision = "add_operation_execution_ledger" +branch_labels = None +depends_on = None + + +def _table_exists(bind, table_name: str) -> bool: + inspector = sa.inspect(bind) + return inspector.has_table(table_name) + + +def _index_exists(bind, table_name: str, index_name: str) -> bool: + inspector = sa.inspect(bind) + indexes = inspector.get_indexes(table_name) + return any(idx.get("name") == index_name for idx in indexes) + + +def _create_index_if_not_exists( + bind, + *, + index_name: str, + table_name: str, + columns: list[str], + unique: bool = False, +) -> None: + if _index_exists(bind, table_name, index_name): + return + op.create_index(index_name, table_name, columns, unique=unique) + + +def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None: + if _index_exists(bind, table_name, index_name): + op.drop_index(index_name, table_name=table_name) + + +def upgrade() -> None: + bind = op.get_bind() + + if not _table_exists(bind, "signal_source_registries"): + op.create_table( + "signal_source_registries", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("signal_id", sa.BigInteger(), nullable=True), + sa.Column("created_by_user_id", sa.BigInteger(), nullable=True), + sa.Column("source_kind", sa.String(length=40), nullable=False), + sa.Column("source_name", sa.String(length=120), nullable=False), + sa.Column("source_identifier", sa.String(length=180), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("requested_via", sa.String(length=40), nullable=False, server_default="system"), + sa.Column("status", sa.String(length=20), nullable=False, server_default="active"), + sa.Column("onboarding_status", sa.String(length=20), nullable=False, server_default="pending"), + sa.Column("config", sa.JSON(), nullable=True), + sa.Column("raw_context", sa.JSON(), nullable=True), + sa.Column("last_signal_at", sa.TIMESTAMP(), nullable=True), + sa.Column("last_worker_run_at", sa.TIMESTAMP(), nullable=True), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"]), + sa.ForeignKeyConstraint(["signal_id"], ["signals.id"]), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]), + sa.UniqueConstraint( + "tenant_id", + "project_id", + "source_kind", + "source_identifier", + name="uq_signal_source_registry_key", + ), + ) + + if not _table_exists(bind, "ai_worker_executions"): + op.create_table( + "ai_worker_executions", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("signal_id", sa.BigInteger(), nullable=True), + sa.Column("signal_source_registry_id", sa.BigInteger(), nullable=True), + sa.Column("operator_user_id", sa.BigInteger(), nullable=True), + sa.Column("trace_id", sa.String(length=64), nullable=False), + sa.Column("operation_trace_id", sa.String(length=64), nullable=True), + sa.Column("execution_type", sa.String(length=40), nullable=False, server_default="signal_source_onboarding"), + sa.Column("worker_role_key", sa.String(length=32), nullable=False), + sa.Column("worker_role_name", sa.String(length=64), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False, server_default="planned"), + sa.Column("summary", sa.Text(), nullable=True), + sa.Column("input_payload", sa.JSON(), nullable=True), + sa.Column("output_payload", sa.JSON(), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("started_at", sa.TIMESTAMP(), nullable=True), + sa.Column("completed_at", sa.TIMESTAMP(), nullable=True), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"]), + sa.ForeignKeyConstraint(["signal_id"], ["signals.id"]), + sa.ForeignKeyConstraint(["signal_source_registry_id"], ["signal_source_registries.id"]), + sa.ForeignKeyConstraint(["operator_user_id"], ["users.id"]), + sa.UniqueConstraint( + "tenant_id", + "trace_id", + "worker_role_key", + name="uq_ai_worker_execution_trace_role", + ), + ) + + if _table_exists(bind, "signal_source_registries"): + for index_name, columns in ( + ("ix_signal_source_registries_tenant_id", ["tenant_id"]), + ("ix_signal_source_registries_project_id", ["project_id"]), + ("ix_signal_source_registries_signal_id", ["signal_id"]), + ("ix_signal_source_registries_created_by_user_id", ["created_by_user_id"]), + ("ix_signal_source_registries_source_kind", ["source_kind"]), + ("ix_signal_source_registries_requested_via", ["requested_via"]), + ("ix_signal_source_registries_status", ["status"]), + ("ix_signal_source_registries_onboarding_status", ["onboarding_status"]), + ): + _create_index_if_not_exists(bind, index_name=index_name, table_name="signal_source_registries", columns=columns) + + if _table_exists(bind, "ai_worker_executions"): + for index_name, columns in ( + ("ix_ai_worker_executions_tenant_id", ["tenant_id"]), + ("ix_ai_worker_executions_project_id", ["project_id"]), + ("ix_ai_worker_executions_signal_id", ["signal_id"]), + ("ix_ai_worker_executions_signal_source_registry_id", ["signal_source_registry_id"]), + ("ix_ai_worker_executions_operator_user_id", ["operator_user_id"]), + ("ix_ai_worker_executions_trace_id", ["trace_id"]), + ("ix_ai_worker_executions_operation_trace_id", ["operation_trace_id"]), + ("ix_ai_worker_executions_execution_type", ["execution_type"]), + ("ix_ai_worker_executions_worker_role_key", ["worker_role_key"]), + ("ix_ai_worker_executions_status", ["status"]), + ): + _create_index_if_not_exists(bind, index_name=index_name, table_name="ai_worker_executions", columns=columns) + + +def downgrade() -> None: + bind = op.get_bind() + if _table_exists(bind, "ai_worker_executions"): + for index_name in ( + "ix_ai_worker_executions_status", + "ix_ai_worker_executions_worker_role_key", + "ix_ai_worker_executions_execution_type", + "ix_ai_worker_executions_operation_trace_id", + "ix_ai_worker_executions_trace_id", + "ix_ai_worker_executions_operator_user_id", + "ix_ai_worker_executions_signal_source_registry_id", + "ix_ai_worker_executions_signal_id", + "ix_ai_worker_executions_project_id", + "ix_ai_worker_executions_tenant_id", + ): + _drop_index_if_exists(bind, index_name, "ai_worker_executions") + op.drop_table("ai_worker_executions") + + if _table_exists(bind, "signal_source_registries"): + for index_name in ( + "ix_signal_source_registries_onboarding_status", + "ix_signal_source_registries_status", + "ix_signal_source_registries_requested_via", + "ix_signal_source_registries_source_kind", + "ix_signal_source_registries_created_by_user_id", + "ix_signal_source_registries_signal_id", + "ix_signal_source_registries_project_id", + "ix_signal_source_registries_tenant_id", + ): + _drop_index_if_exists(bind, index_name, "signal_source_registries") + op.drop_table("signal_source_registries") diff --git a/backend/alembic/versions/20260307_add_ai_orchestration_runs.py b/backend/alembic/versions/20260307_add_ai_orchestration_runs.py new file mode 100644 index 00000000..cd7e1977 --- /dev/null +++ b/backend/alembic/versions/20260307_add_ai_orchestration_runs.py @@ -0,0 +1,114 @@ +"""add ai orchestration runs + +Revision ID: add_ai_orchestration_runs +Revises: add_signal_source_orchestration_tables +Create Date: 2026-03-07 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "add_ai_orchestration_runs" +down_revision = "add_signal_source_orchestration_tables" +branch_labels = None +depends_on = None + + +def _table_exists(bind, table_name: str) -> bool: + inspector = sa.inspect(bind) + return inspector.has_table(table_name) + + +def _index_exists(bind, table_name: str, index_name: str) -> bool: + inspector = sa.inspect(bind) + indexes = inspector.get_indexes(table_name) + return any(idx.get("name") == index_name for idx in indexes) + + +def _create_index_if_not_exists( + bind, + *, + index_name: str, + table_name: str, + columns: list[str], + unique: bool = False, +) -> None: + if _index_exists(bind, table_name, index_name): + return + op.create_index(index_name, table_name, columns, unique=unique) + + +def _drop_index_if_exists(bind, index_name: str, table_name: str) -> None: + if _index_exists(bind, table_name, index_name): + op.drop_index(index_name, table_name=table_name) + + +def upgrade() -> None: + bind = op.get_bind() + + if not _table_exists(bind, "ai_orchestration_runs"): + op.create_table( + "ai_orchestration_runs", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("trace_id", sa.String(length=64), nullable=False), + sa.Column("run_type", sa.String(length=40), nullable=False), + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("signal_id", sa.BigInteger(), nullable=True), + sa.Column("response_mode", sa.String(length=16), nullable=False, server_default="fast"), + sa.Column("status", sa.String(length=20), nullable=False, server_default="queued"), + sa.Column("planner_status", sa.String(length=20), nullable=True), + sa.Column("openclaw_status", sa.String(length=20), nullable=True), + sa.Column("worker_total", sa.Integer(), nullable=False, server_default="0"), + sa.Column("worker_completed", sa.Integer(), nullable=False, server_default="0"), + sa.Column("worker_failed", sa.Integer(), nullable=False, server_default="0"), + sa.Column("summary", sa.Text(), nullable=True), + sa.Column("planner_diagnostics", sa.JSON(), nullable=True), + sa.Column("result_payload", sa.JSON(), nullable=True), + sa.Column("created_by_user_id", sa.BigInteger(), nullable=True), + sa.Column("started_at", sa.TIMESTAMP(), nullable=True), + sa.Column("completed_at", sa.TIMESTAMP(), nullable=True), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"]), + sa.ForeignKeyConstraint(["signal_id"], ["signals.id"]), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]), + sa.UniqueConstraint("tenant_id", "trace_id", name="uq_ai_orchestration_run_trace_id"), + ) + + if _table_exists(bind, "ai_orchestration_runs"): + for index_name, columns in ( + ("ix_ai_orchestration_runs_tenant_id", ["tenant_id"]), + ("ix_ai_orchestration_runs_trace_id", ["trace_id"]), + ("ix_ai_orchestration_runs_run_type", ["run_type"]), + ("ix_ai_orchestration_runs_project_id", ["project_id"]), + ("ix_ai_orchestration_runs_signal_id", ["signal_id"]), + ("ix_ai_orchestration_runs_response_mode", ["response_mode"]), + ("ix_ai_orchestration_runs_status", ["status"]), + ("ix_ai_orchestration_runs_planner_status", ["planner_status"]), + ("ix_ai_orchestration_runs_openclaw_status", ["openclaw_status"]), + ("ix_ai_orchestration_runs_created_by_user_id", ["created_by_user_id"]), + ): + _create_index_if_not_exists(bind, index_name=index_name, table_name="ai_orchestration_runs", columns=columns) + + +def downgrade() -> None: + bind = op.get_bind() + if _table_exists(bind, "ai_orchestration_runs"): + for index_name in ( + "ix_ai_orchestration_runs_created_by_user_id", + "ix_ai_orchestration_runs_openclaw_status", + "ix_ai_orchestration_runs_planner_status", + "ix_ai_orchestration_runs_status", + "ix_ai_orchestration_runs_response_mode", + "ix_ai_orchestration_runs_signal_id", + "ix_ai_orchestration_runs_project_id", + "ix_ai_orchestration_runs_run_type", + "ix_ai_orchestration_runs_trace_id", + "ix_ai_orchestration_runs_tenant_id", + ): + _drop_index_if_exists(bind, index_name, "ai_orchestration_runs") + op.drop_table("ai_orchestration_runs") diff --git a/backend/alembic/versions/20260308_add_ai_pmo_kernel_tables.py b/backend/alembic/versions/20260308_add_ai_pmo_kernel_tables.py new file mode 100644 index 00000000..1346ae74 --- /dev/null +++ b/backend/alembic/versions/20260308_add_ai_pmo_kernel_tables.py @@ -0,0 +1,194 @@ +"""add ai pmo kernel tables + +Revision ID: add_ai_pmo_kernel_tables +Revises: add_ai_processing_audit_fields +Create Date: 2026-03-08 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "add_ai_pmo_kernel_tables" +down_revision = "add_ai_processing_audit_fields" +branch_labels = None +depends_on = None + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _index_exists(bind, table_name: str, index_name: str) -> bool: + inspector = sa.inspect(bind) + if not inspector.has_table(table_name): + return False + return any(idx.get("name") == index_name for idx in inspector.get_indexes(table_name)) + + +def _create_index_if_missing(bind, table_name: str, index_name: str, columns: list[str], *, unique: bool = False) -> None: + if not _index_exists(bind, table_name, index_name): + op.create_index(index_name, table_name, columns, unique=unique) + + +def upgrade() -> None: + bind = op.get_bind() + + if not _table_exists(bind, "work_items"): + op.create_table( + "work_items", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("ingest_event_id", sa.BigInteger(), sa.ForeignKey("ingest_events.id", ondelete="CASCADE"), nullable=False), + sa.Column("source_system", sa.String(length=32), nullable=False), + sa.Column("source_message_id", sa.String(length=128), nullable=False), + sa.Column("state", sa.String(length=32), nullable=False, server_default="received"), + sa.Column("intent", sa.String(length=64), nullable=True), + sa.Column("decision_level", sa.String(length=20), nullable=True), + sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=True), + sa.Column("signal_id", sa.BigInteger(), sa.ForeignKey("signals.id"), nullable=True), + sa.Column("owner_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("dedupe_key", sa.String(length=255), nullable=True), + sa.Column("context_snapshot", sa.JSON(), nullable=True), + sa.Column("action_bundle", sa.JSON(), nullable=True), + sa.Column("execution_result", sa.JSON(), nullable=True), + sa.Column("review_summary", sa.JSON(), nullable=True), + sa.Column("reply_template_key", sa.String(length=64), nullable=True), + sa.UniqueConstraint("tenant_id", "source_system", "source_message_id", name="uq_work_items_source_message"), + sa.UniqueConstraint("tenant_id", "ingest_event_id", name="uq_work_items_ingest_event"), + ) + + if not _table_exists(bind, "decision_records"): + op.create_table( + "decision_records", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("work_item_id", sa.BigInteger(), sa.ForeignKey("work_items.id", ondelete="CASCADE"), nullable=False), + sa.Column("decision_object_type", sa.String(length=40), nullable=False, server_default="portfolio"), + sa.Column("decision_object_id", sa.BigInteger(), nullable=True), + sa.Column("decision_level", sa.String(length=20), nullable=False, server_default="portfolio"), + sa.Column("context_snapshot", sa.JSON(), nullable=True), + sa.Column("rationale", sa.Text(), nullable=True), + sa.Column("confidence", sa.Float(), nullable=True), + sa.Column("risk_score", sa.Float(), nullable=True), + sa.Column("budget_impact", sa.JSON(), nullable=True), + sa.Column("resource_impact", sa.JSON(), nullable=True), + sa.Column("policy_gate_hit", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("policy_gate_code", sa.String(length=80), nullable=True), + sa.Column("action_bundle", sa.JSON(), nullable=True), + sa.Column("approval_requirement", sa.JSON(), nullable=True), + sa.Column("execution_result", sa.JSON(), nullable=True), + sa.Column("final_outcome", sa.String(length=32), nullable=True), + ) + + if not _table_exists(bind, "plan_bundles"): + op.create_table( + "plan_bundles", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("work_item_id", sa.BigInteger(), sa.ForeignKey("work_items.id", ondelete="CASCADE"), nullable=False), + sa.Column("plan_type", sa.String(length=20), nullable=False), + sa.Column("summary", sa.Text(), nullable=True), + sa.Column("payload", sa.JSON(), nullable=True), + ) + + if not _table_exists(bind, "reply_receipts"): + op.create_table( + "reply_receipts", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("work_item_id", sa.BigInteger(), sa.ForeignKey("work_items.id", ondelete="SET NULL"), nullable=True), + sa.Column("source_system", sa.String(length=32), nullable=False), + sa.Column("source_message_id", sa.String(length=128), nullable=False), + sa.Column("receipt_phase", sa.String(length=32), nullable=False), + sa.Column("template_key", sa.String(length=64), nullable=True), + sa.Column("payload", sa.JSON(), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False, server_default="pending"), + sa.UniqueConstraint( + "tenant_id", + "source_system", + "source_message_id", + "receipt_phase", + name="uq_reply_receipts_source_phase", + ), + ) + + if not _table_exists(bind, "portfolio_snapshots"): + op.create_table( + "portfolio_snapshots", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("scope_type", sa.String(length=20), nullable=False, server_default="tenant"), + sa.Column("scope_ref", sa.String(length=64), nullable=False), + sa.Column("summary", sa.JSON(), nullable=True), + sa.Column("payload", sa.JSON(), nullable=True), + ) + + for table_name, columns in ( + ("work_items", ["tenant_id"]), + ("decision_records", ["tenant_id"]), + ("plan_bundles", ["tenant_id"]), + ("reply_receipts", ["tenant_id"]), + ("portfolio_snapshots", ["tenant_id"]), + ): + _create_index_if_missing(bind, table_name, f"ix_{table_name}_tenant_id", columns) + + for table_name, index_name, columns in ( + ("work_items", "ix_work_items_source_system", ["source_system"]), + ("work_items", "ix_work_items_source_message_id", ["source_message_id"]), + ("work_items", "ix_work_items_state", ["state"]), + ("work_items", "ix_work_items_intent", ["intent"]), + ("work_items", "ix_work_items_decision_level", ["decision_level"]), + ("work_items", "ix_work_items_project_id", ["project_id"]), + ("work_items", "ix_work_items_signal_id", ["signal_id"]), + ("work_items", "ix_work_items_owner_user_id", ["owner_user_id"]), + ("work_items", "ix_work_items_dedupe_key", ["dedupe_key"]), + ("work_items", "ix_work_items_ingest_event_id", ["ingest_event_id"]), + ("decision_records", "ix_decision_records_work_item_id", ["work_item_id"]), + ("decision_records", "ix_decision_records_decision_object_id", ["decision_object_id"]), + ("decision_records", "ix_decision_records_decision_level", ["decision_level"]), + ("decision_records", "ix_decision_records_policy_gate_hit", ["policy_gate_hit"]), + ("decision_records", "ix_decision_records_policy_gate_code", ["policy_gate_code"]), + ("decision_records", "ix_decision_records_final_outcome", ["final_outcome"]), + ("plan_bundles", "ix_plan_bundles_work_item_id", ["work_item_id"]), + ("plan_bundles", "ix_plan_bundles_plan_type", ["plan_type"]), + ("reply_receipts", "ix_reply_receipts_work_item_id", ["work_item_id"]), + ("reply_receipts", "ix_reply_receipts_source_system", ["source_system"]), + ("reply_receipts", "ix_reply_receipts_source_message_id", ["source_message_id"]), + ("reply_receipts", "ix_reply_receipts_receipt_phase", ["receipt_phase"]), + ("reply_receipts", "ix_reply_receipts_status", ["status"]), + ("portfolio_snapshots", "ix_portfolio_snapshots_scope_type", ["scope_type"]), + ("portfolio_snapshots", "ix_portfolio_snapshots_scope_ref", ["scope_ref"]), + ): + _create_index_if_missing(bind, table_name, index_name, columns) + + +def downgrade() -> None: + bind = op.get_bind() + for table_name, indexes in ( + ("portfolio_snapshots", ["ix_portfolio_snapshots_scope_ref", "ix_portfolio_snapshots_scope_type", "ix_portfolio_snapshots_tenant_id"]), + ("reply_receipts", ["ix_reply_receipts_status", "ix_reply_receipts_receipt_phase", "ix_reply_receipts_source_message_id", "ix_reply_receipts_source_system", "ix_reply_receipts_work_item_id", "ix_reply_receipts_tenant_id"]), + ("plan_bundles", ["ix_plan_bundles_plan_type", "ix_plan_bundles_work_item_id", "ix_plan_bundles_tenant_id"]), + ("decision_records", ["ix_decision_records_final_outcome", "ix_decision_records_policy_gate_code", "ix_decision_records_policy_gate_hit", "ix_decision_records_decision_level", "ix_decision_records_decision_object_id", "ix_decision_records_work_item_id", "ix_decision_records_tenant_id"]), + ("work_items", ["ix_work_items_ingest_event_id", "ix_work_items_dedupe_key", "ix_work_items_owner_user_id", "ix_work_items_signal_id", "ix_work_items_project_id", "ix_work_items_decision_level", "ix_work_items_intent", "ix_work_items_state", "ix_work_items_source_message_id", "ix_work_items_source_system", "ix_work_items_tenant_id"]), + ): + if not _table_exists(bind, table_name): + continue + for index_name in indexes: + if _index_exists(bind, table_name, index_name): + op.drop_index(index_name, table_name=table_name) + for table_name in ("portfolio_snapshots", "reply_receipts", "plan_bundles", "decision_records", "work_items"): + if _table_exists(bind, table_name): + op.drop_table(table_name) diff --git a/backend/alembic/versions/20260308_add_ai_processing_audit_fields.py b/backend/alembic/versions/20260308_add_ai_processing_audit_fields.py new file mode 100644 index 00000000..b672cab0 --- /dev/null +++ b/backend/alembic/versions/20260308_add_ai_processing_audit_fields.py @@ -0,0 +1,89 @@ +"""add ai processing audit fields + +Revision ID: add_ai_processing_audit_fields +Revises: add_inbox_structured_fields +Create Date: 2026-03-08 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "add_ai_processing_audit_fields" +down_revision = "add_inbox_structured_fields" +branch_labels = None +depends_on = None + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _column_exists(bind, table_name: str, column_name: str) -> bool: + inspector = sa.inspect(bind) + if not inspector.has_table(table_name): + return False + columns = inspector.get_columns(table_name) + return any(col.get("name") == column_name for col in columns) + + +def _index_exists(bind, table_name: str, index_name: str) -> bool: + inspector = sa.inspect(bind) + if not inspector.has_table(table_name): + return False + return any(idx.get("name") == index_name for idx in inspector.get_indexes(table_name)) + + +def _add_column_if_missing(bind, table_name: str, column: sa.Column) -> None: + if not _column_exists(bind, table_name, str(column.name)): + op.add_column(table_name, column) + + +def _create_index_if_missing(bind, table_name: str, index_name: str, columns: list[str]) -> None: + if not _index_exists(bind, table_name, index_name): + op.create_index(index_name, table_name, columns) + + +def upgrade() -> None: + bind = op.get_bind() + if not _table_exists(bind, "ai_processing_logs"): + return + + _add_column_if_missing(bind, "ai_processing_logs", sa.Column("normalized_text", sa.Text(), nullable=True)) + _add_column_if_missing(bind, "ai_processing_logs", sa.Column("route", sa.String(length=80), nullable=True)) + _add_column_if_missing(bind, "ai_processing_logs", sa.Column("intent", sa.String(length=40), nullable=True)) + _add_column_if_missing(bind, "ai_processing_logs", sa.Column("executor", sa.String(length=40), nullable=True)) + _add_column_if_missing(bind, "ai_processing_logs", sa.Column("operation_code", sa.String(length=80), nullable=True)) + _add_column_if_missing(bind, "ai_processing_logs", sa.Column("operation_result", sa.JSON(), nullable=True)) + _add_column_if_missing(bind, "ai_processing_logs", sa.Column("reply_source", sa.String(length=40), nullable=True)) + _create_index_if_missing(bind, "ai_processing_logs", "ix_ai_processing_logs_route", ["route"]) + _create_index_if_missing(bind, "ai_processing_logs", "ix_ai_processing_logs_intent", ["intent"]) + _create_index_if_missing(bind, "ai_processing_logs", "ix_ai_processing_logs_operation_code", ["operation_code"]) + + +def downgrade() -> None: + bind = op.get_bind() + if not _table_exists(bind, "ai_processing_logs"): + return + + for index_name in ( + "ix_ai_processing_logs_operation_code", + "ix_ai_processing_logs_intent", + "ix_ai_processing_logs_route", + ): + if _index_exists(bind, "ai_processing_logs", index_name): + op.drop_index(index_name, table_name="ai_processing_logs") + + for column_name in ( + "reply_source", + "operation_result", + "operation_code", + "executor", + "intent", + "route", + "normalized_text", + ): + if _column_exists(bind, "ai_processing_logs", column_name): + op.drop_column("ai_processing_logs", column_name) diff --git a/backend/alembic/versions/20260308_add_inbox_structured_fields.py b/backend/alembic/versions/20260308_add_inbox_structured_fields.py new file mode 100644 index 00000000..8e435f71 --- /dev/null +++ b/backend/alembic/versions/20260308_add_inbox_structured_fields.py @@ -0,0 +1,152 @@ +"""add inbox structured fields + +Revision ID: add_inbox_structured_fields +Revises: add_ai_orchestration_runs +Create Date: 2026-03-08 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "add_inbox_structured_fields" +down_revision = "add_ai_orchestration_runs" +branch_labels = None +depends_on = None + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _column_exists(bind, table_name: str, column_name: str) -> bool: + inspector = sa.inspect(bind) + if not inspector.has_table(table_name): + return False + columns = inspector.get_columns(table_name) + return any(col.get("name") == column_name for col in columns) + + +def _index_exists(bind, table_name: str, index_name: str) -> bool: + inspector = sa.inspect(bind) + if not inspector.has_table(table_name): + return False + return any(idx.get("name") == index_name for idx in inspector.get_indexes(table_name)) + + +def _add_column_if_missing(bind, table_name: str, column: sa.Column) -> None: + if not _column_exists(bind, table_name, str(column.name)): + op.add_column(table_name, column) + + +def _drop_column_if_exists(bind, table_name: str, column_name: str) -> None: + if _column_exists(bind, table_name, column_name): + op.drop_column(table_name, column_name) + + +def _create_index_if_missing(bind, table_name: str, index_name: str, columns: list[str]) -> None: + if not _index_exists(bind, table_name, index_name): + op.create_index(index_name, table_name, columns) + + +def _drop_index_if_exists(bind, table_name: str, index_name: str) -> None: + if _index_exists(bind, table_name, index_name): + op.drop_index(index_name, table_name=table_name) + + +def upgrade() -> None: + bind = op.get_bind() + + if _table_exists(bind, "notifications"): + _add_column_if_missing(bind, "notifications", sa.Column("kind", sa.String(length=30), nullable=True)) + _add_column_if_missing(bind, "notifications", sa.Column("priority", sa.String(length=20), nullable=True)) + _add_column_if_missing(bind, "notifications", sa.Column("status", sa.String(length=30), nullable=True)) + _add_column_if_missing(bind, "notifications", sa.Column("blocking_reason", sa.Text(), nullable=True)) + _add_column_if_missing(bind, "notifications", sa.Column("recommended_action", sa.String(length=80), nullable=True)) + _add_column_if_missing(bind, "notifications", sa.Column("dedupe_key", sa.String(length=255), nullable=True)) + _add_column_if_missing( + bind, + "notifications", + sa.Column("occurrence_count", sa.Integer(), nullable=False, server_default="1"), + ) + _add_column_if_missing(bind, "notifications", sa.Column("payload", sa.JSON(), nullable=True)) + _create_index_if_missing(bind, "notifications", "ix_notifications_kind", ["kind"]) + _create_index_if_missing(bind, "notifications", "ix_notifications_priority", ["priority"]) + _create_index_if_missing(bind, "notifications", "ix_notifications_status", ["status"]) + _create_index_if_missing(bind, "notifications", "ix_notifications_dedupe_key", ["dedupe_key"]) + + if _table_exists(bind, "feishu_items"): + _add_column_if_missing(bind, "feishu_items", sa.Column("decision_reason", sa.Text(), nullable=True)) + _add_column_if_missing(bind, "feishu_items", sa.Column("blocking_reason", sa.Text(), nullable=True)) + _add_column_if_missing( + bind, + "feishu_items", + sa.Column("safe_to_auto_execute", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + _add_column_if_missing( + bind, + "feishu_items", + sa.Column("auto_execute_state", sa.String(length=30), nullable=True), + ) + _add_column_if_missing(bind, "feishu_items", sa.Column("dedupe_key", sa.String(length=255), nullable=True)) + _add_column_if_missing(bind, "feishu_items", sa.Column("message_class", sa.String(length=30), nullable=True)) + _add_column_if_missing( + bind, + "feishu_items", + sa.Column("occurrence_count", sa.Integer(), nullable=False, server_default="1"), + ) + _add_column_if_missing(bind, "feishu_items", sa.Column("next_step_text", sa.Text(), nullable=True)) + _add_column_if_missing(bind, "feishu_items", sa.Column("undoable_until", sa.TIMESTAMP(), nullable=True)) + _add_column_if_missing(bind, "feishu_items", sa.Column("result_payload", sa.JSON(), nullable=True)) + _create_index_if_missing(bind, "feishu_items", "ix_feishu_items_safe_to_auto_execute", ["safe_to_auto_execute"]) + _create_index_if_missing(bind, "feishu_items", "ix_feishu_items_auto_execute_state", ["auto_execute_state"]) + _create_index_if_missing(bind, "feishu_items", "ix_feishu_items_dedupe_key", ["dedupe_key"]) + _create_index_if_missing(bind, "feishu_items", "ix_feishu_items_message_class", ["message_class"]) + + +def downgrade() -> None: + bind = op.get_bind() + + if _table_exists(bind, "notifications"): + for index_name in ( + "ix_notifications_dedupe_key", + "ix_notifications_status", + "ix_notifications_priority", + "ix_notifications_kind", + ): + _drop_index_if_exists(bind, "notifications", index_name) + for column_name in ( + "payload", + "occurrence_count", + "dedupe_key", + "recommended_action", + "blocking_reason", + "status", + "priority", + "kind", + ): + _drop_column_if_exists(bind, "notifications", column_name) + + if _table_exists(bind, "feishu_items"): + for index_name in ( + "ix_feishu_items_message_class", + "ix_feishu_items_dedupe_key", + "ix_feishu_items_auto_execute_state", + "ix_feishu_items_safe_to_auto_execute", + ): + _drop_index_if_exists(bind, "feishu_items", index_name) + for column_name in ( + "result_payload", + "undoable_until", + "next_step_text", + "occurrence_count", + "message_class", + "dedupe_key", + "auto_execute_state", + "safe_to_auto_execute", + "blocking_reason", + "decision_reason", + ): + _drop_column_if_exists(bind, "feishu_items", column_name) diff --git a/backend/alembic/versions/20260309_add_ai_pmo_simulation_field.py b/backend/alembic/versions/20260309_add_ai_pmo_simulation_field.py new file mode 100644 index 00000000..442f75aa --- /dev/null +++ b/backend/alembic/versions/20260309_add_ai_pmo_simulation_field.py @@ -0,0 +1,40 @@ +"""add ai pmo simulation field + +Revision ID: add_ai_pmo_simulation_field +Revises: add_ai_pmo_kernel_tables +Create Date: 2026-03-09 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "add_ai_pmo_simulation_field" +down_revision = "add_ai_pmo_kernel_tables" +branch_labels = None +depends_on = None + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _column_exists(bind, table_name: str, column_name: str) -> bool: + inspector = sa.inspect(bind) + if not inspector.has_table(table_name): + return False + return any(col.get("name") == column_name for col in inspector.get_columns(table_name)) + + +def upgrade() -> None: + bind = op.get_bind() + if _table_exists(bind, "decision_records") and not _column_exists(bind, "decision_records", "simulation_result"): + op.add_column("decision_records", sa.Column("simulation_result", sa.JSON(), nullable=True)) + + +def downgrade() -> None: + bind = op.get_bind() + if _table_exists(bind, "decision_records") and _column_exists(bind, "decision_records", "simulation_result"): + op.drop_column("decision_records", "simulation_result") diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 443d09c2..31515487 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -20,6 +20,12 @@ from .wecom import router as wecom_router from .model_capability import router as model_capability_router from .budget import router as budget_router from .scheduler import router as scheduler_router +from .notifications import router as notifications_router +from .llm_engines import router as llm_engines_router +from .prompt_profiles import router as prompt_profiles_router +from .knowledge_graph import router as knowledge_graph_router +from .operation_catalog import router as operation_catalog_router +from .ai_pmo import router as ai_pmo_router __all__ = [ "signals_router", @@ -38,4 +44,10 @@ __all__ = [ "model_capability_router", "budget_router", "scheduler_router", + "notifications_router", + "llm_engines_router", + "prompt_profiles_router", + "knowledge_graph_router", + "operation_catalog_router", + "ai_pmo_router", ] diff --git a/backend/app/api/ai_logs.py b/backend/app/api/ai_logs.py new file mode 100644 index 00000000..bfe7f8bc --- /dev/null +++ b/backend/app/api/ai_logs.py @@ -0,0 +1,341 @@ +"""AI 处理可视化日志 API。""" + +from __future__ import annotations + +import json +from datetime import date, datetime +from typing import Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import and_, case, func, or_, select, union_all +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, +) + + +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), + limit: int = Query(default=20, ge=1, le=100), + offset: int = Query(default=0, ge=0), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = int(current_user.tenant_id) + total_stmt = select(func.count(AIProcessingLog.id)).where(AIProcessingLog.tenant_id == tenant_id) + data_stmt = ( + select(AIProcessingLog) + .where(AIProcessingLog.tenant_id == tenant_id) + .order_by(AIProcessingLog.created_at.desc(), AIProcessingLog.id.desc()) + .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 = 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] + return success_response( + { + "items": items, + "total": total, + "limit": limit, + "offset": offset, + "has_more": (offset + len(items)) < total, + } + ) + + +@router.get("/stats") +async def get_ai_log_stats( + project_id: Optional[int] = Query(default=None, ge=1), + date_value: str = Query(default="today", alias="date"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = int(current_user.tenant_id) + if date_value == "today": + target_date = datetime.now().date() + else: + try: + target_date = date.fromisoformat(date_value) + except ValueError as exc: + raise HTTPException(status_code=400, detail=f"invalid_date:{date_value}") from exc + + stats_stmt = ( + select( + func.count(AIProcessingLog.id).label("received_count"), + func.sum(case((AIProcessingLog.processing_status == "success", 1), else_=0)).label("success_count"), + func.sum(case((AIProcessingLog.processing_status == "failed", 1), else_=0)).label("failed_count"), + func.sum( + case((AIProcessingLog.routing_decision == "duplicate_filtered", 1), else_=0) + ).label("duplicate_filtered_count"), + func.sum( + case((AIProcessingLog.routing_decision == "execute_reply_guard_triggered", 1), else_=0) + ).label("execute_reply_guard_count"), + func.sum( + case( + ( + AIProcessingLog.routing_decision.in_( + ["auto_create_project", "auto_enter_signal"] + ), + 1, + ), + else_=0, + ) + ).label("auto_entered_count"), + func.sum( + case( + ( + AIProcessingLog.routing_decision.in_( + [ + "pending_low_confidence", + "pending_cold_start", + "pending_uncertain_project", + ] + ), + 1, + ), + else_=0, + ) + ).label("pending_count"), + func.sum(case((AIProcessingLog.processing_status == "processing", 1), else_=0)).label( + "processing_count" + ), + func.avg(AIProcessingLog.signal_confidence).label("avg_signal_confidence"), + func.avg(AIProcessingLog.project_score).label("avg_project_score"), + ) + .where( + and_( + AIProcessingLog.tenant_id == tenant_id, + func.date(AIProcessingLog.created_at) == target_date.isoformat(), + ) + ) + ) + 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( + { + "date": target_date.isoformat(), + "received_count": int(row.received_count or 0), + "success_count": int(row.success_count or 0), + "failed_count": int(row.failed_count or 0), + "duplicate_filtered_count": int(row.duplicate_filtered_count or 0), + "execute_reply_guard_count": int(row.execute_reply_guard_count or 0), + "auto_entered_count": int(row.auto_entered_count or 0), + "pending_count": int(row.pending_count or 0), + "processing_count": int(row.processing_count or 0), + "avg_signal_confidence": round(float(row.avg_signal_confidence or 0.0), 4), + "avg_project_score": round(float(row.avg_project_score or 0.0), 4), + } + ) + + +@router.get("/alerts/execute-reply-guard") +async def list_execute_reply_guard_alerts( + limit: int = Query(default=20, ge=1, le=100), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = int(current_user.tenant_id) + rows = ( + await db.execute( + select(AIProcessingLog) + .where( + and_( + AIProcessingLog.tenant_id == tenant_id, + AIProcessingLog.routing_decision == "execute_reply_guard_triggered", + ) + ) + .order_by(AIProcessingLog.created_at.desc(), AIProcessingLog.id.desc()) + .limit(limit) + ) + ).scalars().all() + return success_response( + { + "count": len(rows), + "items": [_serialize_log_row(row) for row in rows], + } + ) + + +@router.get("/latest") +async def get_latest_ai_log( + project_id: Optional[int] = Query(default=None, ge=1), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = int(current_user.tenant_id) + stmt = ( + select(AIProcessingLog) + .where(AIProcessingLog.tenant_id == tenant_id) + .order_by(AIProcessingLog.created_at.desc(), AIProcessingLog.id.desc()) + .limit(1) + ) + 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) + + +@router.get("/{log_id}") +async def get_ai_log_detail( + log_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = int(current_user.tenant_id) + row = ( + await db.execute( + select(AIProcessingLog).where( + and_( + AIProcessingLog.tenant_id == tenant_id, + AIProcessingLog.id == int(log_id), + ) + ) + ) + ).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)) diff --git a/backend/app/api/ai_pmo.py b/backend/app/api/ai_pmo.py new file mode 100644 index 00000000..d18fedd1 --- /dev/null +++ b/backend/app/api/ai_pmo.py @@ -0,0 +1,417 @@ +"""AI PMO 决策中枢 API。""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, 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.response import success_response +from ..database import 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"]) + + +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): + project_id: Optional[int] = Field(default=None) + source_message_id: Optional[str] = Field(default=None, max_length=128) + + +@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() + items = [ + _serialize_work_item_summary(row=row, ingest=ingest, project=project) + for row, ingest, project in rows + ] + 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") + service = AIPMOKernelService(db, tenant_id=tenant_id) + 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"继续处理 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), + ) + if not result.get("handled"): + raise HTTPException(status_code=400, detail=str(result.get("reason") or "resume_failed")) + return success_response(result) + + +@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 _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 _serialize_work_item_summary(*, row: WorkItem, ingest: IngestEvent, project: Optional[Project]) -> dict[str, Any]: + payload = ingest.payload if isinstance(ingest.payload, dict) else {} + message_text = str(payload.get("message_text") or "").strip() + summary = _compact_text(message_text or _next_step_text(row) or row.intent or "AI PMO 工作项", 180) + title = _compact_text(message_text or f"AI PMO · {row.intent or 'work_item'}", 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) + return { + "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": 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, + "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_steward.py b/backend/app/api/ai_steward.py index 2c351f66..8378a1f4 100644 --- a/backend/app/api/ai_steward.py +++ b/backend/app/api/ai_steward.py @@ -9,27 +9,71 @@ AI Steward API - 主动式 AI 管家接口 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 +from typing import List, Optional, Dict, Any, Literal from datetime import datetime, timezone, timedelta import json +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 User +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.openclaw_commander_service import OpenClawCommanderService +from ..services.signal_source_orchestration_service import SignalSourceOrchestrationService +from ..schemas.rag import RAGAskRequest router = APIRouter(prefix="/ai-steward", tags=["AI Steward"]) +def _utc_now_naive() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +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): @@ -77,6 +121,7 @@ class FeishuCaptureRequest(BaseModel): group_chat_id: str message_content: str sender: str + feishu_message_id: Optional[str] = None class BrowserCaptureRequest(BaseModel): @@ -100,8 +145,201 @@ class ConflictDetectRequest(BaseModel): detect_type: str = "all" # all, charter, definition +class OpenClawCommandDispatchRequest(BaseModel): + """OpenClaw 指挥层任务分配请求""" + project_id: int = Field(..., ge=1) + instruction: str = Field(..., min_length=4, max_length=2000) + dry_run: bool = False + max_operations: int = Field(default=8, ge=1, le=20) + response_mode: Literal["fast", "complete"] = "fast" + + +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]] + + +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("/command/dispatch") +async def dispatch_openclaw_command( + request: OpenClawCommandDispatchRequest, + 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) + service = OpenClawCommanderService(db) + try: + result = await service.dispatch_command( + tenant_id=effective_tenant_id, + operator_user=current_user, + project_id=request.project_id, + instruction=request.instruction, + dry_run=request.dry_run, + max_operations=request.max_operations, + response_mode=request.response_mode, + ) + except ValueError as exc: + detail = str(exc) + if detail == "project not found": + raise HTTPException(status_code=404, detail=detail) from exc + raise HTTPException(status_code=400, detail=detail) from exc + return success_response(result) + + +@router.post("/signal-sources/onboard") +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": datetime.now(timezone.utc).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("/project-from-text", response_model=ProjectFromTextResponse) async def create_project_from_text( request: ProjectFromTextRequest, @@ -163,7 +401,8 @@ async def capture_feishu_signal( group_chat_id=request.group_chat_id, message_content=request.message_content, sender=request.sender, - tenant_id=tenant_id + tenant_id=tenant_id, + feishu_message_id=request.feishu_message_id, ) return result except Exception as e: @@ -285,8 +524,8 @@ async def get_cognitive_conflicts( id=str(c.id), type=c.conflict_type, description=c.description, - severity="high" if "critical" in c.description.lower() else "medium", - related_users=json.loads(c.involved_user_ids) if c.involved_user_ids else [] + severity="high" if "critical" in (c.description or "").lower() else "medium", + related_users=_parse_related_users(c.involved_user_ids), ) for c in conflicts ] @@ -424,7 +663,7 @@ async def resolve_cognitive_conflict( raise HTTPException(status_code=404, detail="冲突记录不存在") conflict.status = 'resolved' - conflict.resolved_at = datetime.now(timezone.utc) + conflict.resolved_at = _utc_now_naive() conflict.description += f"\n\n解决方案({current_user.username}): {resolution_note}" await db.commit() @@ -790,10 +1029,13 @@ async def get_ai_steward_stats( 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 >= datetime.now(timezone.utc) - timedelta(days=7) + Signal.created_at >= week_ago ) ) signals_captured = signal_result.scalar() or 0 @@ -803,7 +1045,7 @@ async def get_ai_steward_stats( select(func.count(Signal.id)).where( Signal.tenant_id == tenant_id, Signal.impact_score >= 0.8, - Signal.created_at >= datetime.now(timezone.utc) - timedelta(days=7) + Signal.created_at >= week_ago ) ) high_impact_count = high_impact_result.scalar() or 0 @@ -822,7 +1064,7 @@ async def get_ai_steward_stats( select(func.count(CognitiveConflict.id)).where( CognitiveConflict.tenant_id == tenant_id, CognitiveConflict.status == 'resolved', - CognitiveConflict.resolved_at >= datetime.now(timezone.utc) - timedelta(days=30) + CognitiveConflict.resolved_at >= month_ago ) ) conflicts_resolved = resolved_conflict_result.scalar() or 0 @@ -1105,6 +1347,7 @@ async def get_ai_config_suggestion( """ from ..models import Integration, Project, Signal from sqlalchemy.future import select + from sqlalchemy import func # 检查已配置的集成 configured_sources = [] @@ -1113,7 +1356,7 @@ async def get_ai_config_suggestion( feishu_result = await db.execute( select(Integration).where( Integration.tenant_id == tenant_id, - Integration.provider == 'feishu', + Integration.type == 'feishu', Integration.is_active == True ) ) @@ -1133,7 +1376,7 @@ async def get_ai_config_suggestion( signal_result = await db.execute( select(func.count(Signal.id)).where( Signal.tenant_id == tenant_id, - Signal.created_at >= datetime.now(timezone.utc) - timedelta(days=7) + Signal.created_at >= (_utc_now_naive() - timedelta(days=7)) ) ) recent_signals = signal_result.scalar() or 0 diff --git a/backend/app/api/feishu.py b/backend/app/api/feishu.py index b1b487aa..0eb0ec0a 100644 --- a/backend/app/api/feishu.py +++ b/backend/app/api/feishu.py @@ -1,38 +1,57 @@ -""" -Feishu Integration API - 飞书集成接口 - -功能: -1. 配置飞书应用凭证 -2. 验证 URL 和处理事件 -3. 发送消息到飞书群 -4. 查询集成状态 -""" -from fastapi import APIRouter, Depends, HTTPException, Request, BackgroundTasks +"""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 datetime import datetime, timezone -import json - 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 router = APIRouter(prefix="/feishu", tags=["Feishu Integration"]) +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: str = Field(..., description="事件订阅加密密钥") - verification_token: str = Field(..., description="事件订阅验证令牌") + 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): @@ -63,40 +82,47 @@ async def save_feishu_config( 配置信息说明: - app_id: 飞书应用的 App ID,从飞书开放平台获取 - app_secret: 飞书应用的 App Secret - - encrypt_key: 事件订阅的加密 Key - - verification_token: 事件订阅的验证 Token 获取步骤: 1. 访问 https://open.feishu.cn/ 2. 创建企业自建应用 3. 在「凭证与基础信息」页面获取 App ID 和 App Secret - 4. 在「事件订阅」配置 Encrypt Key 和 Verification Token - 5. 添加权限:im:message, im:message:group_at_msg, im:chat - 6. 订阅事件:接收消息 v1 (message.receive_v1) + 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=request.encrypt_key, - verification_token=request.verification_token, + 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': [ - '1. 在飞书开放平台配置事件订阅 URL', - f'2. URL: https://your-domain.com/api/feishu/webhook', - '3. 点击验证按钮完成 URL 验证' - ] + 'message': '飞书配置已保存,并已切换到长连接模式', + 'next_steps': next_steps } + except HTTPException: + raise except Exception as e: raise HTTPException( status_code=500, @@ -110,16 +136,19 @@ async def get_feishu_config( 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 if service.is_configured() else None, - 'has_encrypt_key': bool(service.encrypt_key), - 'has_verification_token': bool(service.verification_token) + '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( @@ -145,7 +174,7 @@ async def delete_feishu_config( result = await db.execute( select(Integration).where( Integration.tenant_id == tenant_id, - Integration.provider == 'feishu' + Integration.type == 'feishu' ) ) integration = result.scalar_one_or_none() @@ -153,6 +182,9 @@ async def delete_feishu_config( 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: @@ -162,93 +194,45 @@ async def delete_feishu_config( ) -# ==================== Webhook Endpoints ==================== - -@router.get("/webhook") -async def verify_webhook_url( - challenge: str, - db: AsyncSession = Depends(get_db) +@router.get("/long-connection/status") +async def feishu_long_connection_status( + current_user: User = Depends(get_current_user), ): - """ - 验证飞书事件订阅 URL - - 飞书会发送 GET 请求来验证 URL 有效性 - 请求参数: challenge - 返回: {"challenge": challenge} - - 注意:此端点不需要身份验证,因为飞书不会发送认证令牌 - """ - try: - service = get_feishu_service(db) - # 使用默认租户 ID 1 - await service.load_config(tenant_id=1) - - if not service.is_configured(): - raise HTTPException( - status_code=400, - detail="请先配置飞书" - ) - - challenge_response = service.verify_url(challenge) - + """查询飞书长连接状态。""" + manager = get_feishu_long_connection_manager() + if not manager: return { - "challenge": challenge_response + "status": "error", + "message": "Feishu 长连接管理器未初始化", + "mode": "long_connection", + "data": None, } - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + return { + "status": "success", + "mode": "long_connection", + "data": manager.status(), + } -@router.post("/webhook") -async def handle_feishu_event( - request: Request, - db: AsyncSession = Depends(get_db) +@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="只有管理员可以重启飞书长连接") - 飞书会发送 POST 请求推送事件 - 需要验证签名确保请求来自飞书 - """ - try: - # 获取请求头 - timestamp = request.headers.get('X-Lark-Request-Timestamp', '') - nonce = request.headers.get('X-Lark-Request-Nonce', '') - signature = request.headers.get('X-Lark-Signature', '') - - # 读取请求体 - body_bytes = await request.body() - body = body_bytes.decode('utf-8') - - # 解析 JSON 获取 tenant_id(如果有多租户) - body_json = json.loads(body) if body else {} - tenant_id = 1 # 默认租户 - - # 加载飞书配置 - service = get_feishu_service(db) - await service.load_config(tenant_id) - - # 验证签名 - if not service.verify_signature(timestamp, nonce, body, signature): - raise HTTPException(status_code=401, detail="签名验证失败") - - # 解析事件 - envelope = json.loads(body) - event_data = envelope.get('event', {}) - - # 处理事件 - result = await service.handle_message_event(event_data) - - return result - - except HTTPException: - raise - except Exception as e: - print(f"[Feishu] Webhook error: {e}") - # 飞书要求返回成功状态,避免重复推送 - return {'code': 0, 'msg': 'received'} + 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 ==================== @@ -333,6 +317,7 @@ async def test_feishu_connection( try: service = get_feishu_service(db) await service.load_config(tenant_id) + manager = get_feishu_long_connection_manager() if not service.is_configured(): return { @@ -343,10 +328,39 @@ async def test_feishu_connection( # 尝试获取 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) + 'has_token': bool(access_token), + 'mode': 'long_connection', + 'event_mode': event_mode, + 'long_connection': long_status, } except Exception as token_error: return { diff --git a/backend/app/api/knowledge_graph.py b/backend/app/api/knowledge_graph.py new file mode 100644 index 00000000..0ed005dc --- /dev/null +++ b/backend/app/api/knowledge_graph.py @@ -0,0 +1,183 @@ +"""Project Knowledge Graph API.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field +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.tenant import get_tenant_id +from ..models import User +from ..services.auth import has_any_role +from ..services.project_knowledge_graph import ProjectKnowledgeGraphService + +router = APIRouter(prefix="/ai-steward/knowledge-graph", tags=["AI Steward"]) + + +class StructuredSyncRequest(BaseModel): + limit: int = Field(default=1000, ge=1, le=5000) + + +class CodeScanRequest(BaseModel): + base_dir: Optional[str] = Field(default=None, max_length=400) + + +class FeishuDeltaEnqueueRequest(BaseModel): + limit: int = Field(default=500, ge=1, le=2000) + + +class EventProcessRequest(BaseModel): + max_batch: int = Field(default=200, ge=1, le=1000) + max_retry: int = Field(default=3, ge=0, le=10) + + +class IncrementalCycleRequest(BaseModel): + delta_limit: int = Field(default=1000, ge=1, le=5000) + event_batch: int = Field(default=200, ge=1, le=1000) + code_scan_hours: int = Field(default=6, ge=1, le=168) + code_scan_base_dir: Optional[str] = Field(default=None, max_length=400) + + +def _require_manager(user: User) -> None: + if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]): + raise HTTPException(status_code=403, detail="仅管理员可操作知识图谱同步") + + +@router.get("/overview") +async def get_kg_overview( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _ = current_user + service = ProjectKnowledgeGraphService(db, tenant_id=int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)) + data = await service.get_overview() + return success_response(data) + + +@router.get("/sync-runs") +async def list_sync_runs( + limit: int = Query(default=50, ge=1, le=200), + run_type: Optional[str] = Query(default=None, max_length=40), + status: Optional[str] = Query(default=None, max_length=20), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _require_manager(current_user) + service = ProjectKnowledgeGraphService(db, tenant_id=int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)) + data = await service.list_sync_runs( + limit=int(limit), + run_type=run_type, + status=status, + ) + return success_response(data) + + +@router.get("/sync-slo") +async def get_sync_slo( + lookback_hours: int = Query(default=24, ge=1, le=24 * 30), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _require_manager(current_user) + service = ProjectKnowledgeGraphService(db, tenant_id=int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)) + data = await service.get_sync_slo(lookback_hours=int(lookback_hours)) + return success_response(data) + + +@router.post("/sync/structured") +async def sync_structured( + body: StructuredSyncRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _require_manager(current_user) + service = ProjectKnowledgeGraphService(db, tenant_id=int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)) + data = await service.sync_structured_entities(limit=int(body.limit)) + return success_response(data) + + +@router.post("/sync/codebase") +async def sync_codebase( + body: CodeScanRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _require_manager(current_user) + service = ProjectKnowledgeGraphService(db, tenant_id=int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)) + + root = Path("/app/backend").resolve() + requested = Path(body.base_dir).resolve() if body.base_dir else root + # 仅允许扫描 backend 根目录或其子目录 + if root not in requested.parents and requested != root: + raise HTTPException(status_code=400, detail="base_dir 仅允许 /app/backend 范围内路径") + + try: + data = await service.sync_codebase_snapshot(base_dir=str(requested)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return success_response(data) + + +@router.post("/sync/feishu") +async def enqueue_feishu_delta( + body: FeishuDeltaEnqueueRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _require_manager(current_user) + service = ProjectKnowledgeGraphService(db, tenant_id=int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)) + data = await service.enqueue_feishu_delta_events(limit=int(body.limit)) + return success_response(data) + + +@router.post("/events/process") +async def process_ingest_events( + body: EventProcessRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _require_manager(current_user) + service = ProjectKnowledgeGraphService(db, tenant_id=int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)) + data = await service.process_pending_events( + max_batch=int(body.max_batch), + max_retry=int(body.max_retry), + ) + return success_response(data) + + +@router.post("/sync/incremental") +async def run_incremental_cycle( + body: IncrementalCycleRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _require_manager(current_user) + service = ProjectKnowledgeGraphService(db, tenant_id=int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)) + + root = Path("/app/backend").resolve() + requested = Path(body.code_scan_base_dir).resolve() if body.code_scan_base_dir else None + if requested is not None and root not in requested.parents and requested != root: + raise HTTPException(status_code=400, detail="code_scan_base_dir 仅允许 /app/backend 范围内路径") + + data = await service.run_incremental_cycle( + delta_limit=int(body.delta_limit), + event_batch=int(body.event_batch), + code_scan_hours=int(body.code_scan_hours), + code_scan_base_dir=str(requested) if requested else None, + run_source="manual_api", + ) + return success_response(data) diff --git a/backend/app/api/llm_engines.py b/backend/app/api/llm_engines.py new file mode 100644 index 00000000..f3e20ba1 --- /dev/null +++ b/backend/app/api/llm_engines.py @@ -0,0 +1,188 @@ +"""LLM 引擎配置 API。""" + +from __future__ import annotations + +from typing import Literal, Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +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.tenant import get_tenant_id +from ..models import User +from ..services.auth import has_any_role +from ..services.llm import llm_service + +router = APIRouter(prefix="/ai-steward/llm-engines", tags=["AI Steward"]) + + +class LLMEngineCreateRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=80) + provider: Literal["deepseek", "glm", "openai", "ollama"] + api_key: Optional[str] = Field(default="", max_length=500) + model: Optional[str] = Field(default=None, max_length=120) + base_url: Optional[str] = Field(default=None, max_length=255) + set_active: bool = True + + +class LLMEngineUpdateRequest(BaseModel): + name: Optional[str] = Field(default=None, min_length=1, max_length=80) + provider: Optional[Literal["deepseek", "glm", "openai", "ollama"]] = None + api_key: Optional[str] = Field(default=None, max_length=500) + model: Optional[str] = Field(default=None, max_length=120) + base_url: Optional[str] = Field(default=None, max_length=255) + + +class LLMEngineTestRequest(BaseModel): + prompt: Optional[str] = Field(default=None, max_length=1000) + + +def _require_manager(user: User) -> None: + if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]): + raise HTTPException(status_code=403, detail="仅管理员可操作 LLM 引擎配置") + + +@router.get("") +async def list_llm_engines( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _ = current_user + data = await llm_service.list_engines(db, tenant_id=tenant_id) + return success_response(data) + + +@router.post("") +async def create_llm_engine( + body: LLMEngineCreateRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _require_manager(current_user) + try: + engine = await llm_service.create_engine( + db, + tenant_id=tenant_id, + name=body.name, + provider=body.provider, + api_key=body.api_key, + model=body.model, + base_url=body.base_url, + set_active=body.set_active, + ) + all_items = await llm_service.list_engines(db, tenant_id=tenant_id) + return success_response({"engine": engine, **all_items}) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.put("/{engine_id}") +async def update_llm_engine( + engine_id: str, + body: LLMEngineUpdateRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _require_manager(current_user) + try: + engine = await llm_service.update_engine( + db, + tenant_id=tenant_id, + engine_id=engine_id, + name=body.name, + provider=body.provider, + api_key=body.api_key, + model=body.model, + base_url=body.base_url, + ) + all_items = await llm_service.list_engines(db, tenant_id=tenant_id) + return success_response({"engine": engine, **all_items}) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/{engine_id}/activate") +async def activate_llm_engine( + engine_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _require_manager(current_user) + try: + result = await llm_service.activate_engine( + db, + tenant_id=tenant_id, + engine_id=engine_id, + ) + all_items = await llm_service.list_engines(db, tenant_id=tenant_id) + return success_response({**result, **all_items}) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/test-active") +async def test_active_llm_engine( + body: LLMEngineTestRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _require_manager(current_user) + try: + data = await llm_service.test_engine_connection( + db, + tenant_id=tenant_id, + engine_id=None, + prompt=body.prompt, + ) + return success_response(data) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/{engine_id}/test") +async def test_llm_engine( + engine_id: str, + body: LLMEngineTestRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _require_manager(current_user) + try: + data = await llm_service.test_engine_connection( + db, + tenant_id=tenant_id, + engine_id=engine_id, + prompt=body.prompt, + ) + return success_response(data) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.delete("/{engine_id}") +async def delete_llm_engine( + engine_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _require_manager(current_user) + try: + result = await llm_service.delete_engine( + db, + tenant_id=tenant_id, + engine_id=engine_id, + ) + all_items = await llm_service.list_engines(db, tenant_id=tenant_id) + return success_response({**result, **all_items}) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py new file mode 100644 index 00000000..c91d8853 --- /dev/null +++ b/backend/app/api/notifications.py @@ -0,0 +1,171 @@ +"""站内通知 API""" +from __future__ import annotations + +from datetime import datetime, timezone +import re +from typing import Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import and_, desc, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..core.response import success_response +from ..database import get_db +from ..dependencies import get_current_user, get_tenant_id +from ..models import Notification, User + + +router = APIRouter(prefix="/notifications", tags=["notifications"]) + + +PROJECT_TARGET_RE = re.compile(r"/projects/(\d+)") + + +def _extract_project_id(row: Notification) -> Optional[int]: + payload = row.payload if isinstance(row.payload, dict) else {} + payload_project_id = payload.get("project_id") + if payload_project_id is not None: + try: + return int(payload_project_id) + except (TypeError, ValueError): + pass + if row.entity_type == "project" and row.entity_id is not None: + return int(row.entity_id) + target_url = str(row.target_url or "").strip() + matched = PROJECT_TARGET_RE.search(target_url) + if matched: + try: + return int(matched.group(1)) + except (TypeError, ValueError): + return None + return None + + +def _extract_project_name(row: Notification) -> Optional[str]: + payload = row.payload if isinstance(row.payload, dict) else {} + name = payload.get("project_name") + if isinstance(name, str) and name.strip(): + return name.strip() + return None + + +def _serialize_notification(row: Notification) -> dict: + payload: Optional[dict[str, Any]] = row.payload if isinstance(row.payload, dict) else None + return { + "id": int(row.id), + "title": row.title, + "content": row.content, + "category": row.category, + "source": row.source, + "entity_type": row.entity_type, + "entity_id": int(row.entity_id) if row.entity_id is not None else None, + "target_url": row.target_url, + "kind": row.kind or "info", + "priority": row.priority, + "status": row.status or row.kind or "info", + "blocking_reason": row.blocking_reason, + "recommended_action": row.recommended_action, + "dedupe_key": row.dedupe_key, + "occurrence_count": int(row.occurrence_count or 1), + "payload": payload, + "project_id": _extract_project_id(row), + "project_name": _extract_project_name(row), + "is_read": bool(row.is_read), + "read_at": row.read_at.isoformat() if row.read_at else None, + "created_at": row.created_at.isoformat() if row.created_at else None, + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + } + + +@router.get("") +async def list_notifications( + unread_only: bool = Query(default=False), + limit: int = Query(default=20, ge=1, le=100), + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + query = select(Notification).where( + and_( + Notification.tenant_id == tenant_id, + Notification.user_id == int(current_user.id), + ) + ) + if unread_only: + query = query.where(Notification.is_read.is_(False)) + query = query.order_by(desc(Notification.updated_at), desc(Notification.created_at)).limit(limit) + + rows = (await db.execute(query)).scalars().all() + unread_count = await db.scalar( + select(func.count(Notification.id)).where( + and_( + Notification.tenant_id == tenant_id, + Notification.user_id == int(current_user.id), + Notification.is_read.is_(False), + ) + ) + ) + return success_response( + data={ + "items": [_serialize_notification(row) for row in rows], + "unread_count": int(unread_count or 0), + } + ) + + +@router.post("/{notification_id}/read") +async def mark_notification_read( + notification_id: int, + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + row = ( + await db.execute( + select(Notification).where( + and_( + Notification.id == notification_id, + Notification.tenant_id == tenant_id, + Notification.user_id == int(current_user.id), + ) + ) + ) + ).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="通知不存在") + + if not row.is_read: + row.is_read = True + row.read_at = datetime.now(timezone.utc) + await db.commit() + + return success_response(data=_serialize_notification(row)) + + +@router.post("/read-all") +async def mark_all_notifications_read( + current_user: User = Depends(get_current_user), + tenant_id: int = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + rows = ( + await db.execute( + select(Notification).where( + and_( + Notification.tenant_id == tenant_id, + Notification.user_id == int(current_user.id), + Notification.is_read.is_(False), + ) + ) + ) + ).scalars().all() + + now = datetime.now(timezone.utc) + for row in rows: + row.is_read = True + row.read_at = now + + if rows: + await db.commit() + + return success_response(data={"updated": len(rows)}) diff --git a/backend/app/api/operation_catalog.py b/backend/app/api/operation_catalog.py new file mode 100644 index 00000000..fd76395e --- /dev/null +++ b/backend/app/api/operation_catalog.py @@ -0,0 +1,57 @@ +"""Operation Catalog API。""" + +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query + +from ..core.response import success_response +from ..dependencies.auth import get_current_user +from ..models import User +from ..services.auth import has_any_role +from ..services.operation_catalog_service import operation_catalog_service + +router = APIRouter(prefix="/ai-steward/operation-catalog", tags=["AI Steward"]) + + +def _require_manager(user: User) -> None: + if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]): + raise HTTPException(status_code=403, detail="仅管理员可查看 Operation Catalog 质量报告") + + +@router.get("") +async def list_operation_catalog( + domain: Optional[str] = Query(default=None), + current_user: User = Depends(get_current_user), +): + _ = current_user + rows = operation_catalog_service.list_operations(domain=domain) + return success_response( + { + "items": rows, + "count": len(rows), + "domain": domain, + } + ) + + +@router.get("/lint") +async def lint_operation_catalog( + current_user: User = Depends(get_current_user), +): + _require_manager(current_user) + report = operation_catalog_service.lint_catalog() + return success_response(report) + + +@router.get("/{operation_code}") +async def get_operation_catalog_item( + operation_code: str, + current_user: User = Depends(get_current_user), +): + _ = current_user + row = operation_catalog_service.get_operation(operation_code=operation_code) + if row is None: + raise HTTPException(status_code=404, detail="operation_code not found") + return success_response(row) diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py index 4cc54074..adc542e4 100644 --- a/backend/app/api/projects.py +++ b/backend/app/api/projects.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import Optional +import json +from typing import Any, Optional from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel @@ -12,6 +13,9 @@ 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 Project, ProjectSignalLink, Signal, Task, User +from ..services.context_cache import context_cache_service +from ..utils.project_members import ensure_project_default_members +from ..utils.sequence import ensure_project_id_sequence from ..utils.title import compact_title router = APIRouter(prefix="/projects", tags=["projects"]) @@ -19,6 +23,18 @@ 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 @@ -187,6 +203,7 @@ async def create_project( title = compact_title(body.title, max_len=24) if not title: raise HTTPException(status_code=400, detail="title required") + await ensure_project_id_sequence(db) project = Project( tenant_id=tenant_id, name=title, @@ -199,8 +216,17 @@ async def create_project( signal_count=0, ) db.add(project) + await db.flush() + await ensure_project_default_members( + db, + tenant_id=tenant_id, + project_id=int(project.id), + owner_id=int(current_user.id), + assigned_role=project.assigned_role, + ) await db.commit() await db.refresh(project) + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) return _serialize_project_row(project) @@ -229,6 +255,7 @@ async def update_project( project.assigned_role = body.assigned_role await db.commit() await db.refresh(project) + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) return _serialize_project_row(project) @@ -266,46 +293,10 @@ async def delete_project( ) ) await db.commit() + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) return {"message": "Project deleted"} -@router.get("/{project_id}/signals") -async def list_project_signals( - project_id: int, - tenant_id: int = Depends(get_tenant_id), - db: AsyncSession = Depends(get_db), -): - await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id) - - rows = ( - await db.execute( - select(ProjectSignalLink, Signal) - .join(Signal, Signal.id == ProjectSignalLink.signal_id) - .where( - and_( - ProjectSignalLink.tenant_id == tenant_id, - ProjectSignalLink.project_id == project_id, - Signal.tenant_id == tenant_id, - ) - ) - ) - ).all() - return [ - { - "id": str(signal.id), - "source": signal.source, - "content": signal.content, - "impact_score": signal.impact_score, - "priority": signal.priority, - "processed": bool(signal.processed), - "created_at": signal.created_at.isoformat() if signal.created_at else None, - "link_type": link.link_type, - "relevance_score": float(link.relevance_score or 0.0), - } - for link, signal in rows - ] - - @router.post("/{project_id}/signals/{signal_id}") async def link_signal_to_project( project_id: int, @@ -361,6 +352,7 @@ async def link_signal_to_project( 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), @@ -390,4 +382,5 @@ async def unlink_signal_from_project( ) ) await db.commit() + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) return {"message": "Signal unlinked"} diff --git a/backend/app/api/prompt_profiles.py b/backend/app/api/prompt_profiles.py new file mode 100644 index 00000000..47e67cb4 --- /dev/null +++ b/backend/app/api/prompt_profiles.py @@ -0,0 +1,79 @@ +"""AI 角色 Prompt 配置 API。""" + +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +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.tenant import get_tenant_id +from ..models import User +from ..services.auth import has_any_role +from ..services.prompt_profile_service import prompt_profile_service + +router = APIRouter(prefix="/ai-steward/prompt-profiles", tags=["AI Steward"]) + + +class PromptRoleItem(BaseModel): + role_key: str = Field(..., min_length=2, max_length=32, pattern=r"^[a-z0-9][a-z0-9_-]{1,31}$") + role_name: str = Field(..., min_length=1, max_length=32) + role_description: Optional[str] = Field(default="", max_length=300) + system_prompt: str = Field(..., min_length=20, max_length=8000) + enabled: bool = True + + +class PromptProfileUpdateRequest(BaseModel): + active_role_key: Optional[str] = Field(default=None, min_length=2, max_length=32) + roles: list[PromptRoleItem] = Field(..., min_length=1, max_length=12) + + +def _require_manager(user: User) -> None: + if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]): + raise HTTPException(status_code=403, detail="仅管理员可修改 Prompt 配置") + + +@router.get("") +async def get_prompt_profiles( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _ = current_user + data = await prompt_profile_service.get_profiles(db, tenant_id=tenant_id) + return success_response(data) + + +@router.get("/active") +async def get_active_prompt_profile( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _ = current_user + data = await prompt_profile_service.get_active_role(db, tenant_id=tenant_id) + return success_response(data) + + +@router.put("") +async def update_prompt_profiles( + body: PromptProfileUpdateRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + _require_manager(current_user) + try: + data = await prompt_profile_service.update_profiles( + db, + tenant_id=tenant_id, + roles=[item.model_dump() for item in body.roles], + active_role_key=body.active_role_key, + ) + return success_response(data) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/backend/app/api/rag.py b/backend/app/api/rag.py new file mode 100644 index 00000000..11fdc8c4 --- /dev/null +++ b/backend/app/api/rag.py @@ -0,0 +1,570 @@ +"""Adaptive RAG API。""" + +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"]) + + +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") diff --git a/backend/app/api/recipes.py b/backend/app/api/recipes.py new file mode 100644 index 00000000..e521e1a1 --- /dev/null +++ b/backend/app/api/recipes.py @@ -0,0 +1,165 @@ +"""食谱 API - 支持热量识别与营养分析""" + +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from sqlalchemy.ext.asyncio import AsyncSession + +from ..database import get_db +from ..dependencies.auth import get_current_tenant_id +from ..schemas.recipe import ( + RecipeCreate, + RecipeUpdate, + RecipeResponse, + RecipeImageUploadRequest, + RecipeAnalyzeCaloriesResponse, +) +from ..services.recipe_service import get_recipe_service + +router = APIRouter(prefix="/recipes", tags=["食谱"]) + + +@router.post("/", response_model=RecipeResponse, summary="创建食谱") +async def create_recipe( + recipe_data: RecipeCreate, + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_current_tenant_id), +): + """创建新食谱""" + service = get_recipe_service() + recipe = await service.create_recipe(db, recipe_data, tenant_id) + return recipe + + +@router.post("/analyze", response_model=RecipeAnalyzeCaloriesResponse, summary="AI 热量分析") +async def analyze_food_calories( + recipe_data: RecipeCreate, + food_description: str, + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_current_tenant_id), +): + """ + 创建食谱并进行 AI 热量分析 + + - food_description: 食物描述(可以是图片识别后的描述) + """ + service = get_recipe_service() + recipe, analysis = await service.create_recipe_with_ai_analysis( + db, recipe_data, food_description, tenant_id + ) + + if not analysis["success"]: + return RecipeAnalyzeCaloriesResponse( + success=False, + data=None, + message=analysis["message"], + ) + + return RecipeAnalyzeCaloriesResponse( + success=True, + data=recipe, + message=analysis["message"], + ) + + +@router.get("/", response_model=list[RecipeResponse], summary="获取食谱列表") +async def list_recipes( + skip: int = 0, + limit: int = 20, + category: Optional[str] = None, + cuisine: Optional[str] = None, + status: Optional[str] = None, + is_favorite: Optional[bool] = None, + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_current_tenant_id), +): + """获取食谱列表,支持分类筛选""" + service = get_recipe_service() + recipes = await service.list_recipes( + db, + tenant_id, + skip=skip, + limit=limit, + category=category, + cuisine=cuisine, + status=status, + is_favorite=is_favorite, + ) + return recipes + + +@router.get("/{recipe_id}", response_model=RecipeResponse, summary="获取单个食谱") +async def get_recipe( + recipe_id: int, + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_current_tenant_id), +): + """获取指定食谱详情""" + service = get_recipe_service() + recipe = await service.get_recipe(db, recipe_id, tenant_id) + if not recipe: + raise HTTPException(status_code=404, detail="食谱不存在") + return recipe + + +@router.put("/{recipe_id}", response_model=RecipeResponse, summary="更新食谱") +async def update_recipe( + recipe_id: int, + recipe_data: RecipeUpdate, + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_current_tenant_id), +): + """更新食谱信息""" + service = get_recipe_service() + recipe = await service.update_recipe(db, recipe_id, recipe_data, tenant_id) + if not recipe: + raise HTTPException(status_code=404, detail="食谱不存在") + return recipe + + +@router.delete("/{recipe_id}", summary="删除食谱") +async def delete_recipe( + recipe_id: int, + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_current_tenant_id), +): + """删除食谱""" + service = get_recipe_service() + success = await service.delete_recipe(db, recipe_id, tenant_id) + if not success: + raise HTTPException(status_code=404, detail="食谱不存在") + return {"message": "删除成功"} + + +@router.post("/{recipe_id}/favorite", response_model=RecipeResponse, summary="收藏/取消收藏食谱") +async def toggle_favorite( + recipe_id: int, + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_current_tenant_id), +): + """收藏或取消收藏食谱""" + service = get_recipe_service() + recipe = await service.get_recipe(db, recipe_id, tenant_id) + if not recipe: + raise HTTPException(status_code=404, detail="食谱不存在") + + recipe.is_favorite = not recipe.is_favorite + await db.commit() + await db.refresh(recipe) + return recipe + + +@router.post("/upload-image", response_model=dict, summary="上传图片(示例)") +async def upload_recipe_image( + image_base64: str, + filename: Optional[str] = None, + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_current_tenant_id), +): + """ + 上传食谱图片 + + 注意:这是示例实现,实际项目中需要集成对象存储服务(如 S3、OSS、COS 等) + """ + service = get_recipe_service() + image_url = await service.upload_image(image_base64, filename) + return {"image_url": image_url} diff --git a/backend/app/api/router.py b/backend/app/api/router.py index d95eebc8..b01edf04 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -14,13 +14,25 @@ from .tasks import router as tasks_router from .webhooks import router as webhooks_router from .auth import router as auth_router from .users import router as users_router -from .projects import router as projects_router from .endpoints.skill import router as skill_router from .workloads import router as workloads_router from .skill_tags import router as skill_tags_router from .sstc import router as sstc_router +from .projects import router as projects_router from .scheduler import router as scheduler_router from .ai_steward import router as ai_steward_router +from .feishu import router as feishu_router +from .wecom import router as wecom_router +from .notifications import router as notifications_router +from .llm_engines import router as llm_engines_router +from .ai_logs import router as ai_logs_router +from .prompt_profiles import router as prompt_profiles_router +from .rag import router as rag_router +from .knowledge_graph import router as knowledge_graph_router +from .recipes import router as recipes_router +from .signal_sources import router as signal_sources_router +from .operation_catalog import router as operation_catalog_router +from .ai_pmo import router as ai_pmo_router # 创建主 API 路由器 api_router = APIRouter() @@ -34,10 +46,22 @@ api_router.include_router(tasks_router) api_router.include_router(webhooks_router) api_router.include_router(auth_router) api_router.include_router(users_router) -api_router.include_router(projects_router) api_router.include_router(skill_router) api_router.include_router(workloads_router) api_router.include_router(skill_tags_router) api_router.include_router(sstc_router) +api_router.include_router(projects_router) api_router.include_router(scheduler_router) api_router.include_router(ai_steward_router) +api_router.include_router(feishu_router) +api_router.include_router(wecom_router) +api_router.include_router(notifications_router) +api_router.include_router(llm_engines_router) +api_router.include_router(ai_logs_router) +api_router.include_router(prompt_profiles_router) +api_router.include_router(rag_router) +api_router.include_router(knowledge_graph_router) +api_router.include_router(recipes_router) +api_router.include_router(signal_sources_router) +api_router.include_router(operation_catalog_router) +api_router.include_router(ai_pmo_router) diff --git a/backend/app/api/scheduler.py b/backend/app/api/scheduler.py index ea35490e..1b6a9537 100644 --- a/backend/app/api/scheduler.py +++ b/backend/app/api/scheduler.py @@ -1,11 +1,18 @@ """Scheduler API 路由。""" +from datetime import datetime, timedelta + from fastapi import APIRouter, Depends +from sqlalchemy import and_, or_, select from sqlalchemy.ext.asyncio import AsyncSession from ..database import get_db from ..dependencies import require_manager_user -from ..models import User +from ..models import KnowledgeBaseDoc, Project, User +from ..services.adaptive_rag_service import AdaptiveRAGService +from ..services.ai_pmo_cadence_service import AIPMOCadenceService +from ..services.project_knowledge_graph import ProjectKnowledgeGraphService +from ..services.symbol_index_service import SymbolIndexService from ..services import workload_service router = APIRouter(prefix="/scheduler", tags=["scheduler"]) @@ -35,3 +42,132 @@ async def trigger_workload_recalc( "message": "success", "data": {"tenant_count": len(tenant_ids), "user_count": total}, } + + +@router.post("/knowledge-base/urls/refresh") +async def trigger_knowledge_url_refresh( + current_user: User = Depends(require_manager_user), + db: AsyncSession = Depends(get_db), +) -> dict: + """手动触发 URL 知识库回抓。""" + tenant_id = int(current_user.tenant_id) + cutoff = datetime.now() - timedelta(days=7) + docs = ( + await db.execute( + select(KnowledgeBaseDoc).where( + and_( + KnowledgeBaseDoc.tenant_id == tenant_id, + KnowledgeBaseDoc.source_type == "url", + or_( + KnowledgeBaseDoc.last_fetched_at.is_(None), + KnowledgeBaseDoc.last_fetched_at <= cutoff, + ), + ) + ) + ) + ).scalars().all() + + refreshed = 0 + failed = 0 + service = AdaptiveRAGService(db, tenant_id=tenant_id) + for doc in docs: + try: + await service.refresh_url_doc(doc_id=int(doc.id), project_id=None) + refreshed += 1 + except Exception: + failed += 1 + + return { + "code": 0, + "message": "success", + "data": {"candidate_count": len(docs), "refreshed": refreshed, "failed": failed}, + } + + +@router.post("/knowledge-graph/incremental") +async def trigger_knowledge_graph_incremental( + current_user: User = Depends(require_manager_user), + db: AsyncSession = Depends(get_db), +) -> dict: + """手动触发一次知识图谱增量流水线。""" + tenant_id = int(current_user.tenant_id) + service = ProjectKnowledgeGraphService(db, tenant_id=tenant_id) + data = await service.run_incremental_cycle(run_source="manual_scheduler_api") + return { + "code": 0, + "message": "success", + "data": data, + } + + +@router.post("/symbols/sync") +async def trigger_symbol_index_sync( + current_user: User = Depends(require_manager_user), + db: AsyncSession = Depends(get_db), +) -> dict: + """手动触发一次 Symbol 索引同步。""" + tenant_id = int(current_user.tenant_id) + service = SymbolIndexService(db, tenant_id=tenant_id) + data = await service.sync_codebase_symbols(max_files=800, force_reindex=False) + return { + "code": 0, + "message": "success", + "data": data, + } + + +@router.post("/features/catalog/sync") +async def trigger_feature_catalog_sync( + current_user: User = Depends(require_manager_user), + db: AsyncSession = Depends(get_db), +) -> dict: + """手动触发一次 Feature Catalog -> KG 同步。""" + tenant_id = int(current_user.tenant_id) + service = ProjectKnowledgeGraphService(db, tenant_id=tenant_id) + data = await service.sync_feature_catalog_now() + return { + "code": 0, + "message": "success", + "data": data, + } + + +@router.post("/ai-pmo/cadence/run") +async def trigger_ai_pmo_cadence_run( + cadence_type: str = "all", + project_id: int | None = None, + current_user: User = Depends(require_manager_user), + db: AsyncSession = Depends(get_db), +) -> dict: + """手动触发 AI PMO cadence snapshot。""" + tenant_id = int(current_user.tenant_id) + service = AIPMOCadenceService(db, tenant_id=tenant_id) + if cadence_type != "all": + snapshot = await service.generate_snapshot(cadence_type=cadence_type, project_id=project_id) + await db.commit() + return {"code": 0, "message": "success", "data": {"count": 1, "items": [snapshot]}} + + items = [await service.generate_snapshot(cadence_type="risk_review", project_id=project_id)] + if project_id is None: + items.append(await service.generate_snapshot(cadence_type="daily_standup", project_id=None)) + items.append(await service.generate_snapshot(cadence_type="milestone_check", project_id=None)) + if datetime.now().weekday() == 0: + items.append(await service.generate_snapshot(cadence_type="weekly_review", project_id=None)) + project_ids = ( + await db.execute( + select(Project.id).where( + and_( + Project.tenant_id == tenant_id, + Project.status.in_(["active", "planning", "doing", "todo"]), + ) + ) + ) + ).scalars().all() + for pid in project_ids: + items.append(await service.generate_snapshot(cadence_type="risk_review", project_id=int(pid))) + else: + items.append(await service.generate_snapshot(cadence_type="daily_standup", project_id=int(project_id))) + items.append(await service.generate_snapshot(cadence_type="milestone_check", project_id=int(project_id))) + + await db.commit() + return {"code": 0, "message": "success", "data": {"count": len(items), "items": items}} diff --git a/backend/app/api/signal_sources.py b/backend/app/api/signal_sources.py new file mode 100644 index 00000000..0ab97679 --- /dev/null +++ b/backend/app/api/signal_sources.py @@ -0,0 +1,401 @@ +"""食谱系统信号源 API + +功能: +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 ..database import get_db +from ..dependencies.tenant import get_tenant_id +from ..core.logging import get_logger +from ..models import Signal, ProjectSignalLink, Project + +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( + request: RecipeRecognitionRequest, + project_id: Optional[int] = Query(None, description="项目ID,不传则自动查找食谱系统项目"), + 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( + db=db, + tenant_id=tenant_id, + project_id=project_id, + ) + + # 2. 构建信号内容 + content_parts = [] + content_parts.append(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: + nutrition.append(f"热量 {request.calories} kcal") + if request.protein: + nutrition.append(f"蛋白 {request.protein}g") + if request.fat: + nutrition.append(f"脂肪 {request.fat}g") + if request.carbs: + nutrition.append(f"碳水 {request.carbs}g") + 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": "推荐菜谱" + }.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, + "category": request.category, + "cuisine": request.cuisine, + "nutrition": { + "calories": request.calories, + "protein": request.protein, + "fat": request.fat, + "carbs": request.carbs, + "fiber": request.fiber, + }, + "image_url": request.image_url, + "user_name": request.user_name, + } + + # 6. 创建信号 + signal = Signal( + tenant_id=tenant_id, + source="recipe_system", + title=title, + content=content, + summary=summary, + signal_type="recipe_recognition", + priority="medium", + impact_score=0.5, + status="待处理", + processed=False, + strategic_analysis=json.dumps(signal_metadata, ensure_ascii=False), + ) + + db.add(signal) + await db.flush() # 获取 signal.id + + # 7. 关联到项目 + 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, + project_id=target_project_id, + message="信号创建成功并已关联到食谱系统项目", + details={ + "signal_id": signal.id, + "project_id": target_project_id, + "title": title, + "status": signal.status, + } + ) + + except HTTPException: + await db.rollback() + raise + except Exception as e: + 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), + ) + raise HTTPException(status_code=500, detail=f"创建信号失败: {str(e)}") + + +@router.get("/recipe/status/{signal_id}", summary="查询食谱信号状态") +async def get_recipe_signal_status( + signal_id: int, + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + """查询食谱识别信号的处理状态""" + from sqlalchemy import select + + result = await db.execute( + select(Signal).where( + Signal.id == signal_id, + Signal.tenant_id == tenant_id, + Signal.source == "recipe_system", + ) + ) + signal = result.scalar_one_or_none() + + if not signal: + raise HTTPException(status_code=404, detail="信号不存在") + + return { + "signal_id": signal.id, + "title": signal.title, + "status": signal.status, + "processed": signal.processed, + "summary": signal.summary, + } + + +# ============================================================================ +# 批量导入食谱记录 +# ============================================================================ + +class RecipeBatchRequest(BaseModel): + """批量导入请求""" + recipes: list[RecipeRecognitionRequest] = Field(..., description="食谱列表") + + +@router.post("/recipe/batch", response_model=RecipeRecognitionResponse, summary="批量导入食谱") +async def recipe_batch_import( + request: RecipeBatchRequest, + project_id: Optional[int] = Query(None, description="项目ID"), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + """批量导入食谱记录,每个都会创建信号""" + try: + 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, + "nutrition": { + "calories": recipe.calories, + "protein": recipe.protein, + "fat": recipe.fat, + "carbs": recipe.carbs, + } + } + + signal = Signal( + tenant_id=tenant_id, + source="recipe_system", + title=title, + content=content, + summary=f"食物:{recipe.food_name}", + signal_type="recipe_recognition", + priority="low", + impact_score=0.3, + status="待处理", + processed=True, # 批量导入的视为已处理 + strategic_analysis=json.dumps(metadata), + ) + db.add(signal) + await db.flush() + + # 关联项目 + link = ProjectSignalLink( + tenant_id=tenant_id, + project_id=target_project_id, + signal_id=signal.id, + ) + db.add(link) + signal_ids.append(signal.id) + + await db.commit() + + return RecipeRecognitionResponse( + success=True, + signal_id=len(signal_ids), # 返回数量 + project_id=target_project_id, + message=f"成功导入 {len(signal_ids)} 条食谱记录", + details={"signal_count": len(signal_ids)} + ) + + except HTTPException: + await db.rollback() + raise + except Exception as e: + 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), + ) + raise HTTPException(status_code=500, detail=f"批量导入失败: {str(e)}") diff --git a/backend/app/api/sstc.py b/backend/app/api/sstc.py index 0603bba8..79e233e2 100644 --- a/backend/app/api/sstc.py +++ b/backend/app/api/sstc.py @@ -12,19 +12,29 @@ SSTC 唯一事实链路 API from __future__ import annotations -from datetime import datetime, timezone +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, Depends, HTTPException, Query +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 success_response +from ..core.response import error_response, success_response from ..database import get_db from ..models import ( AICallLog, AlignmentRecord, + Integration, + ModelRequirementBinding, + ModelVersion, ProjectSignalLink, Signal, Task, @@ -36,13 +46,18 @@ 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.operation_log_service import log_operation from ..services.auth import has_any_role from ..services.skill_service import recommend_personnel @@ -54,11 +69,112 @@ from ..services.sstc_service import ( 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 router = APIRouter(tags=["sstc"]) +_FEISHU_PLACEHOLDER_RE = re.compile(r"^飞书用户\(([^)]+)\)$") +_SIGNAL_PREFIX_RE = re.compile(r"^\[(.*?)\]\s*:\s*") + +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: @@ -88,6 +204,901 @@ def _as_dict(value: Any) -> dict: 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 _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: + return _strip_signal_sender_prefix(str(signal.content or "")) + + +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 = datetime.now(timezone.utc).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 @@ -130,6 +1141,129 @@ async def _ensure_charter( 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, *, @@ -175,6 +1309,308 @@ async def _ensure_release( 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": datetime.now(timezone.utc).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( @@ -237,6 +1673,26 @@ async def get_risks( 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, @@ -252,43 +1708,311 @@ async def get_sstc_chain( @router.get("/projects/{project_id}/signals") async def list_signals( project_id: str, - status: Optional[str] = Query(default=None, description="pending/linked/archived"), + 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) - svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) - await _ensure_project(svc, pid) + 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_ids: set[int] = set() + linked_signal_states: dict[int, str] = {} + linked_signal_charter_name: dict[int, str] = {} for c in charters: - payload = _as_dict(c.content) - for sid in payload.get("signal_ids", []): - if isinstance(sid, int): - linked_signal_ids.add(sid) + 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: - row_status = "pending" - if int(s.id) in linked_signal_ids: - row_status = "linked" - if bool(s.processed): - row_status = "archived" - if status and row_status != status: - continue - rows.append( - { - "id": str(s.id), - "title": (s.content or "")[:80] or "未命名信号", - "content": s.content, - "priority": s.priority, - "signal_type": s.source or "other", - "source": s.source, - "status": row_status, - "created_at": s.created_at.isoformat() if s.created_at else None, - } + 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), ) - return success_response(rows) + 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), + } + 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(timezone.utc).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": datetime.now(timezone.utc).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") @@ -314,6 +2038,7 @@ async def archive_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), @@ -326,13 +2051,19 @@ async def archive_signal( after_data={"processed": True}, ) await db.commit() - return success_response({"id": str(signal.id), "status": "archived"}) + 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") @@ -391,12 +2122,51 @@ async def create_charter( 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 {}) - content["signal_ids"] = sorted(set(body.signal_ids)) + 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, @@ -415,8 +2185,21 @@ async def create_charter( 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), @@ -453,6 +2236,11 @@ async def get_charter_detail( ) ) 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( @@ -474,6 +2262,14 @@ async def get_charter_detail( ) ) 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( { @@ -485,12 +2281,29 @@ async def get_charter_detail( { "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), } 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), + } + if source_signal + else None + ), "tasks": [ { "id": str(t.id), @@ -569,6 +2382,14 @@ async def update_charter( ) await db.commit() await db.refresh(new_node) + _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), @@ -603,6 +2424,14 @@ async def update_charter( ) 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), @@ -615,6 +2444,109 @@ async def update_charter( ) +@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, @@ -708,6 +2640,26 @@ async def confirm_alignment( 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, @@ -774,36 +2726,42 @@ async def create_task( due_date = _parse_due_date(body.due_date) - task = Task( + task = await persist_task( + db, tenant_id=tenant_id, truth_node_id=cid, project_id=pid, - assignee_id=None, - title=compact_title(body.title, max_len=32), - description=body.description, - required_skill_tag_ids=body.required_skill_tag_ids or [], - status="todo", - priority=body.priority, - estimated_hours=body.estimated_hours, - due_date=due_date, - ) - db.add(task) - await log_operation( - db, - tenant_id=tenant_id, - user_id=int(current_user.id), - project_id=pid, - entity_type="task", - entity_id="new", - action="create", - after_data={ - "title": task.title, - "truth_node_id": cid, - "priority": body.priority, - }, + 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), @@ -861,19 +2819,25 @@ async def ai_breakdown_tasks( continue priority = str(item.get("priority") or "P1").lower() mapped_priority = "high" if priority == "p0" else "medium" if priority == "p1" else "low" - task = Task( + task = await persist_task( + db, tenant_id=tenant_id, truth_node_id=cid, project_id=pid, - assignee_id=None, - title=title, - description=item.get("description"), - required_skill_tag_ids=[], - status="todo", - priority=mapped_priority, - estimated_hours=float(item.get("estimated_hours") or 0) or None, + 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, ) - db.add(task) created.append(task) completion_tokens += max(len(title) // 2, 1) @@ -900,6 +2864,16 @@ async def ai_breakdown_tasks( 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), @@ -1003,6 +2977,12 @@ async def patch_task_status( ) 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( @@ -1011,6 +2991,14 @@ async def patch_task_status( 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}) @@ -1091,6 +3079,17 @@ async def patch_task( ) 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}) @@ -1144,6 +3143,9 @@ async def delete_task( ) 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}) @@ -1211,6 +3213,7 @@ async def recommend_assignee_for_task( @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), @@ -1220,46 +3223,29 @@ async def optimize_task_workload( pid = _parse_project_id(project_id) tenant_id = int(current_user.tenant_id) await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) - task_result = await db.execute( - select(Task).where( - and_(Task.project_id == pid, Task.tenant_id == tenant_id, Task.status.in_(["todo", "in_progress"])) - ) + 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", ) - tasks = list(task_result.scalars().all()) - wl_result = await db.execute( - select(User.id, User.workload) - .join( - UserProject, - and_( - UserProject.user_id == User.id, - UserProject.tenant_id == tenant_id, - UserProject.project_id == pid, - ), + if not proposals: + return success_response( + { + "updated": 0, + "message": "no tasks to optimize", + "mode": body.mode, + } ) - .where( - and_( - User.tenant_id == tenant_id, - User.status == "active", - User.is_active.is_(True), - ) - ) - ) - members = [{"id": int(uid), "workload": int(wl or 0)} for uid, wl in wl_result.all()] - if not members: - return success_response({"updated": 0, "message": "no active users"}) - members.sort(key=lambda x: x["workload"]) - updated = 0 - touched_user_ids: set[int] = set() - for task in tasks: - if task.assignee_id: - continue - picked = members[0] - task.assignee_id = int(picked["id"]) - touched_user_ids.add(int(picked["id"])) - picked["workload"] += int(float(task.estimated_hours or 1) * 2) - members.sort(key=lambda x: x["workload"]) - updated += 1 + 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, @@ -1269,18 +3255,22 @@ async def optimize_task_workload( entity_type="task", entity_id=str(pid), action="optimize_workload", - after_data={"updated": updated}, + 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, + } ) - 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() - return success_response({"updated": updated}) @router.get("/projects/{project_id}/releases") @@ -1349,12 +3339,20 @@ async def create_release_plan( 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, @@ -1408,10 +3406,18 @@ async def patch_release_status( 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"] = datetime.now(timezone.utc).isoformat() release_node.content = content await log_operation( db, @@ -1465,17 +3471,79 @@ async def generate_release_verification( "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"] = datetime.now(timezone.utc).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, @@ -1493,15 +3561,21 @@ async def complete_release_verification( 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 = datetime.now(timezone.utc).isoformat() - for item in items: - item["status"] = "done" - item["verified_by"] = str(current_user.id) - item["verified_at"] = now_str 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() @@ -1555,6 +3629,12 @@ async def create_review( 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", @@ -1562,7 +3642,7 @@ async def create_review( project_id=pid, created_by=int(current_user.id), title=compact_title(body.title, max_len=24), - content=body.content, + content=normalized_content, version=1, status="draft", ) @@ -1579,6 +3659,14 @@ async def create_review( ) 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), @@ -1589,6 +3677,212 @@ async def create_review( ) +@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, @@ -1604,19 +3898,12 @@ async def archive_review( 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) - result = await db.execute( - select(TruthNode).where( - and_( - TruthNode.id == rid, - TruthNode.project_id == pid, - TruthNode.tenant_id == tenant_id, - TruthNode.node_type == "review", - ) - ) + review_node = await _ensure_review( + db, + tenant_id=tenant_id, + project_id=pid, + review_node_id=rid, ) - review_node = result.scalar_one_or_none() - if not review_node: - raise HTTPException(status_code=404, detail="Review not found") before = {"status": review_node.status} review_node.status = "archived" if body.archive else "draft" @@ -1636,4 +3923,12 @@ async def archive_review( 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/users.py b/backend/app/api/users.py index 9bae658e..2626e886 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -10,6 +10,7 @@ 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 @@ -125,6 +126,15 @@ def _parse_int_list(raw: Optional[str]) -> list[int]: 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, @@ -213,6 +223,22 @@ async def _replace_user_projects( 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)) + async def _create_user_with_defaults( db: AsyncSession, @@ -393,12 +419,12 @@ async def list_users( query = query.where(func.coalesce(UserWorkload.workload_ratio, 0.0) > 100.0) total_result = await db.execute( - select(func.count()).select_from(query.distinct(User.id).subquery()) + select(func.count()).select_from(query.distinct().subquery()) ) total = int(total_result.scalar() or 0) result = await db.execute( - query.distinct(User.id) + query.distinct() .order_by(User.updated_at.desc(), User.id.desc()) .offset((page - 1) * page_size) .limit(page_size) @@ -490,6 +516,9 @@ async def create_user( 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 @@ -571,8 +600,12 @@ async def update_user( before_data=before_data, after_data=user.to_dict(), ) - await db.commit() - await db.refresh(user) + 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) @@ -599,10 +632,10 @@ async def reset_password( if not is_manager and int(current_user.id) != user_id: raise HTTPException(status_code=403, detail="No permission") - if len(body.new_password or "") < 6: - raise HTTPException(status_code=400, detail="Password must be at least 6 characters") - - user.password_hash = AuthService.hash_password(body.new_password) + 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 = datetime.utcnow() await log_operation( db, diff --git a/backend/app/api/webhooks.py b/backend/app/api/webhooks.py index 1f2d6f61..fb101410 100644 --- a/backend/app/api/webhooks.py +++ b/backend/app/api/webhooks.py @@ -1,10 +1,10 @@ -"""Webhook 接收 API""" +"""Webhook 接收 API。""" import json import re from datetime import datetime, timezone -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select @@ -12,10 +12,8 @@ from ..database import get_db from ..models import Signal from ..services.llm import llm_service from ..services.plane import plane_service -from ..services.lark import LarkService router = APIRouter(prefix="/webhooks", tags=["webhooks"]) -lark_service = LarkService() @router.post("/git") @@ -114,58 +112,3 @@ async def handle_git_webhook( ) return {"status": "processed", "updated_tasks": updated_tasks} - - -@router.post("/lark") -async def handle_lark_webhook( - request: Request, - db: AsyncSession = Depends(get_db), -): - """接收飞书 Bot Webhook""" - try: - data = await request.json() - except Exception: - return {"status": "error", "message": "Invalid JSON"} - - if "challenge" in data: - return {"challenge": data["challenge"]} - - event = data.get("event", {}) - if not event: - return {"status": "ignored"} - - if event.get("type") == "message" or event.get("msg_type") == "text": - message_content = event.get("text_without_at_bot") or event.get( - "content", "" - ) - if isinstance(message_content, str) and message_content.startswith("{"): - try: - content_obj = json.loads(message_content) - message_content = content_obj.get("text", "") - except Exception: - pass - - message_content = message_content.strip() - chat_id = event.get("open_chat_id") or event.get("chat_id") - - if not message_content or not chat_id: - return {"status": "ignored", "reason": "No content or chat_id"} - - try: - try: - tasks = await llm_service.generate_task_breakdown_async( - message_content, - db=db, - tenant_id=1, # Webhook 来源尚未绑定租户,先按默认租户计费 - ) - except RuntimeError: - return {"status": "blocked", "reason": "budget_exceeded_hard_limit"} - if tasks: - await plane_service.sync_tasks(tasks) - await lark_service.send_card_message( - chat_id, message_content, tasks - ) - except Exception as e: - print(f"Error processing Lark message: {e}") - - return {"status": "processed"} diff --git a/backend/app/api/wecom.py b/backend/app/api/wecom.py index 5eb45387..b6cc25a2 100644 --- a/backend/app/api/wecom.py +++ b/backend/app/api/wecom.py @@ -101,17 +101,23 @@ async def get_wecom_config( 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 if service.is_configured() else None, - 'agent_id': service.agent_id if service.is_configured() else None, + '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) + '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( @@ -137,7 +143,7 @@ async def delete_wecom_config( result = await db.execute( select(Integration).where( Integration.tenant_id == tenant_id, - Integration.provider == 'wecom' + Integration.type == 'wecom' ) ) integration = result.scalar_one_or_none() @@ -157,6 +163,7 @@ async def delete_wecom_config( # ==================== Webhook Endpoints ==================== @router.get("/webhook") +@router.get("/webhook/", include_in_schema=False) async def verify_webhook_url( msg_signature: str, timestamp: str, @@ -193,6 +200,7 @@ async def verify_webhook_url( @router.post("/webhook") +@router.post("/webhook/", include_in_schema=False) async def handle_wecom_event( request: Request, db: AsyncSession = Depends(get_db) diff --git a/backend/app/config/ai_prompts.py b/backend/app/config/ai_prompts.py index aca5d298..11f6cc7e 100644 --- a/backend/app/config/ai_prompts.py +++ b/backend/app/config/ai_prompts.py @@ -169,6 +169,393 @@ Output JSON: # ============================================================================ +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_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,不要解释。 +""" + + +# ============================================================================ +# OpenClaw 指挥层 +# ============================================================================ + +PROMPT_OPENCLAW_COMMAND_DISPATCH = """ +首字符必须是 {{,末字符必须是 }}。 +你是 PMFlow 的 OpenClaw 指挥层。只做一件事:根据输入生成可执行 operations。 + +输入: +- command_json: {command_json} +- project_json: {project_json} +- tasks_json: {tasks_json} +- members_json: {members_json} +- operation_catalog_json: {operation_catalog_json} + +只输出 JSON: +{{ + "summary": "一句话总结本次指挥决策", + "confidence": 0.0-1.0, + "operations": [ + {{ + "operation_code": "project.create|project.update|project.archive|project.reopen|task.create|task.assign|task.prioritize|task.status_transition|notification.create|personnel.workload_update", + "payload": {{}}, + "reason": "为什么执行这一步" + }} + ] +}} + +规则: +1. operation_code 只能来自 operation_catalog_json。 +2. 总操作数 <= {max_operations}。 +3. 不编造 task_id / user_id / project_id。 +4. 若 project_json.scope=global,当前项目为空时可从 project_json.candidate_projects 里选择 project_id;需要新建项目时使用 project.create。 +5. project.create 缺少 title 时不要生成该操作。 +6. project.update / project.archive / project.reopen 只有在 project_id 明确时才能生成。 +7. 信息不足时返回空 operations,并在 summary 说明阻塞原因。 +8. reason 要短、直接、可执行。 +9. confidence 必须是 number,不能是字符串。 +10. 禁止输出 thinking、analysis、解释句、Markdown、代码块。 +11. 字段未知时返回空字符串、空数组或 null。 +12. 信息不足时也必须返回合法 JSON,operations=[]。 +13. 不要 Markdown,不要解释,只返回 JSON。 +""" + + +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"], + "openclaw_instruction": "交给 OpenClaw 的自然语言指令" +}} + +若不是信号源接入请求,输出: +{{ + "should_onboard": false, + "summary": "不是信号源接入请求的原因", + "source": null, + "worker_roles": [], + "openclaw_instruction": "" +}} + +规则: +1. 只有明确新增/接入/配置信号源时才返回 should_onboard=true。 +2. 不编造 url / chat_id / repo / webhook。 +3. worker_roles 只允许 pm_dispatcher / tech_architect / delivery_coach。 +4. openclaw_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,不要解释文字。 +""" + + +# ============================================================================ +# 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 = """ 你是一位资深产品经理。根据以下市场/技术信号,生成一份结构化的需求宪章草案。 @@ -497,3 +884,117 @@ PROMPT_WORKLOAD_OPTIMIZATION = """ ] }} """ + + +# ============================================================================ +# 食谱热量识别 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。 +""" + + +# ============================================================================ +# AI 角色 Prompt 配置(支持前端可视化设置) +# ============================================================================ + + +def build_default_role_prompt_bundle() -> dict: + """返回默认角色 Prompt 配置(每次调用返回新对象)。""" + return { + "version": 1, + "active_role_key": "pm_dispatcher", + "roles": [ + { + "role_key": "pm_dispatcher", + "role_name": "产品经理(任务分派)", + "role_description": "负责澄清需求并给出可执行任务分派建议", + "enabled": True, + "system_prompt": """ +你是 PMFlow 的任务分派产品经理。 + +职责定位: +1. 先理解需求背景,再输出执行计划。 +2. 重点给出可落地的任务分派建议,避免空泛讨论。 +3. 回复风格要专业、直接、有人味,优先给结论。 + +输出要求: +1. 先用 1-2 句话复述需求并确认目标。 +2. 再给 3 条以内“下一步动作”,每条都要可执行。 +3. 若信息不足,只提关键澄清问题(最多 2 个),不要臆测。 +4. 如果涉及风险、依赖或排期冲突,要明确提醒。 + +禁止项: +- 不要输出与项目无关的泛化建议。 +- 不要使用命令式生硬语气。 +- 不要编造不存在的数据或结论。 +""".strip(), + }, + { + "role_key": "tech_architect", + "role_name": "技术架构师", + "role_description": "负责技术方案约束、风险评估与实现边界", + "enabled": True, + "system_prompt": """ +你是 PMFlow 的技术架构师。 + +你需要基于需求识别实现边界、关键风险和技术依赖,给出可执行的技术建议。 +输出要点: +1. 先给结论(可行/有风险/需拆分)。 +2. 给出技术风险与约束(性能、安全、兼容、成本)。 +3. 提供建议的实现路径与最小可行步骤。 +4. 结尾给出是否建议立即进入开发。 + +风格:简洁、严谨、可执行,不讲空话。 +""".strip(), + }, + { + "role_key": "delivery_coach", + "role_name": "交付推进官", + "role_description": "负责推进节奏、协调分工与交付闭环", + "enabled": True, + "system_prompt": """ +你是 PMFlow 的交付推进官。 + +你要把需求转成可执行的交付动作,强调优先级、负责人和时间边界。 +输出格式: +1. 当前状态判断(一句话)。 +2. 建议动作(不超过 3 条,动词开头)。 +3. 风险提示(如有)。 + +风格:明确、友好、推动协作,避免过度技术细节。 +""".strip(), + }, + ], + } diff --git a/backend/app/config/operation_catalog.py b/backend/app/config/operation_catalog.py new file mode 100644 index 00000000..92b8341d --- /dev/null +++ b/backend/app/config/operation_catalog.py @@ -0,0 +1,455 @@ +"""Operation Catalog(系统写操作目录). + +规则: +1. LLM 仅输出结构化意图,真实写库必须走 Tool/Service 层。 +2. 每个操作必须声明 operation_code/input_schema/rbac_policy/idempotency_key/audit_fields/side_effect_guard。 +3. 执行态统一遵循 commit_then_outbox,便于副作用可追溯与重放。 +""" + +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", + ], + ), +) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 8a40509f..e311d06e 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -5,10 +5,19 @@ """ import os +from pathlib import Path from typing import Optional from dotenv import load_dotenv -load_dotenv() +# 统一从 backend/.env 加载配置,避免因启动目录不同读到错误环境 +BACKEND_DIR = Path(__file__).resolve().parents[2] +PROJECT_ROOT = BACKEND_DIR.parent +load_dotenv(BACKEND_DIR / ".env", override=False) +load_dotenv(PROJECT_ROOT / ".env", override=False) + +# 默认数据库固定到 backend/neural_sync.db,避免相对路径导致多库混用 +DEFAULT_SQLITE_DB = BACKEND_DIR / "neural_sync.db" +DEFAULT_DATABASE_URL = f"sqlite+aiosqlite:///{DEFAULT_SQLITE_DB.as_posix()}" class Settings: @@ -19,7 +28,11 @@ class Settings: debug: bool = os.getenv("DEBUG", "false").lower() in ("true", "1", "yes") # 数据库 - database_url: str = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./neural_sync.db") + database_url: str = os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL) + db_pool_size: int = int(os.getenv("DB_POOL_SIZE", "20")) + db_max_overflow: int = int(os.getenv("DB_MAX_OVERFLOW", "40")) + db_pool_timeout: int = int(os.getenv("DB_POOL_TIMEOUT", "30")) + db_pool_recycle: int = int(os.getenv("DB_POOL_RECYCLE", "1800")) # JWT secret_key: str = os.getenv("SECRET_KEY", "your-secret-key-change-in-production") diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py index b5d58bbd..7b1fb0af 100644 --- a/backend/app/core/logging.py +++ b/backend/app/core/logging.py @@ -6,6 +6,7 @@ import logging import sys +import json from typing import Any # 尝试使用 structlog,若未安装则回退到标准 logging @@ -52,8 +53,63 @@ def configure_logging(debug: bool = False) -> None: ) +class _StdLoggerCompat: + """兼容 structlog 风格 kwargs 的标准 logging 适配器。""" + + _PASSTHROUGH_KEYS = {"exc_info", "stack_info", "stacklevel", "extra"} + + def __init__(self, logger: logging.Logger): + self._logger = logger + + def _split_kwargs(self, kwargs: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + passthrough: dict[str, Any] = {} + fields: dict[str, Any] = {} + for key, value in kwargs.items(): + if key in self._PASSTHROUGH_KEYS: + passthrough[key] = value + else: + fields[key] = value + return passthrough, fields + + @staticmethod + def _format_fields(fields: dict[str, Any]) -> str: + try: + return json.dumps(fields, ensure_ascii=False, default=str) + except Exception: + return str(fields) + + def _log(self, method: str, msg: Any, *args: Any, **kwargs: Any) -> None: + passthrough, fields = self._split_kwargs(kwargs) + text = str(msg) + if fields: + text = f"{text} | {self._format_fields(fields)}" + getattr(self._logger, method)(text, *args, **passthrough) + + def debug(self, msg: Any, *args: Any, **kwargs: Any) -> None: + self._log("debug", msg, *args, **kwargs) + + def info(self, msg: Any, *args: Any, **kwargs: Any) -> None: + self._log("info", msg, *args, **kwargs) + + def warning(self, msg: Any, *args: Any, **kwargs: Any) -> None: + self._log("warning", msg, *args, **kwargs) + + def error(self, msg: Any, *args: Any, **kwargs: Any) -> None: + self._log("error", msg, *args, **kwargs) + + def exception(self, msg: Any, *args: Any, **kwargs: Any) -> None: + kwargs.setdefault("exc_info", True) + self._log("error", msg, *args, **kwargs) + + def critical(self, msg: Any, *args: Any, **kwargs: Any) -> None: + self._log("critical", msg, *args, **kwargs) + + def __getattr__(self, item: str) -> Any: + return getattr(self._logger, item) + + def get_logger(name: str): """获取 logger 实例""" if USE_STRUCTLOG: return structlog.get_logger(name) - return logging.getLogger(name) + return _StdLoggerCompat(logging.getLogger(name)) diff --git a/backend/app/database.py b/backend/app/database.py index d081d030..3d60e8b9 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -10,6 +10,8 @@ from sqlalchemy.ext.asyncio import ( async_sessionmaker, create_async_engine, ) +from sqlalchemy import event +from sqlalchemy.pool import NullPool from .core.config import settings @@ -20,13 +22,43 @@ def _is_sqlite() -> bool: return DATABASE_URL.lower().startswith("sqlite") +engine_kwargs = { + "echo": settings.debug, + "future": True, + "pool_pre_ping": not _is_sqlite(), +} +if _is_sqlite(): + engine_kwargs["connect_args"] = { + "timeout": 30, + "check_same_thread": False, + } + engine_kwargs["poolclass"] = NullPool +if not _is_sqlite(): + engine_kwargs.update( + { + "pool_size": max(5, int(settings.db_pool_size)), + "max_overflow": max(0, int(settings.db_max_overflow)), + "pool_timeout": max(5, int(settings.db_pool_timeout)), + "pool_recycle": max(60, int(settings.db_pool_recycle)), + } + ) + engine: AsyncEngine = create_async_engine( DATABASE_URL, - echo=settings.debug, - future=True, - pool_pre_ping=not _is_sqlite(), + **engine_kwargs, ) +if _is_sqlite(): + @event.listens_for(engine.sync_engine, "connect") + def _configure_sqlite_pragmas(dbapi_connection, _connection_record) -> None: # pragma: no cover + cursor = dbapi_connection.cursor() + try: + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA synchronous=NORMAL") + cursor.execute("PRAGMA busy_timeout=30000") + finally: + cursor.close() + AsyncSessionLocal = async_sessionmaker( bind=engine, class_=AsyncSession, diff --git a/backend/app/dependencies/auth.py b/backend/app/dependencies/auth.py index 0004994f..d1198e73 100644 --- a/backend/app/dependencies/auth.py +++ b/backend/app/dependencies/auth.py @@ -12,6 +12,7 @@ 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 User, UserStatus # noqa: E402 from ..services.auth import AuthService, has_any_role @@ -71,3 +72,17 @@ def require_roles(*roles: str): return current_user return role_checker + + +async def get_current_tenant_id( + current_user: User = Depends(get_current_user), + header_tenant_id: int = Depends(get_tenant_id), +) -> int: + """兼容旧接口:优先取 JWT 中 tenant_id,没有则回落 Header。""" + try: + tenant_id = int(getattr(current_user, "tenant_id", 0) or 0) + if tenant_id > 0: + return tenant_id + except (TypeError, ValueError): + pass + return int(header_tenant_id or 1) diff --git a/backend/app/main.py b/backend/app/main.py index f35b874d..02c84cc1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,11 +2,13 @@ from __future__ import annotations +import asyncio import os from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +import sqlalchemy as sa from .api.router import api_router from .core.config import settings @@ -15,10 +17,310 @@ from .core.response import success_response from .database import AsyncSessionLocal, engine from .models import Base 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.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 configure_logging(settings.debug) logger = get_logger(__name__) +register_context_cache_invalidation_hooks() + + +def _sqlite_backfill_columns(sync_conn) -> None: + """为历史 SQLite 库补齐新增列,避免运行时 500。""" + dialect_name = str(sync_conn.dialect.name or "").lower() + if dialect_name != "sqlite": + return + + def _columns(table_name: str) -> set[str]: + try: + rows = sync_conn.exec_driver_sql(f"PRAGMA table_info({table_name})").fetchall() + return {str(row[1]) for row in rows} + except Exception: + return set() + + def _add_column(table_name: str, column_name: str, column_ddl: str) -> None: + cols = _columns(table_name) + if not cols or column_name in cols: + return + sync_conn.exec_driver_sql( + f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_ddl}" + ) + + # truth_nodes + _add_column("truth_nodes", "signal_id", "BIGINT") + + # signals + _add_column("signals", "feishu_item_id", "BIGINT") + _add_column("signals", "matched_project_id", "BIGINT") + _add_column("signals", "title", "VARCHAR(200)") + _add_column("signals", "signal_type", "VARCHAR(50)") + _add_column("signals", "summary", "TEXT") + _add_column("signals", "reasoning", "TEXT") + _add_column("signals", "confidence", "FLOAT") + _add_column("signals", "recommended_action", "VARCHAR(50)") + _add_column("signals", "status", "VARCHAR(50) NOT NULL DEFAULT '待处理'") + + # qa_records + _add_column("qa_records", "session_id", "INTEGER") + _add_column("qa_records", "turn_index", "INTEGER NOT NULL DEFAULT 1") + + # ai_processing_logs + _add_column("ai_processing_logs", "normalized_text", "TEXT") + _add_column("ai_processing_logs", "route", "VARCHAR(80)") + _add_column("ai_processing_logs", "intent", "VARCHAR(40)") + _add_column("ai_processing_logs", "executor", "VARCHAR(40)") + _add_column("ai_processing_logs", "operation_code", "VARCHAR(80)") + _add_column("ai_processing_logs", "operation_result", "JSON") + _add_column("ai_processing_logs", "reply_source", "VARCHAR(40)") + + +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("sqlite"): + return "sqlite" + 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", "")) + + 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 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)}。" + "禁止同一服务混用 SQLite/Postgres。" + ) + + +def _ensure_feishu_integrity_guards(sync_conn) -> None: + dialect = _normalize_dialect(sync_conn.dialect.name) + if dialect not in {"postgresql", "sqlite"}: + return + + # Outbox 表兜底,兼容未跑 Alembic 的环境。 + outbox_meta = sa.MetaData() + outbox_table = sa.Table( + "feishu_reply_outbox", + outbox_meta, + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default=sa.text("1")), + sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("trace_id", sa.String(length=64), nullable=True), + sa.Column("chat_id", sa.String(length=128), nullable=False), + sa.Column("message_text", sa.Text(), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'pending'")), + sa.Column("attempts", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("next_retry_at", sa.TIMESTAMP(), nullable=True), + sa.Column("sent_at", sa.TIMESTAMP(), nullable=True), + sa.Column("last_error", sa.Text(), nullable=True), + ) + outbox_table.create(sync_conn, checkfirst=True) + sync_conn.exec_driver_sql( + "CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_tenant_id ON feishu_reply_outbox (tenant_id)" + ) + sync_conn.exec_driver_sql( + "CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_trace_id ON feishu_reply_outbox (trace_id)" + ) + sync_conn.exec_driver_sql( + "CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_chat_id ON feishu_reply_outbox (chat_id)" + ) + sync_conn.exec_driver_sql( + "CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_status ON feishu_reply_outbox (status)" + ) + sync_conn.exec_driver_sql( + "CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_next_retry_at ON feishu_reply_outbox (next_retry_at)" + ) + + inspector = sa.inspect(sync_conn) + if not inspector.has_table("feishu_items"): + return + + duplicate_groups = sync_conn.execute( + sa.text( + """ + SELECT tenant_id, feishu_message_id, split_from_item_id, MIN(id) AS keep_id + FROM feishu_items + WHERE feishu_message_id IS NOT NULL + GROUP BY tenant_id, feishu_message_id, split_from_item_id + HAVING COUNT(*) > 1 + """ + ) + ).fetchall() + + table_exists = { + "signals": inspector.has_table("signals"), + "project_suggestions": inspector.has_table("project_suggestions"), + "ai_processing_logs": inspector.has_table("ai_processing_logs"), + "feishu_items": True, + } + + for tenant_id, message_id, split_from_item_id, keep_id in duplicate_groups: + if split_from_item_id is None: + duplicate_ids = sync_conn.execute( + sa.text( + """ + SELECT id + FROM feishu_items + WHERE tenant_id = :tenant_id + AND feishu_message_id = :message_id + AND split_from_item_id IS NULL + AND id <> :keep_id + ORDER BY id ASC + """ + ), + { + "tenant_id": int(tenant_id), + "message_id": str(message_id), + "keep_id": int(keep_id), + }, + ).fetchall() + else: + duplicate_ids = sync_conn.execute( + sa.text( + """ + SELECT id + FROM feishu_items + WHERE tenant_id = :tenant_id + AND feishu_message_id = :message_id + AND split_from_item_id = :split_from_item_id + AND id <> :keep_id + ORDER BY id ASC + """ + ), + { + "tenant_id": int(tenant_id), + "message_id": str(message_id), + "split_from_item_id": int(split_from_item_id), + "keep_id": int(keep_id), + }, + ).fetchall() + + for (duplicate_id,) in duplicate_ids: + duplicate_id = int(duplicate_id) + canonical_id = int(keep_id) + if table_exists["feishu_items"]: + sync_conn.execute( + sa.text( + "UPDATE feishu_items SET split_from_item_id = :new_id " + "WHERE split_from_item_id = :old_id" + ), + {"new_id": canonical_id, "old_id": duplicate_id}, + ) + if table_exists["signals"]: + sync_conn.execute( + sa.text( + "UPDATE signals SET feishu_item_id = :new_id " + "WHERE feishu_item_id = :old_id" + ), + {"new_id": canonical_id, "old_id": duplicate_id}, + ) + if table_exists["project_suggestions"]: + sync_conn.execute( + sa.text( + "UPDATE project_suggestions SET feishu_item_id = :new_id " + "WHERE feishu_item_id = :old_id" + ), + {"new_id": canonical_id, "old_id": duplicate_id}, + ) + if table_exists["ai_processing_logs"]: + sync_conn.execute( + sa.text( + "UPDATE ai_processing_logs SET feishu_item_id = :new_id " + "WHERE feishu_item_id = :old_id" + ), + {"new_id": canonical_id, "old_id": duplicate_id}, + ) + sync_conn.execute( + sa.text("DELETE FROM feishu_items WHERE id = :id"), + {"id": duplicate_id}, + ) + + # 幂等唯一约束(含 split 维度)硬化;若存在重复历史数据将直接失败,避免静默错乱。 + sync_conn.exec_driver_sql( + """ + CREATE UNIQUE INDEX IF NOT EXISTS uq_feishu_items_tenant_message_root + ON feishu_items (tenant_id, feishu_message_id) + WHERE split_from_item_id IS NULL AND feishu_message_id IS NOT NULL + """ + ) + sync_conn.exec_driver_sql( + """ + CREATE UNIQUE INDEX IF NOT EXISTS uq_feishu_items_tenant_message_split + ON feishu_items (tenant_id, feishu_message_id, split_from_item_id) + WHERE split_from_item_id IS NOT NULL AND feishu_message_id IS NOT NULL + """ + ) + + +async def _feishu_outbox_drain_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) @asynccontextmanager @@ -29,9 +331,12 @@ async def lifespan(_: FastAPI): "true", "yes", } - if auto_create_tables: - async with engine.begin() as conn: + async with engine.begin() as conn: + await conn.run_sync(_check_database_dialect_guard) + if auto_create_tables: await conn.run_sync(Base.metadata.create_all) + await conn.run_sync(_sqlite_backfill_columns) + await conn.run_sync(_ensure_feishu_integrity_guards) async with AsyncSessionLocal() as db: try: @@ -40,11 +345,39 @@ 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: + await db.commit() + logger.info("stale_ai_orchestration_runs_interrupted", count=interrupted) + except Exception as exc: # pragma: no cover + logger.warning("interrupt_stale_ai_orchestration_runs_failed", error=str(exc)) scheduler = WorkloadRecalcScheduler(AsyncSessionLocal) scheduler.start() + knowledge_scheduler = KnowledgeUrlRefreshScheduler(AsyncSessionLocal) + knowledge_scheduler.start() + kg_scheduler = KnowledgeGraphIncrementalScheduler(AsyncSessionLocal) + 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", + ) yield + outbox_task.cancel() + try: + await outbox_task + except asyncio.CancelledError: + pass + await feishu_lc.stop() + await ai_pmo_cadence_scheduler.stop() + await kg_scheduler.stop() + await knowledge_scheduler.stop() await scheduler.stop() await engine.dispose() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index da75aa22..50e5d76b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -8,7 +8,11 @@ from .skill import SkillTag from .user_skill_binding import UserSkillBinding from .user_workload import UserWorkload from .signal import Signal +from .signal_edit import SignalEdit from .signal_feedback import SignalFeedback +from .feishu_item import FeishuItem +from .feishu_reply_outbox import FeishuReplyOutbox +from .project_suggestion import ProjectSuggestion from .context_node import ContextNode from .experiment import Experiment from .project_domain import ProjectDomain @@ -29,8 +33,34 @@ from .model_capability_matrix import ( BudgetAlert, ) from .integration import Integration +from .notification import Notification from .ai_call_log import AICallLog +from .ai_processing_log import AIProcessingLog from .operation_log import OperationLog +from .operation_execution_ledger import OperationExecutionLedger +from .signal_source_registry import SignalSourceRegistry +from .ai_worker_execution import AIWorkerExecution +from .ai_orchestration_run import AIOrchestrationRun +from .knowledge_base_doc import KnowledgeBaseDoc +from .knowledge_base_chunk import KnowledgeBaseChunk +from .qa_record import QARecord +from .qa_feedback import QAFeedback +from .qa_session import QASession +from .qa_session_state import QASessionState +from .kg_entity import KGEntity +from .kg_relation import KGRelation +from .kg_evidence import KGEvidence +from .kg_sync_run import KGSyncRun +from .ingest_event import IngestEvent +from .ingest_offset import IngestOffset +from .symbol_definition import SymbolDefinition +from .symbol_reference import SymbolReference +from .recipe import Recipe +from .work_item import WorkItem +from .decision_record import DecisionRecord +from .plan_bundle import PlanBundle +from .reply_receipt import ReplyReceipt +from .portfolio_snapshot import PortfolioSnapshot __all__ = [ "Base", @@ -45,7 +75,11 @@ __all__ = [ "UserSkillBinding", "UserWorkload", "Signal", + "SignalEdit", "SignalFeedback", + "FeishuItem", + "FeishuReplyOutbox", + "ProjectSuggestion", "ContextNode", "Experiment", "ProjectDomain", @@ -64,6 +98,32 @@ __all__ = [ "Budget", "BudgetAlert", "Integration", + "Notification", "AICallLog", + "AIProcessingLog", "OperationLog", + "OperationExecutionLedger", + "SignalSourceRegistry", + "AIWorkerExecution", + "AIOrchestrationRun", + "KnowledgeBaseDoc", + "KnowledgeBaseChunk", + "QARecord", + "QAFeedback", + "QASession", + "QASessionState", + "KGEntity", + "KGRelation", + "KGEvidence", + "KGSyncRun", + "IngestEvent", + "IngestOffset", + "SymbolDefinition", + "SymbolReference", + "Recipe", + "WorkItem", + "DecisionRecord", + "PlanBundle", + "ReplyReceipt", + "PortfolioSnapshot", ] diff --git a/backend/app/models/ai_orchestration_run.py b/backend/app/models/ai_orchestration_run.py new file mode 100644 index 00000000..5afbb23c --- /dev/null +++ b/backend/app/models/ai_orchestration_run.py @@ -0,0 +1,42 @@ +"""AI 编排运行账本。""" + +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import BigInteger, ForeignKey, Integer, Text, UniqueConstraint, VARCHAR, TIMESTAMP +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base +from .types import JSONB + + +class AIOrchestrationRun(Base): + __tablename__ = "ai_orchestration_runs" + __table_args__ = ( + UniqueConstraint("tenant_id", "trace_id", name="uq_ai_orchestration_run_trace_id"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + trace_id: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True) + run_type: Mapped[str] = mapped_column(VARCHAR(40), nullable=False, index=True) + project_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("projects.id"), nullable=False, index=True) + signal_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("signals.id"), nullable=True, index=True) + response_mode: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, default="fast", index=True) + status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="queued", index=True) + planner_status: Mapped[Optional[str]] = mapped_column(VARCHAR(20), nullable=True, index=True) + openclaw_status: Mapped[Optional[str]] = mapped_column(VARCHAR(20), nullable=True, index=True) + worker_total: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + worker_completed: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + worker_failed: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + planner_diagnostics: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) + result_payload: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) + created_by_user_id: Mapped[Optional[int]] = mapped_column( + BigInteger, + ForeignKey("users.id"), + nullable=True, + index=True, + ) + started_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True) + completed_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True) diff --git a/backend/app/models/ai_processing_log.py b/backend/app/models/ai_processing_log.py new file mode 100644 index 00000000..343db32f --- /dev/null +++ b/backend/app/models/ai_processing_log.py @@ -0,0 +1,38 @@ +"""AI 处理可视化日志模型。""" + +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import BigInteger, Float, ForeignKey, Integer, JSON, TEXT, VARCHAR +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base + + +class AIProcessingLog(Base): + __tablename__ = "ai_processing_logs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + feishu_message_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True) + feishu_item_id: Mapped[Optional[int]] = mapped_column( + BigInteger, ForeignKey("feishu_items.id"), nullable=True, index=True + ) + raw_message: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True) + ai_raw_output: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True) + project_score: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + signal_confidence: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + level: Mapped[Optional[str]] = mapped_column(VARCHAR(20), nullable=True) + normalized_text: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True) + route: Mapped[Optional[str]] = mapped_column(VARCHAR(80), nullable=True, index=True) + intent: Mapped[Optional[str]] = mapped_column(VARCHAR(40), nullable=True, index=True) + executor: Mapped[Optional[str]] = mapped_column(VARCHAR(40), nullable=True) + operation_code: Mapped[Optional[str]] = mapped_column(VARCHAR(80), nullable=True, index=True) + operation_result: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) + reply_source: Mapped[Optional[str]] = mapped_column(VARCHAR(40), nullable=True) + routing_decision: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True) + routing_reason: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True) + reasoning_bullets: Mapped[Optional[list[str]]] = mapped_column(JSON, nullable=True) + processing_status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="processing", index=True) + error_message: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True) + duration_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) diff --git a/backend/app/models/ai_worker_execution.py b/backend/app/models/ai_worker_execution.py new file mode 100644 index 00000000..12b8a9fe --- /dev/null +++ b/backend/app/models/ai_worker_execution.py @@ -0,0 +1,49 @@ +"""AI worker 执行账本。""" + +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import BigInteger, ForeignKey, Integer, Text, UniqueConstraint, VARCHAR, TIMESTAMP +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base +from .types import JSONB + + +class AIWorkerExecution(Base): + __tablename__ = "ai_worker_executions" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "trace_id", + "worker_role_key", + name="uq_ai_worker_execution_trace_role", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + project_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("projects.id"), nullable=False, index=True + ) + signal_id: Mapped[Optional[int]] = mapped_column( + BigInteger, ForeignKey("signals.id"), nullable=True, index=True + ) + signal_source_registry_id: Mapped[Optional[int]] = mapped_column( + BigInteger, ForeignKey("signal_source_registries.id"), nullable=True, index=True + ) + operator_user_id: Mapped[Optional[int]] = mapped_column( + BigInteger, ForeignKey("users.id"), nullable=True, index=True + ) + trace_id: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True) + operation_trace_id: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True) + execution_type: Mapped[str] = mapped_column(VARCHAR(40), nullable=False, default="signal_source_onboarding", index=True) + worker_role_key: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True) + worker_role_name: Mapped[str] = mapped_column(VARCHAR(64), nullable=False) + status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="planned", index=True) + summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + input_payload: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) + output_payload: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) + error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + started_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True) + completed_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True) diff --git a/backend/app/models/decision_record.py b/backend/app/models/decision_record.py new file mode 100644 index 00000000..0db52ec1 --- /dev/null +++ b/backend/app/models/decision_record.py @@ -0,0 +1,39 @@ +"""AI PMO 决策记录模型。""" + +from __future__ import annotations + +from typing import Any, Optional + +from sqlalchemy import BigInteger, Boolean, Float, ForeignKey, Integer, Text, VARCHAR +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base +from .types import JSONB + + +class DecisionRecord(Base): + __tablename__ = "decision_records" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + work_item_id: Mapped[int] = mapped_column( + BigInteger, + ForeignKey("work_items.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + decision_object_type: Mapped[str] = mapped_column(VARCHAR(40), nullable=False, default="portfolio") + decision_object_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True) + decision_level: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="portfolio", index=True) + context_snapshot: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + rationale: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + confidence: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + risk_score: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + budget_impact: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + resource_impact: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + simulation_result: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + policy_gate_hit: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) + policy_gate_code: Mapped[Optional[str]] = mapped_column(VARCHAR(80), nullable=True, index=True) + action_bundle: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + approval_requirement: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + execution_result: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + final_outcome: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True, index=True) diff --git a/backend/app/models/feishu_item.py b/backend/app/models/feishu_item.py new file mode 100644 index 00000000..7d3e502c --- /dev/null +++ b/backend/app/models/feishu_item.py @@ -0,0 +1,78 @@ +"""飞书分流队列项模型""" +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from sqlalchemy import ( + BigInteger, + Boolean, + Float, + ForeignKey, + Index, + Integer, + TEXT, + TIMESTAMP, + VARCHAR, + text, +) +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base +from .types import JSONB + + +class FeishuItem(Base): + __tablename__ = "feishu_items" + __table_args__ = ( + Index( + "uq_feishu_items_tenant_message_root", + "tenant_id", + "feishu_message_id", + unique=True, + postgresql_where=text("split_from_item_id IS NULL AND feishu_message_id IS NOT NULL"), + sqlite_where=text("split_from_item_id IS NULL AND feishu_message_id IS NOT NULL"), + ), + Index( + "uq_feishu_items_tenant_message_split", + "tenant_id", + "feishu_message_id", + "split_from_item_id", + unique=True, + postgresql_where=text("split_from_item_id IS NOT NULL AND feishu_message_id IS NOT NULL"), + sqlite_where=text("split_from_item_id IS NOT NULL AND feishu_message_id IS NOT NULL"), + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + feishu_message_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True) + project_score: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + signal_confidence: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + raw_message: Mapped[str] = mapped_column(TEXT, nullable=False) + split_from_item_id: Mapped[Optional[int]] = mapped_column( + BigInteger, ForeignKey("feishu_items.id"), nullable=True, index=True + ) + level: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, index=True) + level_confidence: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + level_reasoning: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True) + card_type: Mapped[str] = mapped_column(VARCHAR(30), nullable=False, default="signal", index=True) + route_status: Mapped[str] = mapped_column(VARCHAR(30), nullable=False, default="pending", index=True) + matched_project_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True) + source_chat_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True) + source_sender: Mapped[Optional[str]] = mapped_column(VARCHAR(120), nullable=True) + source_sender_open_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True) + source_occurred_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) + ai_output: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True) + decision_reason: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True) + blocking_reason: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True) + safe_to_auto_execute: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) + auto_execute_state: Mapped[Optional[str]] = mapped_column(VARCHAR(30), nullable=True, index=True) + dedupe_key: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True, index=True) + message_class: Mapped[Optional[str]] = mapped_column(VARCHAR(30), nullable=True, index=True) + occurrence_count: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + next_step_text: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True) + undoable_until: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) + result_payload: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) + queue_visible: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) + processed_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) + dismissed_reason: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True) diff --git a/backend/app/models/feishu_reply_outbox.py b/backend/app/models/feishu_reply_outbox.py new file mode 100644 index 00000000..1ec38717 --- /dev/null +++ b/backend/app/models/feishu_reply_outbox.py @@ -0,0 +1,24 @@ +"""飞书回执 Outbox 模型。""" +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from sqlalchemy import Integer, TIMESTAMP, TEXT, VARCHAR +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base + + +class FeishuReplyOutbox(Base): + __tablename__ = "feishu_reply_outbox" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + trace_id: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True) + chat_id: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True) + message_text: Mapped[str] = mapped_column(TEXT, nullable=False) + status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="pending", index=True) + attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + next_retry_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True, index=True) + sent_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) + last_error: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True) diff --git a/backend/app/models/ingest_event.py b/backend/app/models/ingest_event.py new file mode 100644 index 00000000..51f3c0f0 --- /dev/null +++ b/backend/app/models/ingest_event.py @@ -0,0 +1,27 @@ +"""Incremental ingest event model.""" + +from __future__ import annotations + +from typing import Any, Optional + +from sqlalchemy import Integer, TEXT, TIMESTAMP, VARCHAR, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base +from .types import JSONB + + +class IngestEvent(Base): + __tablename__ = "ingest_events" + __table_args__ = ( + UniqueConstraint("tenant_id", "channel", "event_key", name="uq_ingest_events_tenant_channel_key"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + channel: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True) + event_key: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True) + status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="pending", index=True) + payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + error_message: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True) + processed_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True, index=True) diff --git a/backend/app/models/ingest_offset.py b/backend/app/models/ingest_offset.py new file mode 100644 index 00000000..dc1b880c --- /dev/null +++ b/backend/app/models/ingest_offset.py @@ -0,0 +1,24 @@ +"""Ingest offset model for incremental sync checkpoints.""" + +from __future__ import annotations + +from typing import Any, Optional + +from sqlalchemy import Integer, VARCHAR, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base +from .types import JSONB + + +class IngestOffset(Base): + __tablename__ = "ingest_offsets" + __table_args__ = ( + UniqueConstraint("tenant_id", "channel", "offset_key", name="uq_ingest_offsets_tenant_channel_key"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + channel: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True) + offset_key: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True) + offset_value: Mapped[str] = mapped_column(VARCHAR(255), nullable=False) + payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) diff --git a/backend/app/models/kg_entity.py b/backend/app/models/kg_entity.py new file mode 100644 index 00000000..dac171fe --- /dev/null +++ b/backend/app/models/kg_entity.py @@ -0,0 +1,32 @@ +"""Project Knowledge Graph entity model.""" + +from __future__ import annotations + +from typing import Any, Optional + +from sqlalchemy import Integer, TIMESTAMP, VARCHAR, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base +from .types import JSONB + + +class KGEntity(Base): + __tablename__ = "kg_entities" + __table_args__ = ( + UniqueConstraint("tenant_id", "entity_type", "entity_key", name="uq_kg_entities_tenant_type_key"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + entity_type: Mapped[str] = mapped_column(VARCHAR(40), nullable=False, index=True) + entity_key: Mapped[str] = mapped_column(VARCHAR(255), nullable=False, index=True) + display_name: Mapped[str] = mapped_column(VARCHAR(255), nullable=False) + payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + source_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="system", index=True) + source_ref: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True) + last_seen_at: Mapped[Optional[TIMESTAMP]] = mapped_column( + TIMESTAMP, + nullable=False, + server_default=func.now(), + index=True, + ) diff --git a/backend/app/models/kg_evidence.py b/backend/app/models/kg_evidence.py new file mode 100644 index 00000000..f2cbcc17 --- /dev/null +++ b/backend/app/models/kg_evidence.py @@ -0,0 +1,41 @@ +"""Project Knowledge Graph evidence model.""" + +from __future__ import annotations + +from typing import Any, Optional + +from sqlalchemy import ForeignKey, Integer, TEXT, TIMESTAMP, VARCHAR, func +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base +from .types import JSONB + + +class KGEvidence(Base): + __tablename__ = "kg_evidences" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + entity_id: Mapped[Optional[int]] = mapped_column( + Integer, + ForeignKey("kg_entities.id", ondelete="CASCADE"), + nullable=True, + index=True, + ) + relation_id: Mapped[Optional[int]] = mapped_column( + Integer, + ForeignKey("kg_relations.id", ondelete="CASCADE"), + nullable=True, + index=True, + ) + evidence_type: Mapped[str] = mapped_column(VARCHAR(40), nullable=False, index=True) + content_hash: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True) + content_excerpt: Mapped[str] = mapped_column(TEXT, nullable=False) + source_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="system", index=True) + source_ref: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True) + meta_payload: Mapped[Optional[Any]] = mapped_column("metadata", JSONB, nullable=True) + occurred_at: Mapped[Optional[TIMESTAMP]] = mapped_column( + TIMESTAMP, + nullable=False, + server_default=func.now(), + index=True, + ) diff --git a/backend/app/models/kg_relation.py b/backend/app/models/kg_relation.py new file mode 100644 index 00000000..2f74cd58 --- /dev/null +++ b/backend/app/models/kg_relation.py @@ -0,0 +1,43 @@ +"""Project Knowledge Graph relation model.""" + +from __future__ import annotations + +from typing import Any, Optional + +from sqlalchemy import Float, ForeignKey, Integer, VARCHAR, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base +from .types import JSONB + + +class KGRelation(Base): + __tablename__ = "kg_relations" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "relation_type", + "subject_entity_id", + "object_entity_id", + name="uq_kg_relations_tenant_type_subject_object", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + relation_type: Mapped[str] = mapped_column(VARCHAR(40), nullable=False, index=True) + subject_entity_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("kg_entities.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + object_entity_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("kg_entities.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + weight: Mapped[float] = mapped_column(Float, nullable=False, default=1.0) + payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + source_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="system", index=True) + source_ref: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True) diff --git a/backend/app/models/kg_sync_run.py b/backend/app/models/kg_sync_run.py new file mode 100644 index 00000000..e3a46640 --- /dev/null +++ b/backend/app/models/kg_sync_run.py @@ -0,0 +1,28 @@ +"""Knowledge Graph 增量同步运行日志。""" + +from __future__ import annotations + +from typing import Any, Optional + +from sqlalchemy import Integer, TEXT, TIMESTAMP, VARCHAR +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base +from .types import JSONB + + +class KGSyncRun(Base): + __tablename__ = "kg_sync_runs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + run_type: Mapped[str] = mapped_column(VARCHAR(40), nullable=False, index=True) + trigger_source: Mapped[str] = mapped_column(VARCHAR(40), nullable=False, default="system", index=True) + status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="running", index=True) + + started_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=False, index=True) + ended_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True, index=True) + duration_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + + input_payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + result_payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + error_message: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True) diff --git a/backend/app/models/knowledge_base_chunk.py b/backend/app/models/knowledge_base_chunk.py new file mode 100644 index 00000000..11b006cd --- /dev/null +++ b/backend/app/models/knowledge_base_chunk.py @@ -0,0 +1,29 @@ +"""知识库分块向量模型。""" + +from __future__ import annotations + +from typing import Any, Optional + +from sqlalchemy import BigInteger, ForeignKey, Integer, Text, VARCHAR +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base +from .types import JSONB, Vector + + +class KnowledgeBaseChunk(Base): + __tablename__ = "knowledge_base_chunks" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + doc_id: Mapped[Optional[int]] = mapped_column( + Integer, + ForeignKey("knowledge_base_docs.id", ondelete="CASCADE"), + nullable=True, + index=True, + ) + source_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="doc", index=True) + project_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True) + content: Mapped[str] = mapped_column(Text, nullable=False) + embedding: Mapped[Optional[Any]] = mapped_column(Vector(768), nullable=True) + chunk_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + chunk_metadata: Mapped[Optional[Any]] = mapped_column("metadata", JSONB, nullable=True) diff --git a/backend/app/models/knowledge_base_doc.py b/backend/app/models/knowledge_base_doc.py new file mode 100644 index 00000000..d665c85f --- /dev/null +++ b/backend/app/models/knowledge_base_doc.py @@ -0,0 +1,25 @@ +"""知识库文档元数据模型。""" + +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from sqlalchemy import BigInteger, Integer, TIMESTAMP, VARCHAR +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base + + +class KnowledgeBaseDoc(Base): + __tablename__ = "knowledge_base_docs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + title: Mapped[str] = mapped_column(VARCHAR(255), nullable=False) + file_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False) + source_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="file") + file_url: Mapped[Optional[str]] = mapped_column(VARCHAR(1024), nullable=True) + uploaded_by: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True) + status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="processing") + last_fetched_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) + error_message: Mapped[Optional[str]] = mapped_column(VARCHAR(500), nullable=True) diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py new file mode 100644 index 00000000..94267b91 --- /dev/null +++ b/backend/app/models/notification.py @@ -0,0 +1,37 @@ +"""站内通知模型""" +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from sqlalchemy import BigInteger, Boolean, ForeignKey, Integer, TIMESTAMP, TEXT, VARCHAR +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base +from .types import JSONB + + +class Notification(Base): + __tablename__ = "notifications" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("users.id"), nullable=False, index=True + ) + category: Mapped[str] = mapped_column(VARCHAR(50), nullable=False, default="system", index=True) + title: Mapped[str] = mapped_column(VARCHAR(200), nullable=False) + content: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True) + source: Mapped[Optional[str]] = mapped_column(VARCHAR(50), nullable=True, index=True) + entity_type: Mapped[Optional[str]] = mapped_column(VARCHAR(50), nullable=True, index=True) + entity_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True) + target_url: Mapped[Optional[str]] = mapped_column(VARCHAR(500), nullable=True) + kind: Mapped[Optional[str]] = mapped_column(VARCHAR(30), nullable=True, index=True) + priority: Mapped[Optional[str]] = mapped_column(VARCHAR(20), nullable=True, index=True) + status: Mapped[Optional[str]] = mapped_column(VARCHAR(30), nullable=True, index=True) + blocking_reason: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True) + recommended_action: Mapped[Optional[str]] = mapped_column(VARCHAR(80), nullable=True) + dedupe_key: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True, index=True) + occurrence_count: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + payload: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) + is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) + read_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) diff --git a/backend/app/models/operation_execution_ledger.py b/backend/app/models/operation_execution_ledger.py new file mode 100644 index 00000000..8ad2e817 --- /dev/null +++ b/backend/app/models/operation_execution_ledger.py @@ -0,0 +1,37 @@ +"""操作执行幂等账本。""" + +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import BigInteger, Integer, ForeignKey, Text, UniqueConstraint, VARCHAR +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base +from .types import JSONB + + +class OperationExecutionLedger(Base): + __tablename__ = "operation_execution_ledger" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "operation_code", + "idempotency_key", + name="uq_operation_execution_ledger_key", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + operation_code: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True) + project_id: Mapped[Optional[int]] = mapped_column( + BigInteger, ForeignKey("projects.id"), nullable=True, index=True + ) + idempotency_key: Mapped[str] = mapped_column(VARCHAR(128), nullable=False) + trace_id: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True) + status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="processing", index=True) + payload: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) + result_payload: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) + entity_type: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True) + entity_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True) + error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) diff --git a/backend/app/models/plan_bundle.py b/backend/app/models/plan_bundle.py new file mode 100644 index 00000000..16767c46 --- /dev/null +++ b/backend/app/models/plan_bundle.py @@ -0,0 +1,27 @@ +"""AI PMO 计划包模型。""" + +from __future__ import annotations + +from typing import Any, Optional + +from sqlalchemy import BigInteger, ForeignKey, Integer, Text, VARCHAR +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base +from .types import JSONB + + +class PlanBundle(Base): + __tablename__ = "plan_bundles" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + work_item_id: Mapped[int] = mapped_column( + BigInteger, + ForeignKey("work_items.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + plan_type: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, index=True) + summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + diff --git a/backend/app/models/portfolio_snapshot.py b/backend/app/models/portfolio_snapshot.py new file mode 100644 index 00000000..adc00f65 --- /dev/null +++ b/backend/app/models/portfolio_snapshot.py @@ -0,0 +1,21 @@ +"""AI PMO 项目群快照模型。""" + +from __future__ import annotations + +from typing import Any, Optional + +from sqlalchemy import Integer, VARCHAR +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base +from .types import JSONB + + +class PortfolioSnapshot(Base): + __tablename__ = "portfolio_snapshots" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + scope_type: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="tenant", index=True) + scope_ref: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True) + summary: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) diff --git a/backend/app/models/project_suggestion.py b/backend/app/models/project_suggestion.py new file mode 100644 index 00000000..c2a1a9e7 --- /dev/null +++ b/backend/app/models/project_suggestion.py @@ -0,0 +1,25 @@ +"""飞书项目建议模型""" +from __future__ import annotations + +from sqlalchemy import BigInteger, Float, ForeignKey, Integer, TEXT, VARCHAR +from sqlalchemy.orm import Mapped, mapped_column +from typing import Optional + +from .base import Base + + +class ProjectSuggestion(Base): + __tablename__ = "project_suggestions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + feishu_item_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("feishu_items.id"), nullable=False, index=True + ) + suggested_name: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True) + suggested_description: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True) + suggested_owner: Mapped[Optional[str]] = mapped_column(VARCHAR(120), nullable=True) + suggested_timeline: Mapped[Optional[str]] = mapped_column(VARCHAR(120), nullable=True) + confidence: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + reasoning: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True) + status: Mapped[str] = mapped_column(VARCHAR(30), nullable=False, default="pending", index=True) + created_project_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True) diff --git a/backend/app/models/qa_feedback.py b/backend/app/models/qa_feedback.py new file mode 100644 index 00000000..66884b00 --- /dev/null +++ b/backend/app/models/qa_feedback.py @@ -0,0 +1,28 @@ +"""问答反馈模型。""" + +from __future__ import annotations + +from typing import Any, Optional + +from sqlalchemy import BigInteger, ForeignKey, Integer, Text, VARCHAR +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base +from .types import JSONB + + +class QAFeedback(Base): + __tablename__ = "qa_feedback" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + qa_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("qa_records.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + rating: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, index=True) + helpfulness: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True) + error_types: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + correct_answer: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + submitted_by: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True) diff --git a/backend/app/models/qa_record.py b/backend/app/models/qa_record.py new file mode 100644 index 00000000..ead8fad9 --- /dev/null +++ b/backend/app/models/qa_record.py @@ -0,0 +1,33 @@ +"""RAG 问答记录模型。""" + +from __future__ import annotations + +from typing import Any, Optional + +from sqlalchemy import BigInteger, ForeignKey, Integer, Text, VARCHAR +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base +from .types import JSONB + + +class QARecord(Base): + __tablename__ = "qa_records" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + question: Mapped[str] = mapped_column(Text, nullable=False) + answer: Mapped[str] = mapped_column(Text, nullable=False) + sources: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + intent: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="question") + domains_used: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + response_time_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + asked_by: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True) + asked_via: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="pmflow", index=True) + project_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True) + session_id: Mapped[Optional[int]] = mapped_column( + Integer, + ForeignKey("qa_sessions.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + turn_index: Mapped[int] = mapped_column(Integer, nullable=False, default=1) diff --git a/backend/app/models/qa_session.py b/backend/app/models/qa_session.py new file mode 100644 index 00000000..b7c675e2 --- /dev/null +++ b/backend/app/models/qa_session.py @@ -0,0 +1,22 @@ +"""QA 会话模型。""" + +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import BigInteger, Integer, TIMESTAMP, VARCHAR +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base + + +class QASession(Base): + __tablename__ = "qa_sessions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True) + title: Mapped[str] = mapped_column(VARCHAR(120), nullable=False, default="新会话") + project_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True) + started_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True, index=True) + last_message_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True, index=True) + message_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) diff --git a/backend/app/models/qa_session_state.py b/backend/app/models/qa_session_state.py new file mode 100644 index 00000000..dc81410d --- /dev/null +++ b/backend/app/models/qa_session_state.py @@ -0,0 +1,29 @@ +"""QA 会话语义状态模型。""" + +from __future__ import annotations + +from typing import Any, Optional + +from sqlalchemy import ForeignKey, Integer, VARCHAR +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base +from .types import JSONB + + +class QASessionState(Base): + __tablename__ = "qa_session_states" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + session_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("qa_sessions.id", ondelete="CASCADE"), + nullable=False, + unique=True, + index=True, + ) + last_subject: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True, index=True) + last_entity_ids: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + last_intent: Mapped[Optional[str]] = mapped_column(VARCHAR(20), nullable=True, index=True) + ambiguity: Mapped[Optional[str]] = mapped_column(VARCHAR(20), nullable=True, index=True) + rewrite_history: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py new file mode 100644 index 00000000..225716fa --- /dev/null +++ b/backend/app/models/recipe.py @@ -0,0 +1,37 @@ +"""食谱模型 - 支持热量识别与营养分析""" +from .base import Base +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import BigInteger, Integer, VARCHAR, Text, Float, JSON +from typing import Optional + + +class Recipe(Base): + __tablename__ = "recipes" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(VARCHAR(200), nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + image_url: Mapped[Optional[str]] = mapped_column(VARCHAR(500), nullable=True) + # 营养信息 + calories: Mapped[Optional[float]] = mapped_column(Float, nullable=True, comment="热量(千卡)") + protein: Mapped[Optional[float]] = mapped_column(Float, nullable=True, comment="蛋白质(克)") + fat: Mapped[Optional[float]] = mapped_column(Float, nullable=True, comment="脂肪(克)") + carbs: Mapped[Optional[float]] = mapped_column(Float, nullable=True, comment="碳水化合物(克)") + fiber: Mapped[Optional[float]] = mapped_column(Float, nullable=True, comment="膳食纤维(克)") + # AI 识别相关 + ai_recognition_confidence: Mapped[Optional[float]] = mapped_column( + Float, nullable=True, comment="AI 识别置信度 0-1" + ) + ai_analysis_result: Mapped[Optional[dict]] = mapped_column( + JSON, nullable=True, comment="AI 识别完整结果" + ) + # 分类 + category: Mapped[Optional[str]] = mapped_column(VARCHAR(50), nullable=True, comment="菜品分类") + cuisine: Mapped[Optional[str]] = mapped_column(VARCHAR(50), nullable=True, comment="菜系") + # 状态 + status: Mapped[str] = mapped_column( + VARCHAR(20), nullable=False, default="draft", comment="draft/published/archived" + ) + # 用户标注 + user_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="用户备注") + is_favorite: Mapped[bool] = mapped_column(default=False, comment="是否收藏") diff --git a/backend/app/models/reply_receipt.py b/backend/app/models/reply_receipt.py new file mode 100644 index 00000000..2e8fd322 --- /dev/null +++ b/backend/app/models/reply_receipt.py @@ -0,0 +1,39 @@ +"""AI PMO 回执账本。""" + +from __future__ import annotations + +from typing import Any, Optional + +from sqlalchemy import BigInteger, ForeignKey, Integer, UniqueConstraint, VARCHAR +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base +from .types import JSONB + + +class ReplyReceipt(Base): + __tablename__ = "reply_receipts" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "source_system", + "source_message_id", + "receipt_phase", + name="uq_reply_receipts_source_phase", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + work_item_id: Mapped[Optional[int]] = mapped_column( + BigInteger, + ForeignKey("work_items.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + source_system: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True) + source_message_id: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True) + receipt_phase: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True) + template_key: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True) + payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="pending", index=True) + diff --git a/backend/app/models/signal.py b/backend/app/models/signal.py index 161d11da..5d2e0600 100644 --- a/backend/app/models/signal.py +++ b/backend/app/models/signal.py @@ -1,7 +1,7 @@ """信号模型""" from .base import Base from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy import BigInteger, Integer, VARCHAR, Text, Float, Boolean +from sqlalchemy import BigInteger, Integer, VARCHAR, Text, Float, Boolean, ForeignKey from typing import Optional @@ -16,5 +16,16 @@ class Signal(Base): processed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) strategic_analysis: Mapped[Optional[str]] = mapped_column(Text, nullable=True) tasks_generated: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + feishu_item_id: Mapped[Optional[int]] = mapped_column( + BigInteger, ForeignKey("feishu_items.id"), nullable=True, index=True + ) + matched_project_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True) + title: Mapped[Optional[str]] = mapped_column(VARCHAR(200), nullable=True) + signal_type: Mapped[Optional[str]] = mapped_column(VARCHAR(50), nullable=True, index=True) + summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + reasoning: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + confidence: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + recommended_action: Mapped[Optional[str]] = mapped_column(VARCHAR(50), nullable=True) + status: Mapped[str] = mapped_column(VARCHAR(50), nullable=False, default="待处理", index=True) feedbacks = relationship("SignalFeedback", back_populates="signal", cascade="all, delete-orphan") diff --git a/backend/app/models/signal_edit.py b/backend/app/models/signal_edit.py new file mode 100644 index 00000000..a29a555b --- /dev/null +++ b/backend/app/models/signal_edit.py @@ -0,0 +1,24 @@ +"""信号编辑历史模型""" +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from sqlalchemy import BigInteger, ForeignKey, Integer, TEXT, TIMESTAMP, VARCHAR +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base + + +class SignalEdit(Base): + __tablename__ = "signal_edits" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + signal_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("signals.id"), nullable=False, index=True + ) + field: Mapped[str] = mapped_column(VARCHAR(80), nullable=False) + old_value: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True) + new_value: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True) + edited_by: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("users.id"), nullable=True, index=True) + edited_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True) diff --git a/backend/app/models/signal_source_registry.py b/backend/app/models/signal_source_registry.py new file mode 100644 index 00000000..967ad648 --- /dev/null +++ b/backend/app/models/signal_source_registry.py @@ -0,0 +1,46 @@ +"""信号源注册表。""" + +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import BigInteger, ForeignKey, Integer, Text, UniqueConstraint, VARCHAR, TIMESTAMP +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base +from .types import JSONB + + +class SignalSourceRegistry(Base): + __tablename__ = "signal_source_registries" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "project_id", + "source_kind", + "source_identifier", + name="uq_signal_source_registry_key", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + project_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("projects.id"), nullable=False, index=True + ) + signal_id: Mapped[Optional[int]] = mapped_column( + BigInteger, ForeignKey("signals.id"), nullable=True, index=True + ) + created_by_user_id: Mapped[Optional[int]] = mapped_column( + BigInteger, ForeignKey("users.id"), nullable=True, index=True + ) + source_kind: Mapped[str] = mapped_column(VARCHAR(40), nullable=False, index=True) + source_name: Mapped[str] = mapped_column(VARCHAR(120), nullable=False) + source_identifier: Mapped[str] = mapped_column(VARCHAR(180), nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + requested_via: Mapped[str] = mapped_column(VARCHAR(40), nullable=False, default="system", index=True) + status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="active", index=True) + onboarding_status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="pending", index=True) + config: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) + raw_context: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) + last_signal_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True) + last_worker_run_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True) diff --git a/backend/app/models/symbol_definition.py b/backend/app/models/symbol_definition.py new file mode 100644 index 00000000..1f916e78 --- /dev/null +++ b/backend/app/models/symbol_definition.py @@ -0,0 +1,46 @@ +"""代码符号定义模型。""" + +from __future__ import annotations + +from typing import Any, Optional + +from sqlalchemy import Integer, TIMESTAMP, VARCHAR, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base +from .types import JSONB + + +class SymbolDefinition(Base): + __tablename__ = "symbol_definitions" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "file_path", + "symbol_type", + "symbol_name", + "start_line", + name="uq_symbol_definitions_tenant_file_type_name_line", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + file_path: Mapped[str] = mapped_column(VARCHAR(600), nullable=False, index=True) + language: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, index=True) + symbol_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True) + symbol_name: Mapped[str] = mapped_column(VARCHAR(255), nullable=False, index=True) + qualified_name: Mapped[Optional[str]] = mapped_column(VARCHAR(700), nullable=True, index=True) + start_line: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True) + end_line: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + signature: Mapped[Optional[str]] = mapped_column(VARCHAR(1000), nullable=True) + docstring: Mapped[Optional[str]] = mapped_column(VARCHAR(2000), nullable=True) + file_hash: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True) + source_mtime: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True) + status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="active", index=True) + payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + last_indexed_at: Mapped[Optional[TIMESTAMP]] = mapped_column( + TIMESTAMP, + nullable=False, + server_default=func.now(), + index=True, + ) diff --git a/backend/app/models/symbol_reference.py b/backend/app/models/symbol_reference.py new file mode 100644 index 00000000..7c7bf563 --- /dev/null +++ b/backend/app/models/symbol_reference.py @@ -0,0 +1,41 @@ +"""代码符号引用关系模型。""" + +from __future__ import annotations + +from typing import Any, Optional + +from sqlalchemy import ForeignKey, Integer, VARCHAR, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base +from .types import JSONB + + +class SymbolReference(Base): + __tablename__ = "symbol_references" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "file_path", + "relation_type", + "target_symbol_name", + "start_line", + "source_symbol_id", + name="uq_symbol_references_tenant_file_relation_target_line_source", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + source_symbol_id: Mapped[Optional[int]] = mapped_column( + Integer, + ForeignKey("symbol_definitions.id", ondelete="CASCADE"), + nullable=True, + index=True, + ) + file_path: Mapped[str] = mapped_column(VARCHAR(600), nullable=False, index=True) + relation_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True) + target_symbol_name: Mapped[str] = mapped_column(VARCHAR(255), nullable=False, index=True) + target_qualified_name: Mapped[Optional[str]] = mapped_column(VARCHAR(700), nullable=True, index=True) + start_line: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True) + end_line: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) diff --git a/backend/app/models/truth_node.py b/backend/app/models/truth_node.py index 00921dff..b228bc41 100644 --- a/backend/app/models/truth_node.py +++ b/backend/app/models/truth_node.py @@ -15,6 +15,9 @@ class TruthNode(Base): BigInteger, ForeignKey("truth_nodes.id"), nullable=True ) project_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True) + signal_id: Mapped[Optional[int]] = mapped_column( + BigInteger, ForeignKey("signals.id"), nullable=True, index=True + ) created_by: Mapped[int] = mapped_column( BigInteger, ForeignKey("users.id"), nullable=False ) diff --git a/backend/app/models/work_item.py b/backend/app/models/work_item.py new file mode 100644 index 00000000..8fe87525 --- /dev/null +++ b/backend/app/models/work_item.py @@ -0,0 +1,57 @@ +"""AI PMO 工作项模型。""" + +from __future__ import annotations + +from typing import Any, Optional + +from sqlalchemy import BigInteger, ForeignKey, Integer, UniqueConstraint, VARCHAR +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base +from .types import JSONB + + +class WorkItem(Base): + __tablename__ = "work_items" + __table_args__ = ( + UniqueConstraint("tenant_id", "source_system", "source_message_id", name="uq_work_items_source_message"), + UniqueConstraint("tenant_id", "ingest_event_id", name="uq_work_items_ingest_event"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + ingest_event_id: Mapped[int] = mapped_column( + BigInteger, + ForeignKey("ingest_events.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + source_system: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True) + source_message_id: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True) + state: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, default="received", index=True) + intent: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True) + decision_level: Mapped[Optional[str]] = mapped_column(VARCHAR(20), nullable=True, index=True) + project_id: Mapped[Optional[int]] = mapped_column( + BigInteger, + ForeignKey("projects.id"), + nullable=True, + index=True, + ) + signal_id: Mapped[Optional[int]] = mapped_column( + BigInteger, + ForeignKey("signals.id"), + nullable=True, + index=True, + ) + owner_user_id: Mapped[Optional[int]] = mapped_column( + BigInteger, + ForeignKey("users.id"), + nullable=True, + index=True, + ) + dedupe_key: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True, index=True) + context_snapshot: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + action_bundle: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + execution_result: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + review_summary: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + reply_template_key: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True) + diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 6131a7e2..ec0b0a5e 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -42,6 +42,13 @@ from .sstc import ( ReviewCreateRequest, ReviewArchiveRequest, ) +from .recipe import ( + RecipeCreate, + RecipeUpdate, + RecipeResponse, + RecipeImageUploadRequest, + RecipeAnalyzeCaloriesResponse, +) from . import ai as ai_schemas __all__ = [ @@ -87,4 +94,9 @@ __all__ = [ "ReleaseStatusPatchRequest", "ReviewCreateRequest", "ReviewArchiveRequest", + "RecipeCreate", + "RecipeUpdate", + "RecipeResponse", + "RecipeImageUploadRequest", + "RecipeAnalyzeCaloriesResponse", ] diff --git a/backend/app/schemas/rag.py b/backend/app/schemas/rag.py new file mode 100644 index 00000000..3eef117e --- /dev/null +++ b/backend/app/schemas/rag.py @@ -0,0 +1,96 @@ +"""RAG / 知识库 / QA 记录请求模型。""" + +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + + +class RAGAskRequest(BaseModel): + question: str = Field(..., min_length=1, max_length=4000) + project_id: Optional[int] = Field(default=None) + asked_via: str = Field(default="pmflow", pattern="^(pmflow|feishu)$") + source_chat_id: Optional[str] = Field(default=None, max_length=128) + session_id: Optional[int] = Field(default=None, ge=1) + new_session: bool = Field(default=False) + thinking_mode: str = Field(default="normal", pattern="^(normal|deep)$") + action_mode: str = Field(default="auto", pattern="^(auto|read|write|read_write)$") + sync_requirement: bool = Field(default=True) + + +class KnowledgeDocCreateRequest(BaseModel): + title: str = Field(..., min_length=1, max_length=255) + file_type: str = Field(default="txt", max_length=32) + content: str = Field(..., min_length=1, max_length=200000) + source_type: str = Field(default="file", max_length=32) + file_url: Optional[str] = Field(default=None, max_length=1024) + project_id: Optional[int] = Field(default=None) + + +class KnowledgeURLCreateRequest(BaseModel): + url: str = Field(..., min_length=5, max_length=1024) + title: Optional[str] = Field(default=None, max_length=255) + project_id: Optional[int] = Field(default=None) + + +class QAFeedbackCreateRequest(BaseModel): + rating: str = Field(..., pattern="^(positive|negative)$") + helpfulness: Optional[str] = Field(default=None, pattern="^(fully|partially|could_be_better)$") + error_types: list[str] = Field(default_factory=list) + correct_answer: Optional[str] = Field(default=None, max_length=4000) + + +class QAListQuery(BaseModel): + project_id: Optional[int] = None + date_from: Optional[datetime] = None + date_to: Optional[datetime] = None + rating: Optional[str] = Field(default=None, pattern="^(positive|negative)$") + page: int = Field(default=1, ge=1) + page_size: int = Field(default=20, ge=1, le=100) + + +class QAExportQuery(BaseModel): + include_unrated: bool = False + project_id: Optional[int] = None + rating: Optional[str] = Field(default=None, pattern="^(positive|negative|unrated)$") + domain: Optional[str] = None + date_from: Optional[datetime] = None + date_to: Optional[datetime] = None + + +class QASessionListQuery(BaseModel): + date_from: Optional[datetime] = None + date_to: Optional[datetime] = None + page: int = Field(default=1, ge=1) + page_size: int = Field(default=20, ge=1, le=100) + + +class QASessionRenameRequest(BaseModel): + title: str = Field(..., min_length=1, max_length=120) + + +class EntityResolutionRequest(BaseModel): + question: str = Field(..., min_length=1, max_length=4000) + project_id: Optional[int] = Field(default=None) + limit: int = Field(default=10, ge=1, le=50) + + +class MultiStepSearchRequest(BaseModel): + question: str = Field(..., min_length=1, max_length=4000) + project_id: Optional[int] = Field(default=None) + source_chat_id: Optional[str] = Field(default=None, max_length=128) + + +class SymbolSyncRequest(BaseModel): + base_dir: Optional[str] = Field(default=None, max_length=500) + max_files: int = Field(default=500, ge=1, le=3000) + force_reindex: bool = Field(default=False) + + +class SymbolSearchRequest(BaseModel): + query: str = Field(..., min_length=1, max_length=255) + language: Optional[str] = Field(default=None, max_length=20) + symbol_type: Optional[str] = Field(default=None, max_length=32) + limit: int = Field(default=30, ge=1, le=200) diff --git a/backend/app/schemas/recipe.py b/backend/app/schemas/recipe.py new file mode 100644 index 00000000..bd4b8925 --- /dev/null +++ b/backend/app/schemas/recipe.py @@ -0,0 +1,71 @@ +"""食谱相关 Pydantic 模型""" + +from pydantic import BaseModel, Field +from typing import Optional, Dict, Any +from datetime import datetime + + +class RecipeCreate(BaseModel): + """创建食谱请求""" + name: str = Field(..., min_length=1, max_length=200, description="菜名") + 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="菜系") + user_notes: Optional[str] = Field(None, description="用户备注") + + +class RecipeUpdate(BaseModel): + """更新食谱请求""" + name: Optional[str] = Field(None, min_length=1, max_length=200) + description: Optional[str] = None + image_url: Optional[str] = None + category: Optional[str] = None + cuisine: Optional[str] = None + status: Optional[str] = Field(None, pattern="^(draft|published|archived)$") + user_notes: Optional[str] = None + is_favorite: Optional[bool] = None + + +class RecipeResponse(BaseModel): + """食谱响应""" + id: int + name: str + description: Optional[str] + image_url: Optional[str] + # 营养信息 + calories: Optional[float] + protein: Optional[float] + fat: Optional[float] + carbs: Optional[float] + fiber: Optional[float] + # AI 识别相关 + ai_recognition_confidence: Optional[float] + ai_analysis_result: Optional[Dict[str, Any]] + # 分类 + category: Optional[str] + cuisine: Optional[str] + # 状态 + status: str + # 用户标注 + user_notes: Optional[str] + is_favorite: bool + # 时间戳 + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class RecipeImageUploadRequest(BaseModel): + """图片上传请求""" + image_base64: str = Field(..., description="Base64 编码的图片数据") + filename: Optional[str] = Field(None, description="原始文件名") + + +class RecipeAnalyzeCaloriesResponse(BaseModel): + """热量分析响应""" + success: bool + data: Optional[RecipeResponse] = None + message: Optional[str] = None diff --git a/backend/app/schemas/sstc.py b/backend/app/schemas/sstc.py index ec85fdc2..fc34f802 100644 --- a/backend/app/schemas/sstc.py +++ b/backend/app/schemas/sstc.py @@ -36,11 +36,14 @@ class StageStatus(str, Enum): EXEC_DONE = "已完成" # release RELEASE_LOCKED = "未解锁" + RELEASE_PREP = "可预发布" RELEASE_PENDING = "待发布" + RELEASE_RELEASING = "发布中" RELEASE_LIVE = "已上线" RELEASE_VERIFIED = "已验证" # review REVIEW_LOCKED = "未解锁" + REVIEW_PREP = "可阶段复盘" REVIEW_DRAFT = "草稿" REVIEW_ARCHIVED = "已归档" @@ -91,6 +94,11 @@ class TaskStatusPatchRequest(BaseModel): status: Literal["todo", "in_progress", "blocked", "review", "done", "completed"] +class WorkloadOptimizeRequest(BaseModel): + mode: Literal["unassigned", "rebalance"] = "rebalance" + max_moves: int = Field(default=200, ge=1, le=500) + + class ReleaseCreateRequest(BaseModel): title: str charter_id: int @@ -112,3 +120,22 @@ class ReviewCreateRequest(BaseModel): class ReviewArchiveRequest(BaseModel): archive: bool = True note: Optional[str] = None + + +class ReleaseVerificationItemPatchRequest(BaseModel): + status: Literal["pending", "done"] + note: Optional[str] = None + + +class ReviewContentPatchRequest(BaseModel): + content: dict[str, Any] = Field(default_factory=dict) + + +class ReviewImprovementToTaskRequest(BaseModel): + improvement_index: int = Field(..., ge=0) + charter_id: Optional[int] = None + title: Optional[str] = None + description: Optional[str] = None + priority: Literal["high", "medium", "low"] = "medium" + estimated_hours: Optional[float] = Field(default=None, ge=0) + due_date: Optional[str] = None diff --git a/backend/app/services/adaptive_rag_service.py b/backend/app/services/adaptive_rag_service.py new file mode 100644 index 00000000..e1fb526d --- /dev/null +++ b/backend/app/services/adaptive_rag_service.py @@ -0,0 +1,6598 @@ +"""全自适应 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_, delete, func, or_, select +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 + + +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": datetime.now(timezone.utc).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": datetime.now(timezone.utc).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": datetime.now(timezone.utc).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": datetime.now(timezone.utc).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": datetime.now(timezone.utc).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 [] + + stmt = select(KnowledgeBaseChunk).where( + and_( + KnowledgeBaseChunk.tenant_id == self.tenant_id, + KnowledgeBaseChunk.source_type.in_(source_types), + ) + ) + + chunks = (await self.db.execute(stmt.limit(500))).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: + # 防止跨项目泄漏:Feishu/PMFlow 来源必须严格匹配项目组; + # 文档/URL 来源可读取全局(project_id 为空)和当前项目数据。 + 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) + limit = max(1, min(int(top_k or DEFAULT_TOP_K), 40)) + 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=datetime.now(timezone.utc) 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 = datetime.now(timezone.utc) + 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 datetime.now(timezone.utc)).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": datetime.now(timezone.utc).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": datetime.now(timezone.utc).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(timezone.utc).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 = datetime.now(timezone.utc).replace(tzinfo=None) + 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, + } + + +__all__ = ["AdaptiveRAGService"] diff --git a/backend/app/services/ai_orchestration_run_service.py b/backend/app/services/ai_orchestration_run_service.py new file mode 100644 index 00000000..3b9b44d1 --- /dev/null +++ b/backend/app/services/ai_orchestration_run_service.py @@ -0,0 +1,179 @@ +"""AI 编排运行账本服务。""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any, Optional + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import AIOrchestrationRun + + +def _utc_now_naive() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class AIOrchestrationRunService: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def create_run( + self, + *, + tenant_id: int, + trace_id: str, + run_type: str, + project_id: int, + signal_id: Optional[int], + response_mode: str, + status: str, + planner_status: Optional[str], + openclaw_status: Optional[str], + worker_total: int, + worker_completed: int, + worker_failed: int, + summary: str, + planner_diagnostics: Optional[dict[str, Any]], + result_payload: Optional[dict[str, Any]], + created_by_user_id: Optional[int], + started_at: Optional[datetime] = None, + completed_at: Optional[datetime] = None, + ) -> AIOrchestrationRun: + row = AIOrchestrationRun( + tenant_id=tenant_id, + trace_id=trace_id, + run_type=run_type, + project_id=project_id, + signal_id=signal_id, + response_mode=response_mode, + status=status, + planner_status=planner_status, + openclaw_status=openclaw_status, + worker_total=max(0, int(worker_total)), + worker_completed=max(0, int(worker_completed)), + worker_failed=max(0, int(worker_failed)), + summary=str(summary or "").strip()[:1000] or None, + planner_diagnostics=planner_diagnostics if isinstance(planner_diagnostics, dict) else None, + result_payload=result_payload if isinstance(result_payload, dict) else None, + created_by_user_id=created_by_user_id, + started_at=started_at or _utc_now_naive(), + completed_at=completed_at, + ) + self.db.add(row) + await self.db.flush() + return row + + async def get_run(self, *, tenant_id: int, trace_id: str) -> Optional[AIOrchestrationRun]: + return ( + await self.db.execute( + select(AIOrchestrationRun).where( + and_( + AIOrchestrationRun.tenant_id == int(tenant_id), + AIOrchestrationRun.trace_id == str(trace_id), + ) + ) + ) + ).scalar_one_or_none() + + async def update_run( + self, + *, + tenant_id: int, + trace_id: str, + status: Optional[str] = None, + planner_status: Optional[str] = None, + openclaw_status: Optional[str] = None, + worker_total: Optional[int] = None, + worker_completed: Optional[int] = None, + worker_failed: Optional[int] = None, + summary: Optional[str] = None, + planner_diagnostics: Optional[dict[str, Any]] = None, + result_payload: Optional[dict[str, Any]] = None, + completed_at: Optional[datetime] = None, + ) -> Optional[AIOrchestrationRun]: + row = await self.get_run(tenant_id=tenant_id, trace_id=trace_id) + if row is None: + return None + if status is not None: + row.status = str(status or "").strip()[:20] + if planner_status is not None: + row.planner_status = str(planner_status or "").strip()[:20] or None + if openclaw_status is not None: + row.openclaw_status = str(openclaw_status or "").strip()[:20] or None + if worker_total is not None: + row.worker_total = max(0, int(worker_total)) + if worker_completed is not None: + row.worker_completed = max(0, int(worker_completed)) + if worker_failed is not None: + row.worker_failed = max(0, int(worker_failed)) + if summary is not None: + row.summary = str(summary or "").strip()[:1000] or None + if planner_diagnostics is not None: + row.planner_diagnostics = planner_diagnostics + if result_payload is not None: + row.result_payload = result_payload + if completed_at is not None: + row.completed_at = completed_at + return row + + async def mark_stale_runs_interrupted( + self, + *, + older_than_minutes: int = 10, + ) -> int: + threshold = _utc_now_naive() - timedelta(minutes=max(1, int(older_than_minutes))) + rows = ( + await self.db.execute( + select(AIOrchestrationRun).where( + and_( + AIOrchestrationRun.status.in_(("queued", "running")), + AIOrchestrationRun.started_at.is_not(None), + AIOrchestrationRun.started_at < threshold, + ) + ) + ) + ).scalars().all() + count = 0 + for row in rows: + payload = row.result_payload if isinstance(row.result_payload, dict) else {} + payload["run_status"] = "interrupted" + payload["pending_workers"] = payload.get("pending_workers") or [] + row.status = "interrupted" + row.completed_at = _utc_now_naive() + row.result_payload = payload + count += 1 + return count + + @staticmethod + def serialize_run(row: AIOrchestrationRun) -> dict[str, Any]: + payload = row.result_payload if isinstance(row.result_payload, dict) else {} + pending_workers = payload.get("pending_workers") if isinstance(payload.get("pending_workers"), list) else [] + worker_total = int(row.worker_total or 0) + worker_completed = int(row.worker_completed or 0) + worker_failed = int(row.worker_failed or 0) + worker_pending = max(0, worker_total - worker_completed - worker_failed) + if not pending_workers and worker_pending > 0: + pending_workers = [f"pending_{idx}" for idx in range(1, worker_pending + 1)] + return { + "trace_id": str(row.trace_id or ""), + "run_type": str(row.run_type or ""), + "project_id": int(row.project_id), + "signal_id": int(row.signal_id) if row.signal_id is not None else None, + "response_mode": str(row.response_mode or ""), + "run_status": str(row.status or ""), + "planner_status": str(row.planner_status or ""), + "openclaw_status": str(row.openclaw_status or ""), + "worker_total": worker_total, + "worker_completed": worker_completed, + "worker_failed": worker_failed, + "worker_pending": worker_pending, + "pending_workers": pending_workers, + "summary": str(row.summary or payload.get("summary") or "").strip(), + "planner_diagnostics": row.planner_diagnostics if isinstance(row.planner_diagnostics, dict) else None, + "result_snapshot": payload, + "created_by_user_id": int(row.created_by_user_id) if row.created_by_user_id is not None else None, + "started_at": row.started_at.isoformat() if row.started_at else None, + "completed_at": row.completed_at.isoformat() if row.completed_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..c989c8b6 --- /dev/null +++ b/backend/app/services/ai_pmo_cadence_service.py @@ -0,0 +1,483 @@ +"""AI PMO cadence engine。""" + +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 + + +def _utc_now_naive() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +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 diff --git a/backend/app/services/ai_pmo_domain_workflow.py b/backend/app/services/ai_pmo_domain_workflow.py new file mode 100644 index 00000000..0cbb672d --- /dev/null +++ b/backend/app/services/ai_pmo_domain_workflow.py @@ -0,0 +1,108 @@ +"""AI PMO domain workflow adapters。""" + +from __future__ import annotations + +from typing import Any, Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from .signal_source_orchestration_service import SignalSourceOrchestrationService + + +class SignalSourceDomainWorkflow: + """把 signal source onboarding 归一成 domain workflow 结果。""" + + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def run( + self, + *, + tenant_id: int, + project_id: int, + signal_id: int, + initiating_text: str, + requested_via: str, + operator_user_id: Optional[int], + ) -> dict[str, Any]: + raw_result = await SignalSourceOrchestrationService(self.db).orchestrate_from_signal( + tenant_id=tenant_id, + project_id=project_id, + signal_id=signal_id, + initiating_text=initiating_text, + requested_via=requested_via, + operator_user_id=operator_user_id, + dry_run=False, + response_mode="fast", + ) + normalized = self.normalize(raw_result) + normalized["raw_result"] = raw_result + return normalized + + @classmethod + def normalize(cls, raw_result: dict[str, Any]) -> dict[str, Any]: + payload = raw_result if isinstance(raw_result, dict) else {} + signal_source = payload.get("signal_source") if isinstance(payload.get("signal_source"), dict) else {} + worker_executions = payload.get("worker_executions") if isinstance(payload.get("worker_executions"), list) else [] + typed_actions = cls._extract_typed_actions(payload.get("openclaw")) + material_actions = [ + row + for row in typed_actions + if str(row.get("operation_code") or "").strip() != "notification.create" + and str(row.get("status") or "").strip() in {"applied", "planned"} + ] + fallback_used = any(bool(item.get("used_fallback")) for item in worker_executions if isinstance(item, dict)) + + raw_status = str(payload.get("status") or "").strip().lower() + registry_status = str(signal_source.get("status") or "").strip().lower() + onboarding_status = str(signal_source.get("onboarding_status") or "").strip().lower() + openclaw_status = str((payload.get("openclaw") or {}).get("status") or "").strip().lower() + + if raw_status == "failed" or openclaw_status == "failed": + final_status = "failed_safe" + elif onboarding_status != "completed": + final_status = "pending_manual" + elif not material_actions: + final_status = "pending_manual" + elif fallback_used: + final_status = "pending_manual" + elif raw_status in {"completed", "partial", "planned"}: + final_status = "completed" + else: + final_status = "pending_manual" + + next_step = "等待人工确认" if final_status != "completed" else "继续自动执行后续任务" + return { + "status": final_status, + "registry_id": signal_source.get("registry_id"), + "registry_status": registry_status, + "onboarding_status": onboarding_status, + "openclaw_status": openclaw_status, + "typed_actions": typed_actions, + "material_action_count": len(material_actions), + "worker_executions": worker_executions, + "pending_workers": payload.get("pending_workers") if isinstance(payload.get("pending_workers"), list) else [], + "next_step": next_step, + "fallback_used": fallback_used, + } + + @staticmethod + def _extract_typed_actions(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 [] + actions: list[dict[str, Any]] = [] + for row in rows: + if not isinstance(row, dict): + continue + actions.append( + { + "operation_code": str(row.get("operation_code") or "").strip(), + "status": str(row.get("status") or "").strip(), + "entity_id": row.get("entity_id"), + "payload": row.get("payload") if isinstance(row.get("payload"), dict) else {}, + } + ) + return actions diff --git a/backend/app/services/ai_pmo_kernel_service.py b/backend/app/services/ai_pmo_kernel_service.py new file mode 100644 index 00000000..2e4f22e1 --- /dev/null +++ b/backend/app/services/ai_pmo_kernel_service.py @@ -0,0 +1,2300 @@ +"""AI PMO 决策中枢内核服务。""" + +from __future__ import annotations + +import hashlib +import json +import re +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Optional + +from sqlalchemy import and_, func, or_, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from ..core.logging import get_logger +from ..models import ( + AIProcessingLog, + Budget, + DecisionRecord, + IngestEvent, + Notification, + PlanBundle, + PortfolioSnapshot, + Project, + ProjectDomain, + ProjectSignalLink, + ReplyReceipt, + Signal, + Task, + TruthNode, + User, + UserProject, + UserSkillBinding, + UserWorkload, + WorkItem, +) +from .adaptive_rag_service import AdaptiveRAGService +from .ai_pmo_cadence_service import AIPMOCadenceService +from .ai_steward import AIStewardService +from .ai_pmo_review_service import AIPMOReviewService +from .notification_service import NotificationService +from .openclaw_commander_service import OpenClawCommanderService +from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow +from .signal_source_orchestration_service import SignalSourceOrchestrationService + +logger = get_logger(__name__) + +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, +} + +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" + +QUESTION_HINTS = ("?", "?", "怎么", "为何", "为什么", "什么", "多少", "是否", "帮我看", "状态") +SMALLTALK_EXACT = { + "收到", + "收到啦", + "好的", + "ok", + "okk", + "yes", + "no", + "谢谢", + "thanks", + "thx", + "在吗", + "在不在", +} +TRANSPORT_MODE_CORRECTION_HINTS = ("不是", "不再", "改为", "改成", "替换为", "取代", "现在都", "现在是") +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 = ("创建项目", "新建项目", "建立项目", "做个项目", "立项", "开个项目") +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", ("关键路径", "里程碑改动", "影响多个项目", "跨项目依赖调整")), +) + + +def _utc_now_naive() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=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 _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 + + +@dataclass(slots=True) +class CanonicalIntent: + name: str + confidence: float + project_id: Optional[int] + signal_title: str + signal_summary: str + metadata: dict[str, Any] + + +class IntentResolver: + """把输入收口为 canonical intent。""" + + @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 normalized in SMALLTALK_EXACT: + return CanonicalIntent(INTENT_IGNORE, 0.95, project_id, "", "", {"reason": "smalltalk"}) + 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 any(token in pretty for token in PROJECT_CREATE_HINTS) or OpenClawCommanderService._extract_project_create_title(pretty): + return CanonicalIntent( + INTENT_PROJECT_CREATE, + 0.9, + project_id, + IntentResolver._build_signal_title(pretty, default="新项目创建请求"), + pretty[:300], + {}, + ) + if any(token in pretty for token in QUESTION_HINTS): + return CanonicalIntent( + INTENT_QUERY, + 0.88, + project_id, + "", + pretty[:300], + {}, + ) + if any(token in pretty for token in SIGNAL_CREATE_HINTS) or any(token in pretty for token in ("新增", "增加", "录入", "跟踪", "关注")): + return CanonicalIntent( + INTENT_SIGNAL_CREATE, + 0.78, + project_id, + IntentResolver._build_signal_title(pretty, default="新信号"), + pretty[:300], + {}, + ) + return CanonicalIntent( + INTENT_SIGNAL_CREATE, + 0.65, + project_id, + IntentResolver._build_signal_title(pretty, default="新信号"), + pretty[:300], + {"fallback": True}, + ) + + @staticmethod + def _build_signal_title(text: str, *, default: str) -> str: + cleaned = re.sub(r"\s+", " ", str(text or "")).strip(" ,,::") + if not cleaned: + return default + for token in ("。", ";", ";", "!", "!", "?", "?"): + if token in cleaned: + cleaned = cleaned.split(token, 1)[0].strip() + break + return cleaned[:120] or default + + +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 = [] + 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: + 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, + } + HOURS_BY_OPERATION = { + "query.answer": 0.2, + "project.create": 12.0, + "signal.enter": 1.5, + "signal_source.onboard": 6.0, + } + + @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: + """自动执行边界。""" + + @staticmethod + def evaluate( + *, + 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 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": "信号源接入缺少项目归属,必须先确认项目。", + }, + } + + # 轻量级事实写入/只读查询不应被项目当前依赖或产能压力阻塞。 + # 否则“AI 雷达”会在最需要先收集事实时失去自动化能力。 + 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) -> None: + self.strategic_brain = StrategicBrain() + self.tactical_brain = TacticalBrain() + self.simulation_engine = SimulationEngine() + self.execution_policy_brain = ExecutionPolicyBrain() + + 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, + } + + +class ReplyRenderer: + """唯一回执出口。""" + + @staticmethod + def render( + *, + work_state: str, + intent: CanonicalIntent, + decision_record: dict[str, Any], + execution_result: dict[str, Any], + ) -> dict[str, Any]: + if work_state == STATE_WAITING_MANUAL: + if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: + followup_text = str(execution_result.get("followup_reply") or "").strip() + if followup_text: + return { + "receipt_phase": RECEIPT_PHASE_PENDING, + "template_key": "pending_manual", + "payload": {"intent": intent.name, "reason": followup_text}, + "text": followup_text, + } + gate_message = str(((decision_record.get("approval_requirement") or {}).get("message")) or "").strip() + if not gate_message: + gate_message = "已进入待人工确认队列。" + return { + "receipt_phase": RECEIPT_PHASE_PENDING, + "template_key": "pending_manual", + "payload": {"intent": intent.name, "reason": gate_message}, + "text": gate_message, + } + if work_state == STATE_FAILED: + if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: + followup_text = str(execution_result.get("followup_reply") or "").strip() + if followup_text: + return { + "receipt_phase": RECEIPT_PHASE_ERROR, + "template_key": "safe_error", + "payload": {"intent": intent.name, "reason": followup_text}, + "text": followup_text, + } + reason = str(execution_result.get("error_message") or "系统处理中出现异常,本次未执行。").strip() + return { + "receipt_phase": RECEIPT_PHASE_ERROR, + "template_key": "safe_error", + "payload": {"intent": intent.name, "reason": reason}, + "text": reason, + } + if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: + followup_text = str(execution_result.get("followup_reply") or "已继续处理。").strip() + return { + "receipt_phase": RECEIPT_PHASE_ACTION, + "template_key": "action_succeeded", + "payload": { + "intent": intent.name, + "target_work_item_id": _safe_int(execution_result.get("target_work_item_id")), + "target_state": execution_result.get("target_state"), + }, + "text": followup_text, + } + if intent.name == INTENT_QUERY: + answer = str(execution_result.get("answer") or "当前暂无可用回答。").strip() + return { + "receipt_phase": RECEIPT_PHASE_ACTION, + "template_key": "action_succeeded", + "payload": {"intent": intent.name, "answer": answer}, + "text": answer, + } + if intent.name == INTENT_PROJECT_CREATE: + project = execution_result.get("project") if isinstance(execution_result.get("project"), dict) else {} + project_id = _safe_int(project.get("id")) + project_title = str(project.get("title") or project.get("name") or "新项目").strip() + return { + "receipt_phase": RECEIPT_PHASE_ACTION, + "template_key": "action_succeeded", + "payload": {"intent": intent.name, "project_id": project_id, "project_title": project_title}, + "text": f"已立项:项目#{project_id}《{project_title}》已创建完成。" if project_id else f"已立项:《{project_title}》已创建完成。", + } + if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: + signal_id = _safe_int(execution_result.get("signal_id")) + workflow = execution_result.get("signal_source_workflow") if isinstance(execution_result.get("signal_source_workflow"), dict) else {} + status = str(workflow.get("status") or (execution_result.get("signal_source_orchestration") or {}).get("status") or "").strip() + if status == "pending_manual": + return { + "receipt_phase": RECEIPT_PHASE_PENDING, + "template_key": "pending_manual", + "payload": {"intent": intent.name, "signal_id": signal_id, "status": status}, + "text": f"已记录信号源接入请求,当前为待人工确认。信号#{signal_id} 已入库。" if signal_id else "已记录信号源接入请求,当前为待人工确认。", + } + return { + "receipt_phase": RECEIPT_PHASE_ACTION, + "template_key": "action_succeeded", + "payload": {"intent": intent.name, "signal_id": signal_id, "status": status}, + "text": f"已接收入库并启动信号源接入流程。信号#{signal_id},状态:{status or 'running'}。", + } + signal_id = _safe_int(execution_result.get("signal_id")) + project_id = _safe_int(execution_result.get("project_id")) + if signal_id is not None and project_id is not None: + text = f"已入库:已为项目#{project_id}创建信号#{signal_id}。" + elif signal_id is not None: + text = f"已入库:已创建信号#{signal_id}。" + else: + text = "已收到并入库。" + return { + "receipt_phase": RECEIPT_PHASE_ACTION, + "template_key": "action_succeeded", + "payload": {"intent": intent.name, "signal_id": signal_id, "project_id": project_id}, + "text": text, + } + + +class TypedExecutor: + """执行层只做 typed action 落地。""" + + def __init__(self, db: AsyncSession, tenant_id: int) -> None: + self.db = db + self.tenant_id = int(tenant_id) + self.signal_source_workflow = SignalSourceDomainWorkflow(db) + + async def execute( + self, + *, + intent: CanonicalIntent, + message_text: str, + sender: str, + operator_user_id: Optional[int], + source_system: str, + source_message_id: str, + ) -> dict[str, Any]: + if intent.name == INTENT_QUERY: + rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) + 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, + ) + 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: + signal = await self._create_signal( + title=intent.signal_title or "新增信号源接入请求", + summary=intent.signal_summary or message_text, + content=message_text, + project_id=intent.project_id, + source="PMFlow", + signal_type="新需求", + recommended_action="enter_only", + ) + 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 {} + 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("openclaw")), + } + + if intent.name == INTENT_SIGNAL_CREATE: + signal = await self._create_signal( + title=intent.signal_title or "新信号", + summary=intent.signal_summary or message_text, + content=message_text, + project_id=intent.project_id, + source="PMFlow", + signal_type="新需求", + recommended_action="enter_only", + ) + return { + "status": "succeeded", + "signal_id": int(signal.id), + "project_id": intent.project_id, + "auto_tasks": [], + } + + return { + "status": "failed_safe", + "error_message": "当前意图尚未迁移到 AI PMO kernel,已保留给兼容链路处理。", + } + + async def _create_signal( + self, + *, + title: str, + summary: str, + content: str, + project_id: Optional[int], + source: str, + signal_type: str, + recommended_action: str, + ) -> Signal: + signal = Signal( + tenant_id=self.tenant_id, + source=source, + title=(title or "新信号")[:120], + summary=(summary or content or "")[:300], + content=(content or summary or "")[:2000], + signal_type=signal_type, + reasoning=json.dumps(["由 AI PMO kernel 统一入库。"], ensure_ascii=False), + 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="medium", + 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 + + +class AIPMOKernelService: + """AI PMO kernel,作为新的统一事实链。""" + + def __init__(self, db: AsyncSession, tenant_id: int = 1) -> None: + self.db = db + self.tenant_id = int(tenant_id) + self.intent_resolver = IntentResolver() + self.decision_engine = DecisionEngine() + self.reply_renderer = ReplyRenderer() + self.executor = TypedExecutor(db, tenant_id=self.tenant_id) + self.review_service = AIPMOReviewService(db, tenant_id=self.tenant_id) + self.cadence_service = AIPMOCadenceService(db, tenant_id=self.tenant_id) + + 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() + 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, + ) + + 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, + }, + ) + 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)), + ) + + try: + world_model = await self._build_world_model( + message_text=clean_text, + sender=sender, + project_hint_id=project_hint_id, + ) + work_item.state = STATE_MODELED + work_item.context_snapshot = world_model + 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.name + 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.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 = intent.name + work_item.project_id = intent.project_id + decision_payload = self.decision_engine.decide( + intent=intent, + world_model=world_model, + message_text=clean_text, + ) + 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 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(), + } + 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.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", + ) + + 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}: + 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, + ) + else: + execution_result = await self.executor.execute( + 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, + ) + verification = await self._verify_execution(intent=intent, execution_result=execution_result, world_model=world_model) + 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() == "pending_manual": + 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.{intent.name}", + operation_code=self._primary_operation_code(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), + ) + await self.db.commit() + return 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") + ), + ) + 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)} + + async def _claim_ingress_event( + self, + *, + source_system: str, + source_message_id: str, + payload: dict[str, Any], + ) -> tuple[IngestEvent, bool]: + channel = f"{source_system}_message" + existing = ( + await self.db.execute( + select(IngestEvent).where( + and_( + IngestEvent.tenant_id == self.tenant_id, + IngestEvent.channel == channel, + IngestEvent.event_key == source_message_id, + ) + ).limit(1) + ) + ).scalar_one_or_none() + if existing is not None: + return existing, False + row = IngestEvent( + tenant_id=self.tenant_id, + channel=channel, + event_key=source_message_id, + status="processing", + payload=payload, + retry_count=0, + error_message=None, + processed_at=None, + ) + self.db.add(row) + try: + await self.db.flush() + except IntegrityError: + await self.db.rollback() + existing = ( + await self.db.execute( + select(IngestEvent).where( + and_( + IngestEvent.tenant_id == self.tenant_id, + IngestEvent.channel == channel, + IngestEvent.event_key == source_message_id, + ) + ).limit(1) + ) + ).scalar_one() + return existing, False + return row, True + + async def _create_work_item( + self, + *, + ingest_event: IngestEvent, + source_system: str, + source_message_id: str, + owner_user_id: Optional[int], + ) -> WorkItem: + row = WorkItem( + tenant_id=self.tenant_id, + ingest_event_id=int(ingest_event.id), + source_system=source_system, + source_message_id=source_message_id, + state=STATE_RECEIVED, + owner_user_id=owner_user_id, + dedupe_key=f"{source_system}:{source_message_id}", + ) + self.db.add(row) + await self.db.flush() + return row + + async def _build_world_model( + self, + *, + message_text: str, + sender: str, + project_hint_id: Optional[int], + ) -> dict[str, Any]: + project = await self._resolve_project(message_text=message_text, project_hint_id=project_hint_id) + project_id = _safe_int(getattr(project, "id", None)) + portfolio_summary = await self._build_portfolio_summary(project_id=project_id) + budget = await self._build_budget_summary(project_id=project_id) + capacity = await self._build_capacity_summary(project_id=project_id) + skill_graph = await self._build_skill_graph(project_id=project_id) + charter = await self._build_charter_snapshot(project_id=project_id) + task_summary = await self._build_task_summary(project_id=project_id) + risk_summary = await self._build_risk_summary(project_id=project_id) + domain = await self._build_project_domain_snapshot(project_id=project_id) + dependencies = self._build_dependency_summary(charter=charter, task_summary=task_summary) + recent_signals = await self._build_recent_signal_summary(project_id=project_id) + + world_model = { + "sender": sender, + "resolved_project_id": project_id, + "project": self._serialize_project(project) if project is not None else None, + "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": project_id, + "budget": budget, + "capacity": capacity, + "skill_graph_size": len(skill_graph), + "dependency_pressure": dependencies.get("pressure"), + }, + } + self.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 self.db.flush() + return world_model + + async def _resolve_project(self, *, message_text: str, project_hint_id: Optional[int]) -> Optional[Project]: + if project_hint_id is not None: + 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)) + 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() + + 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 + return None + + async def _build_portfolio_summary(self, *, project_id: Optional[int]) -> dict[str, Any]: + 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]) -> dict[str, Any]: + 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]) -> list[dict[str, Any]]: + user_ids: list[int] = [] + if project_id is not None: + 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: + 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 [] + 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]) -> list[dict[str, Any]]: + user_ids: list[int] = [] + if project_id is not None: + 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 [] + 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]) -> Optional[dict[str, Any]]: + if project_id is None: + return None + 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]) -> list[dict[str, Any]]: + if project_id is None: + return [] + 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]) -> dict[str, Any]: + if project_id is None: + return {"total": 0, "by_status": {}, "overdue_count": 0, "uncovered_skill_tag_ids": [], "blocked_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[dict[str, Any]] = [] + 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 + skill_graph = await self._build_skill_graph(project_id=project_id) + 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]) -> list[dict[str, Any]]: + if project_id is None: + return [] + 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]) -> dict[str, Any]: + 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[dict[str, Any]], task_summary: dict[str, Any]) -> dict[str, Any]: + 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), + } + + async def _create_decision_record( + self, + *, + work_item: WorkItem, + world_model: dict[str, Any], + decision_payload: dict[str, Any], + ) -> DecisionRecord: + strategic = decision_payload["strategic"] + policy = decision_payload["policy"] + row = DecisionRecord( + tenant_id=self.tenant_id, + work_item_id=int(work_item.id), + decision_object_type=str(strategic.get("decision_object_type") or "portfolio"), + decision_object_id=_safe_int(strategic.get("decision_object_id")), + decision_level=str(strategic.get("decision_level") or "portfolio"), + context_snapshot=world_model, + rationale=str(strategic.get("rationale") or ""), + confidence=0.82, + risk_score=float(strategic.get("risk_score") or 0.0), + budget_impact=strategic.get("budget_impact"), + resource_impact=strategic.get("resource_impact"), + simulation_result=decision_payload.get("simulation"), + policy_gate_hit=bool(policy.get("policy_gate_hit")), + policy_gate_code=(str(policy.get("policy_gate_code") or "")[:80] or None), + action_bundle=decision_payload["execution_plan"], + approval_requirement=policy.get("approval_requirement"), + final_outcome=STATE_DECIDED, + ) + self.db.add(row) + await self.db.flush() + return row + + async def _create_plan_bundle( + self, + *, + work_item_id: int, + plan_type: str, + summary: str, + payload: dict[str, Any], + ) -> PlanBundle: + row = PlanBundle( + tenant_id=self.tenant_id, + work_item_id=int(work_item_id), + plan_type=plan_type[:20], + summary=summary[:2000], + payload=payload, + ) + self.db.add(row) + await self.db.flush() + return row + + async def _claim_reply_receipt( + self, + *, + work_item_id: int, + source_system: str, + source_message_id: str, + receipt_phase: str, + template_key: str, + payload: dict[str, Any], + ) -> bool: + row = ReplyReceipt( + tenant_id=self.tenant_id, + work_item_id=int(work_item_id), + source_system=source_system[:32], + source_message_id=source_message_id[:128], + receipt_phase=receipt_phase[:32], + template_key=template_key[:64], + payload=payload, + status="ready", + ) + self.db.add(row) + try: + await self.db.flush() + return True + except IntegrityError: + await self.db.rollback() + existing_ingest = ( + await self.db.execute( + select(IngestEvent).where( + and_( + IngestEvent.tenant_id == self.tenant_id, + IngestEvent.channel == f"{source_system}_message", + IngestEvent.event_key == source_message_id, + ) + ).limit(1) + ) + ).scalar_one() + existing_work_item = ( + await self.db.execute( + select(WorkItem).where( + and_( + WorkItem.tenant_id == self.tenant_id, + WorkItem.ingest_event_id == int(existing_ingest.id), + ) + ).limit(1) + ) + ).scalar_one_or_none() + if existing_work_item is not None: + self.db.add(existing_work_item) + return False + + async def _project_waiting_manual( + self, + *, + work_item: WorkItem, + message_text: str, + render: dict[str, Any], + intent: CanonicalIntent, + ) -> None: + await NotificationService(self.db).create_notifications( + tenant_id=self.tenant_id, + user_ids=await NotificationService(self.db)._resolve_recipients(tenant_id=self.tenant_id), + title="AI PMO 待人工确认", + content=str(render.get("text") or message_text)[:4000], + category="decision", + source="ai_pmo", + entity_type="work_item", + entity_id=int(work_item.id), + target_url=f"/inbox?workItemId={int(work_item.id)}", + kind="action_required", + priority="high", + status="pending_manual", + recommended_action="view_detail", + dedupe_key=f"ai-pmo-waiting:{int(work_item.id)}", + payload={ + "work_item_id": int(work_item.id), + "intent": intent.name, + "project_id": intent.project_id, + "message_text": message_text[:500], + }, + commit=False, + ) + + async def _project_success(self, *, intent: CanonicalIntent, execution_result: dict[str, Any]) -> None: + if intent.name == INTENT_SIGNAL_CREATE and _safe_int(execution_result.get("signal_id")) is not None: + await NotificationService(self.db).notify_signal_created( + tenant_id=self.tenant_id, + signal_id=int(execution_result["signal_id"]), + signal_content=str(execution_result.get("summary") or execution_result.get("signal_id") or ""), + source="ai_pmo", + target_url=( + f"/projects/{int(execution_result['project_id'])}/board" + if _safe_int(execution_result.get("project_id")) is not None + else None + ), + ) + + async def _execute_followup_intent( + self, + *, + work_item: WorkItem, + intent: CanonicalIntent, + message_text: str, + sender: str, + operator_user_id: Optional[int], + source_system: str, + source_message_id: str, + channel_ref: Optional[str], + source_sender_open_id: Optional[str], + world_model: dict[str, Any], + ) -> tuple[dict[str, Any], dict[str, Any]]: + explicit_target_id = self._extract_followup_work_item_id(message_text) + candidates = await self._load_recent_followup_candidates( + current_work_item_id=int(work_item.id), + source_system=source_system, + channel_ref=channel_ref, + sender=sender, + source_sender_open_id=source_sender_open_id, + explicit_target_id=explicit_target_id, + ) + actionable = [ + (target, ingest) + for target, ingest in candidates + if str(target.state or "") == STATE_WAITING_MANUAL + and str(target.intent or "") in FOLLOWUP_TARGET_INTENTS + ] + if explicit_target_id is not None and not actionable: + matched = next((target for target, _ in candidates if int(target.id) == int(explicit_target_id)), None) + if matched is not None: + reply_text = f"工作项#{int(matched.id)} 当前状态为 {matched.state},无需继续处理。" + return { + "status": "succeeded", + "followup_reply": reply_text, + "target_work_item_id": int(matched.id), + "target_state": matched.state, + "target_intent": matched.intent, + "project_id": _safe_int(matched.project_id), + "signal_id": _safe_int(matched.signal_id), + }, {"verified": True, "issues": []} + + if not actionable: + reply_text = "当前群最近没有可继续处理的待确认工作项。" + return { + "status": "pending_manual", + "followup_reply": reply_text, + "candidate_work_item_ids": [], + }, {"verified": False, "issues": ["waiting_manual"]} + + if len(actionable) > 1: + reply_text = self._build_followup_candidate_clarification([target for target, _ in actionable]) + return { + "status": "pending_manual", + "followup_reply": reply_text, + "candidate_work_item_ids": [int(target.id) for target, _ in actionable[:5]], + }, {"verified": False, "issues": ["waiting_manual", "followup_ambiguous"]} + + target_work_item, target_ingest = actionable[0] + return await self._resume_followup_target( + target_work_item=target_work_item, + target_ingest=target_ingest, + followup_intent=intent, + followup_sender=sender, + operator_user_id=operator_user_id, + source_system=source_system, + source_message_id=source_message_id, + world_model=world_model, + ) + + async def _load_recent_followup_candidates( + self, + *, + current_work_item_id: int, + source_system: str, + channel_ref: Optional[str], + sender: str, + source_sender_open_id: Optional[str], + explicit_target_id: Optional[int], + limit: int = 12, + ) -> list[tuple[WorkItem, IngestEvent]]: + rows = ( + await self.db.execute( + select(WorkItem, IngestEvent) + .join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id) + .where( + and_( + WorkItem.tenant_id == self.tenant_id, + WorkItem.id != int(current_work_item_id), + ) + ) + .order_by(WorkItem.id.desc()) + .limit(max(limit * 3, 18)) + ) + ).all() + selected: list[tuple[WorkItem, IngestEvent]] = [] + normalized_channel = str(channel_ref or "").strip() + normalized_sender = str(sender or "").strip() + normalized_open_id = str(source_sender_open_id or "").strip() + for target_work_item, ingest in rows: + if explicit_target_id is not None and int(target_work_item.id) == int(explicit_target_id): + selected.append((target_work_item, ingest)) + continue + payload = ingest.payload if isinstance(ingest.payload, dict) else {} + same_channel = normalized_channel and str(payload.get("channel_ref") or "").strip() == normalized_channel + same_open_id = normalized_open_id and str(payload.get("source_sender_open_id") or "").strip() == normalized_open_id + same_sender = normalized_sender and str(payload.get("sender") or "").strip() == normalized_sender + if not (same_channel or same_open_id or same_sender): + continue + selected.append((target_work_item, ingest)) + if len(selected) >= limit: + break + return selected + + async def _resume_followup_target( + self, + *, + target_work_item: WorkItem, + target_ingest: IngestEvent, + followup_intent: CanonicalIntent, + followup_sender: str, + operator_user_id: Optional[int], + source_system: str, + source_message_id: str, + world_model: dict[str, Any], + ) -> tuple[dict[str, Any], dict[str, Any]]: + payload = target_ingest.payload if isinstance(target_ingest.payload, dict) else {} + original_message = str(payload.get("message_text") or "").strip() + override_project_id = _safe_int(world_model.get("resolved_project_id")) + target_project_id = _safe_int(target_work_item.project_id) or override_project_id + rebuilt_intent = self.intent_resolver.resolve(text=original_message, project_id=target_project_id) + target_intent_name = str(target_work_item.intent or rebuilt_intent.name or "").strip() + target_intent = CanonicalIntent( + name=target_intent_name or rebuilt_intent.name, + confidence=float(rebuilt_intent.confidence or 0.88), + project_id=target_project_id, + signal_title=rebuilt_intent.signal_title, + signal_summary=rebuilt_intent.signal_summary, + metadata=dict(rebuilt_intent.metadata), + ) + if target_intent.name not in FOLLOWUP_TARGET_INTENTS: + reply_text = f"工作项#{int(target_work_item.id)} 当前类型为 {target_intent.name or 'unknown'},暂不支持继续处理。" + return { + "status": "failed_safe", + "followup_reply": reply_text, + "target_work_item_id": int(target_work_item.id), + "target_state": target_work_item.state, + "target_intent": target_intent.name, + }, {"verified": False, "issues": ["unsupported_intent"]} + if target_intent.name in {INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD} and target_project_id is None: + reply_text = "未执行:目标工作项仍缺少项目归属,请补充项目 ID 后再继续处理。" + return { + "status": "pending_manual", + "followup_reply": reply_text, + "target_work_item_id": int(target_work_item.id), + "target_state": STATE_WAITING_MANUAL, + "target_intent": target_intent.name, + }, {"verified": False, "issues": ["waiting_manual"]} + + target_work_item.project_id = target_project_id + target_work_item.state = STATE_EXECUTING + target_execution_result = await self.executor.execute( + intent=target_intent, + message_text=original_message, + sender=followup_sender, + operator_user_id=operator_user_id, + source_system=source_system, + source_message_id=f"{source_message_id}:resume:{int(target_work_item.id)}", + ) + target_work_item.execution_result = target_execution_result + if _safe_int(target_execution_result.get("signal_id")) is not None: + target_work_item.signal_id = _safe_int(target_execution_result.get("signal_id")) + + target_verification = await self._verify_execution( + intent=target_intent, + execution_result=target_execution_result, + world_model=target_work_item.context_snapshot if isinstance(target_work_item.context_snapshot, dict) else world_model, + ) + target_work_item.review_summary = await self.review_service.review_work_item( + work_item=target_work_item, + intent_name=target_intent.name, + execution_result=target_execution_result, + world_model=target_work_item.context_snapshot if isinstance(target_work_item.context_snapshot, dict) else world_model, + verification=target_verification, + simulation={ + "followup_resumed": True, + "target_work_item_id": int(target_work_item.id), + "followup_intent": followup_intent.name, + }, + ) + await self._maybe_emit_risk_cadence_snapshot( + project_id=_safe_int(target_work_item.project_id), + review_summary=target_work_item.review_summary, + ) + if target_verification.get("verified"): + target_work_item.state = STATE_SUCCEEDED + await self._project_success(intent=target_intent, execution_result=target_execution_result) + else: + target_issues = target_verification.get("issues") if isinstance(target_verification.get("issues"), list) else [] + if any(issue in {"pending_manual", "signal_source_pending_manual"} for issue in target_issues): + target_work_item.state = STATE_WAITING_MANUAL + else: + target_work_item.state = STATE_FAILED + + target_decision = ( + await self.db.execute( + select(DecisionRecord) + .where( + and_( + DecisionRecord.tenant_id == self.tenant_id, + DecisionRecord.work_item_id == int(target_work_item.id), + ) + ) + .order_by(DecisionRecord.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if target_decision is not None: + target_decision.execution_result = target_execution_result + target_decision.final_outcome = target_work_item.state + + target_render = self.reply_renderer.render( + work_state=target_work_item.state, + intent=target_intent, + decision_record={}, + execution_result=target_execution_result, + ) + reply_text = str(target_render.get("text") or "").strip() + if target_work_item.state == STATE_SUCCEEDED: + reply_text = f"已继续处理:{reply_text}" + elif not reply_text: + reply_text = "未执行:继续处理后仍未形成可验证终态。" + project_id = _safe_int(target_execution_result.get("project_id")) or _safe_int(target_work_item.project_id) + signal_id = _safe_int(target_execution_result.get("signal_id")) or _safe_int(target_work_item.signal_id) + status = ( + "succeeded" + if target_work_item.state == STATE_SUCCEEDED + else ("pending_manual" if target_work_item.state == STATE_WAITING_MANUAL else "failed_safe") + ) + current_verification = {"verified": target_work_item.state == STATE_SUCCEEDED, "issues": []} + if target_work_item.state == STATE_WAITING_MANUAL: + current_verification = {"verified": False, "issues": ["waiting_manual"]} + elif target_work_item.state == STATE_FAILED: + current_verification = {"verified": False, "issues": ["executor_failed"]} + return { + "status": status, + "followup_reply": reply_text, + "target_work_item_id": int(target_work_item.id), + "target_state": target_work_item.state, + "target_intent": target_intent.name, + "project_id": project_id, + "signal_id": signal_id, + "auto_tasks": target_execution_result.get("auto_tasks") if isinstance(target_execution_result.get("auto_tasks"), list) else [], + }, current_verification + + async def _verify_execution( + self, + *, + intent: CanonicalIntent, + execution_result: dict[str, Any], + world_model: dict[str, Any], + ) -> dict[str, Any]: + if str(execution_result.get("status") or "").lower() == "failed_safe": + return {"verified": False, "issues": ["executor_failed"]} + if str(execution_result.get("status") or "").lower() == "pending_manual": + return {"verified": False, "issues": ["pending_manual"]} + if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: + return { + "verified": bool( + _safe_int(execution_result.get("target_work_item_id")) is not None + and str(execution_result.get("target_state") or "").strip() == STATE_SUCCEEDED + ), + "issues": [] if str(execution_result.get("target_state") or "").strip() == STATE_SUCCEEDED else ["waiting_manual"], + } + if intent.name == INTENT_QUERY: + return {"verified": bool(str(execution_result.get("answer") or "").strip()), "issues": []} + if intent.name == INTENT_PROJECT_CREATE: + return {"verified": _safe_int(execution_result.get("project_id")) is not None, "issues": []} + if intent.name in {INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD}: + signal_id = _safe_int(execution_result.get("signal_id")) + if signal_id is None: + return {"verified": False, "issues": ["signal_missing"]} + if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: + workflow = execution_result.get("signal_source_workflow") if isinstance(execution_result.get("signal_source_workflow"), dict) else {} + if str(workflow.get("status") or "").lower() != "completed": + return {"verified": False, "issues": ["signal_source_pending_manual"]} + if intent.project_id is None: + return {"verified": True, "issues": []} + link_exists = ( + await self.db.execute( + select(ProjectSignalLink.id).where( + and_( + ProjectSignalLink.tenant_id == self.tenant_id, + ProjectSignalLink.project_id == int(intent.project_id), + ProjectSignalLink.signal_id == int(signal_id), + ) + ).limit(1) + ) + ).scalar_one_or_none() + return {"verified": link_exists is not None, "issues": [] if link_exists is not None else ["project_signal_link_missing"]} + return {"verified": False, "issues": ["unsupported_intent"]} + + @staticmethod + def _build_review_summary( + *, + intent: CanonicalIntent, + verification: dict[str, Any], + world_model: dict[str, Any], + ) -> dict[str, Any]: + return { + "verified": bool(verification.get("verified")), + "issues": verification.get("issues") if isinstance(verification.get("issues"), list) else [], + "next_step": "进入下一轮调度" if verification.get("verified") else "等待人工复核", + "risk_score_after": float((world_model.get("risk_summary") or {}).get("open_risk_count") or 0.0), + "intent": intent.name, + } + + async def _maybe_emit_risk_cadence_snapshot( + self, + *, + project_id: Optional[int], + review_summary: dict[str, Any], + ) -> None: + if not isinstance(review_summary, dict): + return + risk_after = float(review_summary.get("risk_score_after") or 0.0) + divergences = review_summary.get("divergences") if isinstance(review_summary.get("divergences"), list) else [] + if risk_after < 0.7 and not divergences: + return + await self.cadence_service.generate_snapshot( + cadence_type="risk_review", + project_id=int(project_id) if project_id is not None else None, + ) + + async def _resolve_operator_user(self, *, sender: str, sender_open_id: Optional[str]) -> Optional[User]: + cleaned_sender = str(sender or "").strip() + if cleaned_sender: + user = ( + await self.db.execute( + select(User).where( + and_( + User.tenant_id == self.tenant_id, + User.is_active.is_(True), + or_(User.name == cleaned_sender, User.username == cleaned_sender), + ) + ).limit(1) + ) + ).scalar_one_or_none() + if user is not None: + return user + preferred = ( + 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 preferred is not None: + return preferred + return ( + await self.db.execute( + select(User).where( + and_( + User.tenant_id == self.tenant_id, + User.is_active.is_(True), + ) + ).order_by(User.id.asc()).limit(1) + ) + ).scalar_one_or_none() + + async def _write_processing_log( + self, + *, + source_message_id: str, + work_item: WorkItem, + message_text: str, + route: str, + operation_code: Optional[str], + operation_result: Any, + processing_status: str, + duration_ms: int, + ) -> None: + self.db.add( + AIProcessingLog( + tenant_id=self.tenant_id, + feishu_message_id=source_message_id if work_item.source_system == "feishu" else None, + feishu_item_id=None, + raw_message=message_text[:8000], + ai_raw_output=json.dumps( + { + "work_item_id": int(work_item.id), + "intent": work_item.intent, + "state": work_item.state, + "reply_template_key": work_item.reply_template_key, + }, + ensure_ascii=False, + ), + project_score=None, + signal_confidence=None, + level="ai_pmo", + normalized_text=message_text[:8000], + route=route[:80], + intent=(str(work_item.intent or "")[:40] or None), + executor="ai_pmo_kernel", + operation_code=(operation_code[:80] if operation_code else None), + operation_result=operation_result if isinstance(operation_result, dict) else {"result": operation_result}, + reply_source=(str(work_item.reply_template_key or "")[:40] or None), + routing_decision=route[:64], + routing_reason=f"AI PMO kernel route={route}", + reasoning_bullets=[ + f"work_item_id={int(work_item.id)}", + f"state={work_item.state}", + f"intent={work_item.intent}", + ], + processing_status=processing_status[:20], + error_message=None, + duration_ms=int(duration_ms), + ) + ) + await self.db.flush() + + def _build_capture_result( + self, + *, + work_item: WorkItem, + intent: CanonicalIntent, + execution_result: dict[str, Any], + render: dict[str, Any], + direct_reply_required: bool, + status: str, + classification: str, + ) -> dict[str, Any]: + linked_project_ids = [int(work_item.project_id)] if work_item.project_id is not None else [] + result = { + "handled": True, + "status": status, + "classification": classification, + "linked_project_ids": linked_project_ids, + "signal_id": _safe_int(execution_result.get("signal_id")) or _safe_int(work_item.signal_id), + "created_project_id": _safe_int(execution_result.get("project_id")) if intent.name == INTENT_PROJECT_CREATE else None, + "auto_tasks": execution_result.get("auto_tasks") if isinstance(execution_result.get("auto_tasks"), list) else execution_result.get("tasks", []), + "ai_trace_id": f"ai-pmo-{int(work_item.id)}", + "results": [ + { + "work_item_id": int(work_item.id), + "state": work_item.state, + "intent": intent.name, + "route_status": "queued_for_decision" if work_item.state == STATE_WAITING_MANUAL else "auto_done", + } + ], + "intent": intent.name, + "direct_reply_required": bool(direct_reply_required), + "direct_reply_text": str(render.get("text") or "").strip(), + "reply_template_key": str(render.get("template_key") or "").strip(), + "work_item_id": int(work_item.id), + "decision_level": work_item.decision_level, + "review_summary": work_item.review_summary, + } + if intent.name == INTENT_QUERY and isinstance(execution_result.get("rag_result"), dict): + result["rag_result"] = execution_result["rag_result"] + if intent.name == INTENT_PROJECT_CREATE: + raw_payload = execution_result.get("raw_payload") if isinstance(execution_result.get("raw_payload"), dict) else {} + result["project"] = raw_payload.get("project") + if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: + result["signal_source_workflow"] = execution_result.get("signal_source_workflow") + result["signal_source_orchestration"] = execution_result.get("signal_source_orchestration") + result["ai_execution"] = execution_result.get("signal_source_orchestration") + return result + + @staticmethod + def _primary_operation_code(intent_name: str) -> Optional[str]: + if intent_name == INTENT_QUERY: + return "query.answer" + if intent_name == INTENT_PROJECT_CREATE: + return "project.create" + if intent_name == INTENT_SIGNAL_CREATE: + return "signal.enter" + if intent_name == INTENT_SIGNAL_SOURCE_ONBOARD: + return "signal_source.onboard" + if intent_name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: + return "work_item.resume" + return None + + @staticmethod + def _extract_followup_work_item_id(message: str) -> Optional[int]: + match = FOLLOWUP_WORK_ITEM_ID_RE.search(str(message or "")) + return int(match.group(1)) if match else None + + @staticmethod + def _build_followup_candidate_clarification(items: list[WorkItem]) -> str: + if not items: + return "当前没有可继续处理的待确认工作项。" + labels = [f"工作项#{int(item.id)}({str(item.intent or '').strip() or 'unknown'})" for item in items[:3]] + return f"当前存在多个待确认工作项:{'、'.join(labels)}。请补充工作项 ID 后再继续处理。" + + @staticmethod + def _build_source_message_id( + *, + source_system: str, + source_message_id: Optional[str], + channel_ref: Optional[str], + sender: str, + message_text: str, + ) -> str: + explicit = str(source_message_id or "").strip() + if explicit: + return explicit[:128] + basis = f"{source_system}|{channel_ref or ''}|{sender}|{message_text}" + digest = hashlib.sha1(basis.encode("utf-8"), usedforsecurity=False).hexdigest() + return f"{source_system}-{digest[:32]}" + + @staticmethod + def _normalize_text(text: str) -> str: + cleaned = re.sub(r"]*>", " ", str(text or ""), flags=re.IGNORECASE) + cleaned = re.sub(r"^@PMFlow\s*", "", cleaned, flags=re.IGNORECASE) + cleaned = re.sub(r"\s+", " ", cleaned).strip() + return cleaned[:4000] + + @staticmethod + def _build_transport_mode_reply(message_text: str) -> Optional[str]: + normalized = re.sub(r"\s+", "", str(message_text or "")).lower() + has_long_connection = "长连接" in normalized or "longconnection" in normalized + has_webhook = "webhook" in normalized + if not (has_long_connection and has_webhook): + return None + if not any(token in normalized for token in TRANSPORT_MODE_CORRECTION_HINTS): + return None + return "已按长连接模式处理飞书接入,当前不会走 webhook;这条说明不会再被当成信号源类型变更。" + + @staticmethod + def _serialize_project(project: Optional[Project]) -> Optional[dict[str, Any]]: + if project is None: + return None + return { + "id": int(project.id), + "name": str(project.name or ""), + "title": str(project.title or project.name or ""), + "description": str(project.description or ""), + "status": str(project.status or ""), + "owner_id": _safe_int(project.owner_id), + } diff --git a/backend/app/services/ai_pmo_review_service.py b/backend/app/services/ai_pmo_review_service.py new file mode 100644 index 00000000..40ca3396 --- /dev/null +++ b/backend/app/services/ai_pmo_review_service.py @@ -0,0 +1,189 @@ +"""AI PMO review engine。""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import PortfolioSnapshot, ProjectSignalLink, ReplyReceipt, SignalSourceRegistry, WorkItem + + +def _safe_int(value: Any) -> int | None: + try: + if value is None or value == "": + return None + return int(value) + except (TypeError, ValueError): + return None + + +class AIPMOReviewService: + """复盘层:结果核验、偏差检测、风险再评分、规则建议。""" + + def __init__(self, db: AsyncSession, tenant_id: int) -> None: + self.db = db + self.tenant_id = int(tenant_id) + + async def review_work_item( + self, + *, + work_item: WorkItem, + intent_name: str, + execution_result: dict[str, Any], + world_model: dict[str, Any], + verification: dict[str, Any], + simulation: dict[str, Any], + ) -> dict[str, Any]: + divergences: list[dict[str, Any]] = [] + rule_updates: list[dict[str, Any]] = [] + + signal_id = _safe_int(execution_result.get("signal_id")) or _safe_int(work_item.signal_id) + project_id = _safe_int(work_item.project_id) + if signal_id is not None and project_id is not None: + link_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 link_exists is None: + divergences.append( + { + "code": "project_signal_link_missing", + "severity": "high", + "detail": f"signal#{signal_id} 未关联 project#{project_id}", + } + ) + rule_updates.append( + { + "rule_key": "signal_link_persistence_guard", + "reason": "信号已写入但 link 缺失,后续查询会出现空白板。", + } + ) + + workflow = execution_result.get("signal_source_workflow") if isinstance(execution_result.get("signal_source_workflow"), dict) else {} + raw_orchestration = execution_result.get("signal_source_orchestration") if isinstance(execution_result.get("signal_source_orchestration"), dict) else {} + registry_id = _safe_int(workflow.get("registry_id")) + if registry_id is not None: + registry = ( + await self.db.execute( + select(SignalSourceRegistry).where( + and_( + SignalSourceRegistry.tenant_id == self.tenant_id, + SignalSourceRegistry.id == int(registry_id), + ) + ).limit(1) + ) + ).scalar_one_or_none() + if registry is not None: + normalized_status = str(workflow.get("status") or "").strip().lower() + registry_onboarding = str(registry.onboarding_status or "").strip().lower() + if normalized_status == "pending_manual" and registry_onboarding == "completed": + divergences.append( + { + "code": "registry_workflow_divergence", + "severity": "high", + "detail": f"registry#{registry_id} 已 completed,但 domain workflow 为 pending_manual", + } + ) + rule_updates.append( + { + "rule_key": "signal_source_finalize_single_source", + "reason": "registry 与 workflow 终态不一致,必须收口到 domain workflow。", + } + ) + if workflow and str(workflow.get("status") or "").strip().lower() != "completed": + rule_updates.append( + { + "rule_key": "onboarding_requires_material_actions", + "reason": "signal source onboarding 只有 material actions + 非 fallback 才能标记 completed。", + } + ) + + duplicate_reply_groups = ( + await self.db.execute( + select(ReplyReceipt.receipt_phase, func.count(ReplyReceipt.id)) + .where( + and_( + ReplyReceipt.tenant_id == self.tenant_id, + ReplyReceipt.work_item_id == int(work_item.id), + ) + ) + .group_by(ReplyReceipt.receipt_phase) + ) + ).all() + duplicate_phases = [str(phase) for phase, count in duplicate_reply_groups if int(count or 0) > 1] + if duplicate_phases: + divergences.append( + { + "code": "duplicate_reply_receipts", + "severity": "medium", + "detail": ",".join(duplicate_phases), + } + ) + rule_updates.append( + { + "rule_key": "reply_receipt_single_phase", + "reason": "同一 source_message 不得生成多条同 phase 回执。", + } + ) + + issues = verification.get("issues") if isinstance(verification.get("issues"), list) else [] + manual_issue_codes = {"pending_manual", "signal_source_pending_manual", "waiting_manual"} + base_score = 0.92 if verification.get("verified") else (0.56 if any(issue in manual_issue_codes for issue in issues) else 0.32) + fallback_penalty = 0.15 if bool(workflow.get("fallback_used")) else 0.0 + divergence_penalty = min(0.45, len(divergences) * 0.12) + agent_quality_score = max(0.0, round(base_score - fallback_penalty - divergence_penalty, 4)) + + risk_before = float((world_model.get("risk_summary") or {}).get("open_risk_count") or 0.0) + projected_risk = float(simulation.get("projected_risk_score") or 0.0) + risk_after = min(1.0, projected_risk + len(divergences) * 0.08 + (0.1 if not verification.get("verified") else 0.0)) + + next_actions: list[str] = [] + if divergences: + next_actions.append("优先修正 projection / registry / reply 收口偏差。") + if not verification.get("verified"): + next_actions.append("当前工作项未形成可验证终态,进入风险例会或人工确认。") + if risk_after >= 0.7: + next_actions.append("风险重评分偏高,建议纳入风险例会升级。") + if not next_actions: + next_actions.append("复盘通过,进入下一轮节奏调度。") + + quality_grade = "A" if agent_quality_score >= 0.85 else ("B" if agent_quality_score >= 0.7 else ("C" if agent_quality_score >= 0.5 else "D")) + review_payload = { + "verified": bool(verification.get("verified")), + "issues": issues, + "divergences": divergences, + "risk_score_before": round(risk_before, 4), + "risk_score_after": round(risk_after, 4), + "agent_quality_score": agent_quality_score, + "agent_quality_grade": quality_grade, + "rule_updates": rule_updates, + "next_actions": next_actions, + "simulation_summary": simulation, + "workflow_summary": workflow or raw_orchestration, + } + self.db.add( + PortfolioSnapshot( + tenant_id=self.tenant_id, + scope_type="review_cycle", + scope_ref=f"work_item:{int(work_item.id)}", + summary={ + "work_item_id": int(work_item.id), + "intent": intent_name, + "verified": bool(verification.get("verified")), + "agent_quality_score": agent_quality_score, + "risk_score_after": round(risk_after, 4), + }, + payload=review_payload, + ) + ) + await self.db.flush() + return review_payload diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index 0fa58512..299aab13 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -15,11 +15,12 @@ import os import time from datetime import datetime from typing import Any, AsyncGenerator, Optional, Union +from sqlalchemy import and_, select # LangChain & OpenAI try: from langchain_openai import ChatOpenAI - from langchain_core.messages import HumanMessage, AIMessage + from langchain_core.messages import HumanMessage, AIMessage, SystemMessage from langchain_core.language_models.chat_models import BaseChatModel LANGCHAIN_AVAILABLE = True except ImportError: @@ -37,6 +38,11 @@ except ImportError: INPUT_PRICE_PER_1K = 0.00015 OUTPUT_PRICE_PER_1K = 0.0006 CACHE_TTL_SECONDS = 3600 # 1 小时 +PROMPT_PROFILE_CACHE_TTL_SECONDS = 60 + +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: @@ -77,6 +83,7 @@ class AIService: 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]]] = {} def _get_llm(self) -> BaseChatModel: if not LANGCHAIN_AVAILABLE: @@ -116,6 +123,84 @@ class AIService: 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, @@ -194,11 +279,18 @@ class AIService: 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() - resp = await llm.ainvoke([HumanMessage(content=prompt)]) + 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 {} diff --git a/backend/app/services/ai_steward.py b/backend/app/services/ai_steward.py index 10329c90..939eb742 100644 --- a/backend/app/services/ai_steward.py +++ b/backend/app/services/ai_steward.py @@ -1,31 +1,33 @@ -""" -AI Steward Service - 主动式 AI 管家核心服务 +"""AI Steward Service - 主动式 AI 管家核心服务。""" + +from __future__ import annotations -核心功能: -1. 零配置项目创建 - 从一句话生成完整 SSTC 链路 -2. 主动信号捕获 - 从多个源自动抓取并评估 -3. 认知冲突检测 - 自动识别并消解团队认知差异 -4. 阻塞自动解决 - 主动识别和解决开发阻塞 -5. 个性化进度简报 - 为每个角色生成定制化简报 -""" 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 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 ( - User, Project, Signal, TruthNode, Task, - ProjectDomain, CognitiveConflict, UserSkillBinding -) +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 .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__) +PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS = 8.0 class AIStewardService: - """主动式 AI 管家服务""" + """主动式 AI 管家服务。""" def __init__(self, db: AsyncSession): self.db = db @@ -35,95 +37,370 @@ class AIStewardService: self, input_text: str, creator_id: int, - tenant_id: int = 1 + tenant_id: int = 1, ) -> Dict[str, Any]: - """ - 零配置项目创建 - 从一句话生成完整 SSTC 链路 - - 输入示例: - "我们要做一个 AI 客服工具,基于 GPT-4o mini,对标 XX 竞品,3 周上线" - - 输出:完整的 SSTC 链路 - - 竞品信号分析 - - 宪章(TruthNode) - - 任务拆解(Task) - - 依赖关系 - - 风险预警 - """ - # Step 0: 获取下一个可用的项目 ID(SQLite 兼容) - from sqlalchemy import text - next_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM projects"))).scalar() or 1 - - # Step 1: AI 解析用户输入,提取关键信息 + """零配置项目创建,统一走 draft -> normalize -> persist 三段式。""" analysis = await self._analyze_project_input(input_text, tenant_id) - - # Step 2: 自动生成项目 - project = Project( - id=next_id, - name=analysis.get('project_name', '未命名项目')[:50], - title=analysis.get('project_title', input_text[:50]), - description=input_text, - owner_id=creator_id, - status='active', - domain_id=analysis.get('domain_id'), - assigned_role=analysis.get('primary_role', 'backend'), - signal_count=0, - tenant_id=tenant_id + draft = await self._generate_project_creation_draft( + input_text=input_text, + analysis=analysis, + tenant_id=tenant_id, ) - self.db.add(project) - await self.db.flush() - - # Step 3: 生成信号(竞品分析) - signal = await self._generate_competitive_signal( - project.id, - analysis, - tenant_id + normalized = await self._normalize_project_creation_draft( + draft=draft, + input_text=input_text, + creator_id=creator_id, + tenant_id=tenant_id, ) - - # Step 4: 生成宪章(核心真理节点) - charter = await self._generate_charter( - project.id, - analysis, - creator_id, - tenant_id - ) - charter_id = int(charter['id']) # Convert string ID to int - - # Step 5: 生成任务链 - tasks = await self._generate_task_chain( - project.id, - charter_id, - analysis, - tenant_id - ) - - # Step 6: 生成风险预警规则 - risks = await self._generate_risk_alert_rules( - project.id, - charter_id, - analysis, - 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]: + 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 { - 'project': { - 'id': str(project.id), - 'name': project.name, - 'title': project.title, - 'description': project.description, - 'status': project.status + "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, - 'charter': charter, - 'tasks': tasks, - 'risks': risks, - 'analysis': analysis + "signal": signal_draft, + "charter": charter_draft, + "tasks": task_drafts, + "risks": self._build_risk_alert_drafts(analysis=analysis), + } + + async def _generate_project_creation_text( + self, + *, + prompt: str, + tenant_id: int, + function_name: str, + timeout_seconds: float = PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS, + ) -> str: + try: + return await asyncio.wait_for( + self.llm.generate( + prompt, + db=self.db, + tenant_id=tenant_id, + function_name=function_name, + ), + timeout=timeout_seconds, + ) + except Exception as exc: + logger.warning( + "ai_steward_project_creation_step_fallback", + tenant_id=int(tenant_id), + function_name=function_name, + error=str(exc), + ) + return "" + + 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, + } + ) + + normalized_project = { + "name": str(project_draft.get("name") or "未命名项目").strip()[:50] or "未命名项目", + "title": str(project_draft.get("title") or input_text[:50] or "未命名项目").strip()[:50] or "未命名项目", + "description": str(project_draft.get("description") or input_text).strip()[:4000] or input_text[:4000], + "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 分析项目输入,提取关键信息""" + """使用 AI 分析项目输入,提取关键信息。""" prompt = f"""分析以下项目需求,提取关键信息并以 JSON 格式返回: 用户输入:{input_text} @@ -144,51 +421,42 @@ class AIStewardService: 只返回 JSON,不要其他内容。""" - response = await self.llm.generate( - prompt, - db=self.db, + response = await self._generate_project_creation_text( + prompt=prompt, tenant_id=tenant_id, + function_name="project_from_text_analysis", ) try: - # 清理可能的 markdown 代码块标记 response = response.strip() - if response.startswith('```'): - response = response.split('```')[1] - if response.startswith('json'): + if response.startswith("```"): + response = response.split("```")[1] + if response.startswith("json"): response = response[4:] analysis = json.loads(response.strip()) - except: - # 降级处理 + except Exception: analysis = { - 'project_name': input_text[:20], - 'project_title': input_text[:50], - 'tech_stack': ['GPT-4o-mini'], - 'target_audience': '通用', - 'timeline': '3周', - 'primary_role': 'ai_engineer', - 'domain_id': 1, - 'key_features': [], - 'competitors': [], - 'success_metrics': [] + "project_name": input_text[:20], + "project_title": input_text[:50], + "tech_stack": ["GPT-4o-mini"], + "target_audience": "通用", + "timeline": "3周", + "primary_role": "ai_engineer", + "domain_id": 1, + "key_features": [], + "competitors": [], + "success_metrics": [], } - return analysis - async def _generate_competitive_signal( + async def _generate_competitive_signal_draft( self, - project_id: int, + *, analysis: Dict[str, Any], - tenant_id: int + tenant_id: int, ) -> Dict[str, Any]: - """生成竞品分析信号""" - from sqlalchemy import text - - # 获取下一个可用的 signal ID - next_signal_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM signals"))).scalar() or 1 - - competitors = analysis.get('competitors', []) + competitors = analysis.get("competitors", []) if not competitors: - competitors = ['行业同类产品'] + competitors = ["行业同类产品"] prompt = f"""基于以下项目信息,生成竞品分析信号: @@ -209,70 +477,32 @@ class AIStewardService: "strategic_analysis": {{"is_strategic": true, "threat_level": "MEDIUM"}} }}""" - response = await self.llm.generate( - prompt, - db=self.db, + response = await self._generate_project_creation_text( + prompt=prompt, tenant_id=tenant_id, - project_id=project_id, + function_name="project_from_text_signal", ) try: response = response.strip() - if response.startswith('```'): - response = response.split('```')[1] - if response.startswith('json'): + if response.startswith("```"): + response = response.split("```")[1] + if response.startswith("json"): response = response[4:] - signal_data = json.loads(response.strip()) - except: - signal_data = { - 'content': f'竞品分析:{", ".join(competitors)} 的技术现状和差异化机会', - 'impact_score': 0.8, - 'priority': 'high', - 'strategic_analysis': {'is_strategic': True} + return json.loads(response.strip()) + except Exception: + return { + "content": f"竞品分析:{', '.join(competitors)} 的技术现状和差异化机会", + "impact_score": 0.8, + "priority": "high", + "strategic_analysis": {"is_strategic": True}, } - signal = Signal( - id=next_signal_id, - source='AI-Steward', - content=signal_data['content'], - impact_score=signal_data.get('impact_score', 0.8), - priority=signal_data.get('priority', 'high'), - processed=True, - strategic_analysis=json.dumps(signal_data.get('strategic_analysis', {})), - tasks_generated=json.dumps([]), - tenant_id=tenant_id - ) - self.db.add(signal) - await self.db.flush() - - # 创建信号与项目的关联 - from ..models import ProjectSignalLink - next_link_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM project_signal_links"))).scalar() or 1 - - link = ProjectSignalLink( - id=next_link_id, - project_id=project_id, - signal_id=signal.id, - link_type='competitive_analysis', - relevance_score=0.9, - tenant_id=tenant_id - ) - self.db.add(link) - await self.db.flush() - - return { - 'id': str(signal.id), - 'content': signal.content, - 'impact_score': signal.impact_score - } - - async def _generate_charter( + async def _generate_charter_draft( self, - project_id: int, + *, analysis: Dict[str, Any], - creator_id: int, - tenant_id: int + tenant_id: int, ) -> Dict[str, Any]: - """生成项目宪章(核心真理节点)""" prompt = f"""基于以下项目信息,生成项目宪章: 项目:{analysis.get('project_title')} @@ -295,70 +525,47 @@ class AIStewardService: "constraints": ["约束1", "约束2"] }}""" - response = await self.llm.generate( - prompt, - db=self.db, + response = await self._generate_project_creation_text( + prompt=prompt, tenant_id=tenant_id, - project_id=project_id, + function_name="project_from_text_charter", ) try: response = response.strip() - if response.startswith('```'): - response = response.split('```')[1] - if response.startswith('json'): + if response.startswith("```"): + response = response.split("```")[1] + if response.startswith("json"): response = response[4:] charter_data = json.loads(response.strip()) - except: + except Exception: charter_data = { - 'title': f'{analysis.get("project_name")}项目宪章', - 'content': f'项目目标:{analysis.get("project_title")}\n成功标准:按时上线', - 'success_criteria': ['按时交付', '功能完整'], - 'constraints': ['时间限制'] + "title": f"{analysis.get('project_name')}项目宪章", + "content": f"项目目标:{analysis.get('project_title')}\n成功标准:按时上线", + "success_criteria": ["按时交付", "功能完整"], + "constraints": ["时间限制"], } - raw_content = charter_data.get('content', '') + raw_content = charter_data.get("content", "") if isinstance(raw_content, dict): - charter_content = dict(raw_content) + content = dict(raw_content) else: - charter_content = { - 'rationale': str(raw_content), - 'success_criteria': charter_data.get('success_criteria', []), - 'constraints': charter_data.get('constraints', []), - 'signal_ids': [], + content = { + "rationale": str(raw_content), + "success_criteria": charter_data.get("success_criteria", []), + "constraints": charter_data.get("constraints", []), + "signal_ids": [], } - - # 获取下一个可用的 truth_node ID - from sqlalchemy import text - next_charter_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM truth_nodes"))).scalar() or 1 - - charter = TruthNode( - id=next_charter_id, - node_type='charter', - project_id=project_id, - created_by=creator_id, - title=charter_data.get('title', '项目宪章'), - content=charter_content, - version=1, - status='active', - tenant_id=tenant_id - ) - self.db.add(charter) - await self.db.flush() - return { - 'id': str(charter.id), - 'title': charter.title, - 'content': charter_content + "title": charter_data.get("title", "项目宪章"), + "content": content, } - async def _generate_task_chain( + async def _generate_task_chain_draft( self, - project_id: int, - charter_id: int, + *, analysis: Dict[str, Any], - tenant_id: int + tenant_id: int, ) -> List[Dict[str, Any]]: - """生成任务链""" prompt = f"""基于以下信息,生成完整的任务链: 项目:{analysis.get('project_title')} @@ -383,126 +590,67 @@ class AIStewardService: ] }}""" - response = await self.llm.generate( - prompt, - db=self.db, + response = await self._generate_project_creation_text( + prompt=prompt, tenant_id=tenant_id, - project_id=project_id, + function_name="project_from_text_task_chain", ) try: response = response.strip() - if response.startswith('```'): - response = response.split('```')[1] - if response.startswith('json'): + if response.startswith("```"): + response = response.split("```")[1] + if response.startswith("json"): response = response[4:] tasks_data = json.loads(response.strip()) - tasks_list = tasks_data.get('tasks', []) - except: - # 降级生成基础任务 - tasks_list = [ - { - 'title': '技术方案设计', - 'type': analysis.get('primary_role', 'backend'), - 'estimated_hours': 16, - 'description': '完成项目技术方案设计', - 'priority': 'high', - 'required_skills': ['系统设计'] - }, - { - 'title': '核心功能开发', - 'type': analysis.get('primary_role', 'backend'), - 'estimated_hours': 40, - 'description': '完成核心功能开发', - 'priority': 'high', - 'required_skills': ['开发'] - } - ] - - tasks = [] - from sqlalchemy import text - next_task_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM tasks"))).scalar() or 1 - - for i, task_data in enumerate(tasks_list): - task = Task( - id=next_task_id, - truth_node_id=charter_id, - project_id=project_id, - assignee_id=None, # AI 推荐稍后分配 - title=task_data.get('title'), - description=task_data.get('description', ''), - required_skill_tag_ids=task_data.get('required_skills', []), # TypeDecorator 会自动处理 - status='pending', - priority=task_data.get('priority', 'medium').upper(), - estimated_hours=task_data.get('estimated_hours', 8), - actual_hours=0, - quality_score=0, - tenant_id=tenant_id - ) - self.db.add(task) - await self.db.flush() - next_task_id += 1 - - tasks.append({ - 'id': str(task.id), - 'title': task.title, - 'type': task_data.get('type'), - 'estimated_hours': task.estimated_hours, - 'priority': task.priority - }) - - return tasks - - async def _generate_risk_alert_rules( - self, - project_id: int, - charter_id: int, - analysis: Dict[str, Any], - tenant_id: int - ) -> List[Dict[str, Any]]: - """生成风险预警规则""" - risks = [ + tasks_list = tasks_data.get("tasks", []) + if isinstance(tasks_list, list) and tasks_list: + return tasks_list + except Exception: + pass + return [ { - 'type': 'schedule_delay', - 'description': '任务超时预警', - 'threshold_hours': analysis.get('estimated_hours', 40) * 1.2, - 'severity': 'medium' + "title": "技术方案设计", + "type": analysis.get("primary_role", "backend"), + "estimated_hours": 16, + "description": "完成项目技术方案设计", + "priority": "high", + "required_skills": ["系统设计"], }, { - 'type': 'quality_drop', - 'description': '质量下降预警', - 'threshold': 0.85, - 'severity': 'high' + "title": "核心功能开发", + "type": analysis.get("primary_role", "backend"), + "estimated_hours": 40, + "description": "完成核心功能开发", + "priority": "high", + "required_skills": ["开发"], }, - { - 'type': 'skill_mismatch', - 'description': '技能匹配度不足', - 'threshold': 0.6, - 'severity': 'medium' - } ] - # 获取下一个可用的 cognitive_conflict ID - from sqlalchemy import text - next_conflict_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM cognitive_conflicts"))).scalar() or 1 - - # 创建认知冲突记录作为风险预警 - for risk in risks: - conflict = CognitiveConflict( - id=next_conflict_id, - truth_node_id=charter_id, # 关联到宪章 - conflict_type=risk['type'], - description=risk['description'], - involved_user_ids=json.dumps([]), - status='active', - tenant_id=tenant_id - ) - self.db.add(conflict) - next_conflict_id += 1 - - return risks + @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 服务实例""" + """获取 AI Steward 服务实例。""" return AIStewardService(db) diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index e4132c54..fe4a0492 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -30,6 +30,7 @@ except ImportError: SECRET_KEY = os.getenv("SECRET_KEY", "neural-sync-secret-key-change-in-production") ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_HOURS = 24 +MAX_BCRYPT_PASSWORD_BYTES = 72 # Password hashing context (if passlib available) if HAS_PASSLIB: @@ -39,23 +40,53 @@ if HAS_PASSLIB: class AuthService: """Service for authentication operations""" + @staticmethod + def password_utf8_bytes(password: str) -> int: + return len(str(password or "").encode("utf-8")) + + @staticmethod + def validate_password(password: str) -> None: + if len(str(password or "")) < 6: + raise ValueError("密码至少需要 6 个字符") + if AuthService.password_utf8_bytes(password) > MAX_BCRYPT_PASSWORD_BYTES: + raise ValueError("密码最长支持 72 个 UTF-8 字节,请缩短后重试") + @staticmethod def verify_password(plain_password: str, hashed_password: str) -> bool: """Verify a plain password against a hashed password""" if HAS_PASSLIB: - return pwd_context.verify(plain_password, hashed_password) - else: - # Simple SHA256 fallback for development + try: + # 优先走 bcrypt(新数据) + if pwd_context.verify(plain_password, hashed_password): + return True + except Exception: + # 旧库可能是历史 SHA256 哈希,回退兼容 + pass + return hmac.compare_digest( AuthService._simple_hash(plain_password), - hashed_password + hashed_password, ) + # Simple SHA256 fallback for development + return hmac.compare_digest( + AuthService._simple_hash(plain_password), + hashed_password + ) + @staticmethod def hash_password(password: str) -> str: """Hash a password for storage""" + AuthService.validate_password(password) if HAS_PASSLIB: - return pwd_context.hash(password) + try: + return pwd_context.hash(password) + except ValueError as exc: + if "72 bytes" in str(exc).lower() and AuthService.password_utf8_bytes(password) > MAX_BCRYPT_PASSWORD_BYTES: + raise ValueError("密码最长支持 72 个 UTF-8 字节,请缩短后重试") from exc + return AuthService._simple_hash(password) + except Exception: + return AuthService._simple_hash(password) else: # Simple SHA256 fallback for development return AuthService._simple_hash(password) diff --git a/backend/app/services/context_cache.py b/backend/app/services/context_cache.py new file mode 100644 index 00000000..ead979b0 --- /dev/null +++ b/backend/app/services/context_cache.py @@ -0,0 +1,1155 @@ +"""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 + +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 + + @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": datetime.now(timezone.utc).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" + if "sqlite" in url: + return "SQLite" + 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": datetime.now(timezone.utc).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": datetime.now(timezone.utc).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": datetime.now(timezone.utc).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": datetime.now(timezone.utc).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() diff --git a/backend/app/services/daily_briefing.py b/backend/app/services/daily_briefing.py index 04579adb..aa80e2d3 100644 --- a/backend/app/services/daily_briefing.py +++ b/backend/app/services/daily_briefing.py @@ -19,7 +19,7 @@ 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 +from sqlalchemy import text, func, and_ from ..models import ( User, Project, Task, Signal, CognitiveConflict, @@ -28,6 +28,35 @@ from ..models import ( from .llm import LLMService +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: """每日进度简报服务""" @@ -76,7 +105,7 @@ class DailyBriefingService: return { 'user_id': user_id, - 'user_role': user.role.value, + 'user_role': self._role_code(user.role), 'date': now.strftime('%Y-%m-%d'), 'summary': summary, 'highlights': relevant_data.get('highlights', []), @@ -113,20 +142,22 @@ class DailyBriefingService: 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).join( - Task.project + 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 ) ) - tasks = task_result.scalars().all() + task_rows = task_result.all() - for task in tasks: + for task, project in task_rows: # 我的任务 if task.assignee_id == user.id: data['your_tasks'].append({ @@ -134,7 +165,7 @@ class DailyBriefingService: 'title': task.title, 'status': task.status, 'priority': task.priority, - 'project': task.project.title, + 'project': project.title, 'updated_at': task.updated_at.isoformat() if hasattr(task, 'updated_at') else None }) @@ -144,16 +175,16 @@ class DailyBriefingService: 'type': 'task', 'priority': 'high', 'content': f'任务"{task.title}"状态: {task.status}', - 'project': task.project.title + 'project': project.title }) # 2. 获取风险(阻塞的任务) - for task in tasks: + for task, project in task_rows: if task.status == 'blocked': data['risks'].append({ 'type': 'task_blocked', 'task': task.title, - 'project': task.project.title, + 'project': project.title, 'severity': 'high' }) @@ -175,7 +206,7 @@ class DailyBriefingService: for signal in signals: # 根据角色过滤信号内容 - if self._is_signal_relevant_to_role(signal, user.role): + if self._is_signal_relevant_to_role(signal, role_code): data['signals'].append({ 'id': signal.id, 'source': signal.source, @@ -208,9 +239,9 @@ class DailyBriefingService: conflicts = conflict_result.scalars().all() for conflict in conflicts: - involved_users = json.loads(conflict.involved_user_ids) if conflict.involved_user_ids else [] + involved_users = _parse_involved_user_ids(conflict.involved_user_ids) # 如果用户参与了冲突,或是管理员 - if user.id in involved_users or user.role in [UserRole.ADMIN, UserRole.BOSS]: + 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, @@ -245,8 +276,9 @@ class DailyBriefingService: # 6. 团队更新(其他用户的相关任务) team_task_result = await self.db.execute( - select(Task).join( - Task.project + 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), @@ -254,14 +286,14 @@ class DailyBriefingService: Task.updated_at >= since ) ) - team_tasks = team_task_result.scalars().all() + team_tasks = team_task_result.all() - for task in team_tasks[:5]: + for task, project in team_tasks[:5]: data['team_updates'].append({ 'assignee_id': task.assignee_id, 'task': task.title, 'status': task.status, - 'project': task.project.title + 'project': project.title }) return data @@ -284,32 +316,33 @@ class DailyBriefingService: def _is_signal_relevant_to_role( self, signal: Signal, - user_role: UserRole + user_role: Any ) -> bool: """判断信号是否与用户角色相关""" content = signal.content.lower() - source = signal.source.lower() + role_code = self._role_code(user_role) + _ = signal.source.lower() # AI工程师关注的内容 - if user_role == UserRole.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 user_role == UserRole.BACKEND: + 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 user_role == UserRole.FRONTEND: + 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 user_role in [UserRole.ADMIN, UserRole.BOSS]: + elif role_code in {UserRole.ADMIN.value, UserRole.BOSS.value}: return signal.impact_score >= 0.8 # 默认返回中等影响以上的信号 @@ -322,9 +355,10 @@ class DailyBriefingService: tenant_id: int, ) -> str: """AI 生成个性化摘要""" + role_code = self._role_code(user.role) prompt = f"""为以下用户生成每日工作摘要: -用户角色:{user.role.value} +用户角色:{role_code} 用户名:{user.username} 今日概况: @@ -356,6 +390,13 @@ class DailyBriefingService: 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, diff --git a/backend/app/services/feishu_integration.py b/backend/app/services/feishu_integration.py index 9e9c139e..df2a01ed 100644 --- a/backend/app/services/feishu_integration.py +++ b/backend/app/services/feishu_integration.py @@ -10,24 +10,40 @@ Feishu Integration Service - 飞书集成服务 飞书开放平台配置: - App ID: 从飞书开放平台获取 - App Secret: 从飞书开放平台获取 -- Encrypt Key: 事件订阅加密密钥 -- Verification Token: 事件订阅验证令牌 +- 事件接收: 统一使用长连接模式 """ import asyncio import json import hashlib -import hmac +import re import time -from datetime import datetime, timezone +from uuid import uuid4 +from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional +import httpx +from sqlalchemy import and_, or_ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select -from sqlalchemy import text from ..models import ( - User, Project, Signal, ProjectSignalLink, Integration + User, + Project, + Signal, + ProjectSignalLink, + Integration, + FeishuReplyOutbox, + AIProcessingLog, ) +from ..database import AsyncSessionLocal +from .notification_service import NotificationService from .signal_capture import ProactiveSignalCaptureService +from .llm import LLMService +from .prompt_profile_service import prompt_profile_service + +CN_TZ = timezone(timedelta(hours=8)) +WEEKDAY_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] +OUTBOX_MAX_ATTEMPTS = 3 +OUTBOX_STALE_SENDING_SECONDS = 120 class FeishuIntegrationService: @@ -41,8 +57,13 @@ class FeishuIntegrationService: 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" + async def load_config(self, tenant_id: int = 1): """从数据库加载飞书配置""" self.tenant_id = tenant_id @@ -50,26 +71,32 @@ class FeishuIntegrationService: result = await self.db.execute( select(Integration).where( Integration.tenant_id == tenant_id, - Integration.provider == 'feishu' + Integration.type == 'feishu', + Integration.is_active.is_(True), ) ) integration = result.scalar_one_or_none() if integration: - config = json.loads(integration.config) if integration.config else {} + 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 = config.get('encrypt_key') - self.verification_token = config.get('verification_token') + 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 all([ - self.app_id, - self.app_secret, - self.encrypt_key, - self.verification_token - ]) + return bool(self.app_id and self.app_secret) async def save_config( self, @@ -77,40 +104,36 @@ class FeishuIntegrationService: 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': encrypt_key, - 'verification_token': verification_token + 'encrypt_key': "", + 'verification_token': "", + 'event_mode': mode, } # 查找或创建集成记录 result = await self.db.execute( select(Integration).where( Integration.tenant_id == tenant_id, - Integration.provider == 'feishu' + Integration.type == 'feishu' ) ) integration = result.scalar_one_or_none() if integration: - integration.config = json.dumps(config) + integration.config = config integration.is_active = True - integration.last_synced_at = datetime.now(timezone.utc) else: - from sqlalchemy import text - next_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM integrations"))).scalar() or 1 - integration = Integration( - id=next_id, - provider='feishu', - name='飞书集成', - config=json.dumps(config), + type='feishu', + config=config, is_active=True, - last_synced_at=datetime.now(timezone.utc), tenant_id=tenant_id ) self.db.add(integration) @@ -120,40 +143,11 @@ class FeishuIntegrationService: # 更新内存配置 self.app_id = app_id self.app_secret = app_secret - self.encrypt_key = encrypt_key - self.verification_token = verification_token + self.encrypt_key = None + self.verification_token = None + self.event_mode = mode self.tenant_id = tenant_id - async def verify_url(self, challenge: str) -> str: - """ - 验证飞书事件订阅 URL - - 飞书会发送一个 GET 请求来验证 URL 有效性 - """ - if not self.is_configured(): - raise ValueError("飞书未配置") - - # 返回 challenge 值即可完成验证 - return challenge - - def verify_signature(self, timestamp: str, nonce: str, body: str, signature: str) -> bool: - """ - 验证飞书事件签名 - - 防止伪造请求 - """ - if not self.encrypt_key: - return False - - # 构造签名字符串 - sign_str = f"{self.encrypt_key}{nonce}{timestamp}{body}" - computed_signature = hashlib.sha256(sign_str.encode()).hexdigest() - - # 飞书返回的签名需要去掉 "sha256=" 前缀 - expected_signature = signature.replace("sha256=", "") - - return computed_signature == expected_signature - async def handle_message_event( self, event_data: Dict[str, Any] @@ -163,11 +157,13 @@ class FeishuIntegrationService: 支持的事件类型: - message.receive_v1: 收到群聊消息 + - im.message.receive_v1: 收到群聊消息(schema 2.0) """ try: - event_type = event_data.get('type', '') + event_type = (event_data.get('type') or '').strip() - if event_type == 'message.receive_v1': + # 飞书 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 { @@ -187,68 +183,1467 @@ class FeishuIntegrationService: event: Dict[str, Any] ) -> Dict[str, Any]: """处理收到的消息事件""" - # 解析消息内容 - message_content = self._extract_message_content(event) + 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" + 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, + ) + self._emit_state_transition( + trace_id=state_trace_id, + state="REPLIED", + route="empty_text_prompt", + ) return {'code': 0, 'msg': 'No text content'} # 获取发送者信息 - sender_name = event.get('sender', {}).get('sender_name', '未知用户') + 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 = event.get('chat', {}).get('chat_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) - result = await capture_service.capture_from_feishu( - group_chat_id=chat_id, - message_content=message_content, - sender=sender_name, - tenant_id=self.tenant_id - ) + 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, + ) + 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(f"[Feishu] Message processed: {result.get('status')}") + 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" + 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 = 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, + ) + 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 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, + ) + 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, + ) + 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, + ) + 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' + '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, + ) -> 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, + ) + 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, + ) + 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, + ) + return + + try: + active_role = await prompt_profile_service.get_active_role(self.db, tenant_id=self.tenant_id) + role = active_role.get("role") if isinstance(active_role, dict) else {} + role_name = str((role or {}).get("role_name") or "产品经理(任务分派)").strip() + system_prompt = str((role or {}).get("system_prompt") or "").strip() + if len(system_prompt) < 20: + system_prompt = ( + "你是 PMFlow 的任务分派产品经理。" + "请用简洁、专业、有温度的中文回复用户,先确认需求,再给可执行下一步。" + ) + + 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 = f""" +{system_prompt} + +你正在飞书群里以「{role_name}」身份回复用户。请基于以下上下文,生成一条 35-140 字中文回复: +- 当前系统时间(北京时间):{now_cn_text} +- 提及用户:{sender_name} +- 用户消息:{(message_content or '')[:600]} +- 系统处理状态:{status} +- 分类结果:{classification} +- 处理原因:{reason or "无"} +- 处理追踪ID:{ai_trace_id or "无"} +- 关联项目ID:{linked_projects_text or '无'} + +回复要求: +1. 语气自然、专业,避免机器口吻。 +2. 先确认你已处理,再给 1-3 条下一步建议。 +3. 若信息不足,请明确要补充什么。 +4. 如果用户询问“日期/今天/时间/几点/星期”,必须基于“当前系统时间(北京时间)”直接回答,不得编造历史日期。 +5. 不要输出 Markdown,不要输出 JSON。 +只输出最终回复正文。 +""".strip() + + 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 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) + + 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, + ) + + 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, + ) -> 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, + ) + asyncio.create_task(self._dispatch_outbox_entry(outbox_id=outbox_id)) + except Exception as exc: + print(f"[Feishu] enqueue reply outbox failed: chat_id={chat_id}, error={exc}") + try: + plain_text = self._normalize_outgoing_reply_text(text) + mention_prefix = self._build_mention_prefix( + open_id=mention_open_id, + display_name=mention_name, + ) + await self.send_message_to_group(chat_id=chat_id, message=f"{mention_prefix}{plain_text}".strip()) + except Exception as send_exc: + print(f"[Feishu] send fallback failed: chat_id={chat_id}, error={send_exc}") + + async def _enqueue_reply_outbox( + self, + *, + chat_id: str, + message_text: str, + trace_id: Optional[str], + ) -> int: + async with AsyncSessionLocal() as db: + row = FeishuReplyOutbox( + tenant_id=int(self.tenant_id or 1), + trace_id=(trace_id or "")[:64] or None, + chat_id=(chat_id or "")[:128], + message_text=(message_text or "")[:2000] or "已收到,正在处理。", + status="pending", + attempts=0, + next_retry_at=None, + ) + db.add(row) + await db.commit() + await db.refresh(row) + return int(row.id) + + async def _dispatch_outbox_entry(self, *, outbox_id: int) -> None: + await self.__class__._dispatch_outbox_entry_by_id(outbox_id=outbox_id) + + @classmethod + async def _dispatch_outbox_entry_by_id(cls, *, outbox_id: int) -> None: + for _ in range(OUTBOX_MAX_ATTEMPTS + 1): + now = datetime.utcnow() + should_wait_seconds = 0.0 + async with AsyncSessionLocal() as db: + row = ( + await db.execute( + select(FeishuReplyOutbox).where( + FeishuReplyOutbox.id == int(outbox_id) + ) + ) + ).scalar_one_or_none() + if row is None: + return + status = str(row.status or "").lower() + if status == "sent": + return + if status == "failed": + return + if status == "sending": + stale_seconds = 0.0 + marker = row.updated_at or row.created_at + if isinstance(marker, datetime): + try: + marker_naive = marker.replace(tzinfo=None) if marker.tzinfo else marker + stale_seconds = max(0.0, (now - marker_naive).total_seconds()) + except Exception: + stale_seconds = 0.0 + if stale_seconds >= OUTBOX_STALE_SENDING_SECONDS: + row.status = "retrying" + row.next_retry_at = now + await db.commit() + else: + return + if row.next_retry_at and row.next_retry_at > now: + should_wait_seconds = max(0.2, min(30.0, (row.next_retry_at - now).total_seconds())) + else: + row.status = "sending" + row.attempts = int(row.attempts or 0) + 1 + row.last_error = None + await db.commit() + + if should_wait_seconds > 0: + await asyncio.sleep(should_wait_seconds) + continue + + send_error: Optional[str] = None + async with AsyncSessionLocal() as db_send: + row = ( + await db_send.execute( + select(FeishuReplyOutbox).where( + FeishuReplyOutbox.id == int(outbox_id) + ) + ) + ).scalar_one_or_none() + if row is None: + return + try: + service = FeishuIntegrationService(db_send) + await service.load_config(tenant_id=int(row.tenant_id)) + await service.send_message_to_group( + chat_id=str(row.chat_id or ""), + message=str(row.message_text or ""), + ) + row.status = "sent" + row.sent_at = datetime.utcnow() + row.next_retry_at = None + row.last_error = None + await db_send.commit() + return + except Exception as exc: + send_error = str(exc) + + async with AsyncSessionLocal() as db_retry: + row = ( + await db_retry.execute( + select(FeishuReplyOutbox).where( + FeishuReplyOutbox.id == int(outbox_id) + ) + ) + ).scalar_one_or_none() + if row is None: + return + attempts = int(row.attempts or 0) + if attempts >= OUTBOX_MAX_ATTEMPTS: + row.status = "failed" + row.next_retry_at = None + else: + row.status = "retrying" + delay_seconds = min(30, 2 * (2 ** max(0, attempts - 1))) + row.next_retry_at = datetime.utcnow() + timedelta(seconds=delay_seconds) + row.last_error = (send_error or "unknown_send_error")[:1000] + await db_retry.commit() + + if attempts >= OUTBOX_MAX_ATTEMPTS: + return + await asyncio.sleep(min(5, 2 * max(1, attempts))) + + @classmethod + async def dispatch_pending_outbox_entries(cls, *, limit: int = 30) -> int: + now = datetime.utcnow() + fetch_limit = max(1, min(200, int(limit))) + async with AsyncSessionLocal() as db: + due_rows = ( + 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()) + .limit(fetch_limit) + ) + ).scalars().all() + + if not due_rows: + return 0 + + await asyncio.gather( + *(cls._dispatch_outbox_entry_by_id(outbox_id=int(outbox_id)) for outbox_id in due_rows), + return_exceptions=True, + ) + return len(due_rows) + + @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 _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 + + @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 + + 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=timezone.utc) + 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]: - """提取消息文本内容""" - message = event.get('message', {}) + payload = self._extract_message_payload(event) + text = str(payload.get("text") or "").strip() + return text or None - # 处理文本消息 - message_type = message.get('message_type', '') + 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 - if message_type == 'text': - content = message.get('content', '') - if content: - try: - content_json = json.loads(content) - return content_json.get('text', '') - except: - pass + 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 - # 处理富文本消息 - elif message_type == 'post': - content = message.get('content', '') - if content: - try: - content_json = json.loads(content) - post = content_json.get('post', {}) - zh_cn = post.get('zh_cn', {}) - elements = zh_cn.get('content', []) or [] - text_parts = [] - for elem in elements: - if elem.get('tag') == 'text': - text_parts.append(elem.get('text', '')) - return ' '.join(text_parts) - except: - pass - - return None + 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, @@ -274,17 +1669,32 @@ class FeishuIntegrationService: } message_data = { - 'receive_id_type': 'chat_id', 'receive_id': chat_id, 'msg_type': 'text', 'content': json.dumps({'text': message}) } - import aiohttp - async with aiohttp.ClientSession() as session: - async with session.post(url, headers=headers, json=message_data) as resp: - result = await resp.json() - return result + 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""" @@ -298,15 +1708,81 @@ class FeishuIntegrationService: 'app_secret': self.app_secret } - import aiohttp - async with aiohttp.ClientSession() as session: - async with session.post(url, headers=headers, json=data) as resp: - result = await resp.json() + 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 - if result.get('code') == 0: - return result.get('tenant_access_token') - else: - raise Exception(f"获取 token 失败: {result}") + 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, @@ -355,23 +1831,43 @@ class FeishuIntegrationService: card_content['elements'].append({'tag': 'hr'}) message_data = { - 'receive_id_type': 'chat_id', 'receive_id': chat_id, 'msg_type': 'interactive', 'content': json.dumps(card_content) } - import aiohttp - async with aiohttp.ClientSession() as session: - async with session.post(url, headers=headers, json=message_data) as resp: - result = await resp.json() - return result + 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) diff --git a/backend/app/services/feishu_long_connection.py b/backend/app/services/feishu_long_connection.py new file mode 100644 index 00000000..44e50544 --- /dev/null +++ b/backend/app/services/feishu_long_connection.py @@ -0,0 +1,546 @@ +"""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 + +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=timezone.utc).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 = datetime.now(timezone.utc).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 = datetime.now(timezone.utc).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 = datetime.now(timezone.utc).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: + 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 + 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 = datetime.now(timezone.utc).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} + + 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 diff --git a/backend/app/services/feishu_signal_pipeline.py b/backend/app/services/feishu_signal_pipeline.py new file mode 100644 index 00000000..407608eb --- /dev/null +++ b/backend/app/services/feishu_signal_pipeline.py @@ -0,0 +1,5986 @@ +"""飞书消息分流与决策队列服务。""" +from __future__ import annotations + +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, + User, +) +from .llm import LLMService +from .adaptive_rag_service import AdaptiveRAGService +from .notification_service import NotificationService +from .openclaw_commander_service import OpenClawCommanderService, OperationExecutorService +from ..utils.project_members import ensure_project_default_members +from ..utils.sequence import ensure_project_id_sequence + +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, +) +OPENCLAW_PROJECT_CREATE_RE = re.compile( + r"(创建|新建|建立|开个|开一个|建个|建一个|做个|做一个).{0,6}项目", + re.IGNORECASE, +) +OPENCLAW_COMMAND_VERB_RE = re.compile( + r"(创建|新建|建立|开个|开一个|建个|建一个|做个|做一个|修改|更新|改成|改为|设为|设成|分配|指派|归档|重开|重新打开|恢复|标记完成|完成|提醒|通知|调整工时|调整工作量)", + re.IGNORECASE, +) +OPENCLAW_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, +) +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_OPENCLAW_EXECUTED = "openclaw_executed" +ROUTING_OPENCLAW_BLOCKED = "openclaw_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 datetime.now(timezone.utc) + + +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(timezone.utc).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(OPENCLAW_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, + } + + 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}") + + 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: + 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 self._looks_like_project_create_request(text): + 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 self._looks_like_task_create_request(text): + 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 self._looks_like_external_connect_request(text): + 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_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 = OpenClawCommanderService._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 = OpenClawCommanderService._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 = OpenClawCommanderService._normalize_project_title_candidate(candidate) + if normalized.endswith(("方向", "规划", "创新")): + normalized = OpenClawCommanderService._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 = OpenClawCommanderService._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 OpenClawCommanderService._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 = OpenClawCommanderService._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 = OpenClawCommanderService._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": "operation_executor", + "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": "operation_executor", + "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": "operation_executor", + "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(timezone.utc).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": "operation_executor", + "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]: + executor = OperationExecutorService(self.db) + project_context = None + project_id = _safe_int(payload.get("project_id")) + if operation_code != "project.create" and project_id is not None: + project_context = ( + 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_context is None: + raise ValueError("project_context_missing") + + logger.info( + "operation_started", + tenant_id=self.tenant_id, + trace_id=trace_id, + operation_code=operation_code, + payload=payload, + executor="operation_executor", + ) + execution = await executor.execute_operations( + tenant_id=self.tenant_id, + operator_user=operator_user, + project=project_context, + operations=[ + { + "operation_code": operation_code, + "payload": payload, + "reason": "intent_gate", + } + ], + dry_run=False, + trace_id=trace_id, + ) + 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="operation_executor", + ) + return execution + + @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=timezone.utc) + + 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="operation_executor", + 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="operation_executor", + 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_openclaw_command_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="operation_executor", + 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=timezone.utc) + + 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="operation_executor", + 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="operation_executor", + 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_openclaw_command_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="operation_executor", + 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=timezone.utc) + 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_execute_openclaw_command( + 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=timezone.utc) + + 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_openclaw_business_command(text): + return False + if cls._looks_like_project_lookup_question(text): + return True + return bool(OBVIOUS_QUESTION_RE.search(text)) + + @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(OPENCLAW_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_openclaw_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_openclaw_business_command(cls, message: str) -> bool: + text = str(message or "").strip() + if not text: + return False + if cls._looks_like_project_create_command(text): + return True + return bool(OPENCLAW_COMMAND_VERB_RE.search(text) and OPENCLAW_COMMAND_OBJECT_RE.search(text)) + + async def _maybe_execute_openclaw_command( + 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_openclaw_business_command(message): + return None + + operator_user = await self._resolve_command_operator_user(sender=sender) + if operator_user is None: + return await self._build_openclaw_blocked_capture( + message=message, + feishu_message_id=feishu_message_id, + duration_ms=int((time.perf_counter() - process_started_at) * 1000), + reason="当前租户下没有可用操作者,系统未执行写操作。", + ) + + target_project_id = inferred_project_id or self._guess_matched_project_id_from_text(message, projects) + commander = OpenClawCommanderService(self.db) + if self._looks_like_project_create_command(message): + command_result = await commander.dispatch_global_command( + tenant_id=self.tenant_id, + operator_user=operator_user, + instruction=message, + dry_run=False, + max_operations=4, + response_mode="fast", + persist_run=False, + ) + elif target_project_id is not None: + command_result = await commander.dispatch_command( + tenant_id=self.tenant_id, + operator_user=operator_user, + project_id=int(target_project_id), + instruction=message, + dry_run=False, + max_operations=6, + response_mode="fast", + persist_run=False, + ) + else: + return await self._build_openclaw_blocked_capture( + message=message, + feishu_message_id=feishu_message_id, + duration_ms=int((time.perf_counter() - process_started_at) * 1000), + reason="缺少明确项目上下文。请指明项目名,或在对应项目群内发送该指令。", + ) + + execution = command_result.get("execution") if isinstance(command_result, dict) else {} + requested_count = int((execution or {}).get("requested_count") or 0) + if requested_count <= 0 and int(command_result.get("planned_operations") or 0) <= 0: + return await self._build_openclaw_blocked_capture( + message=message, + feishu_message_id=feishu_message_id, + duration_ms=int((time.perf_counter() - process_started_at) * 1000), + reason="未识别到可安全执行的业务操作,系统未写库。", + ) + + linked_project_ids = self._collect_command_project_ids(command_result) + created_project_id = self._extract_created_project_id(command_result) + primary_project_id = created_project_id or (linked_project_ids[0] if linked_project_ids else None) + route_status = self._resolve_command_route_status(command_result) + item_id = await self._record_command_context_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, + matched_project_id=primary_project_id, + route_status=route_status, + command_result=command_result, + ) + reply_text = self._build_openclaw_command_reply(command_result) + duration_ms = int((time.perf_counter() - process_started_at) * 1000) + await self._write_processing_log( + feishu_message_id=feishu_message_id, + feishu_item_id=item_id, + raw_message=message, + ai_raw_output=json.dumps({"command_result": command_result}, ensure_ascii=False)[:120000], + project_score=1.0 if primary_project_id is not None else None, + signal_confidence=0.0, + level="command", + routing_decision=ROUTING_OPENCLAW_EXECUTED, + routing_reason="识别为明确业务命令,已直接进入 OpenClaw command 层并按真实执行结果回执。", + reasoning_bullets=[ + "命中 command-first 路由,跳过 RAG 问答与信号分流。", + f"planner_status={str(command_result.get('planner_status') or '') or 'unknown'}", + f"openclaw_status={str(command_result.get('openclaw_status') or '') or 'unknown'}", + ], + processing_status="success", + error_message=None, + duration_ms=duration_ms, + ) + return { + "status": "captured", + "classification": "command", + "project_scope": "command", + "linked_project_ids": linked_project_ids, + "signal_id": None, + "created_project_id": created_project_id or primary_project_id, + "auto_tasks": [], + "ai_trace_id": str(command_result.get("trace_id") or ""), + "results": [ + { + "item_id": item_id, + "level": "command", + "project_id": primary_project_id, + "signal_id": None, + "route_status": route_status, + } + ], + "intent": "command", + "direct_reply_required": True, + "direct_reply_text": reply_text, + "command_result": command_result, + } + + 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_openclaw_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_OPENCLAW_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(timezone.utc).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="OpenClaw 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": "openclaw_commander", + "operation_code": None, + "result_summary": self._build_openclaw_command_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("openclaw_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_openclaw_command_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_openclaw_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_openclaw_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 UTC,避免 asyncpg aware/naive 报错。 + today_start = _utc_now().astimezone(timezone.utc).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": "operation_executor", + "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": "operation_executor", + "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 + if cold_start_enabled 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 = "保守自动化已拦截自动入库,避免早期误关联。" + else: + item.blocking_reason = "当前置信度不足以自动入库,需要你拍板后再继续。" + item.next_step_text = "确认后,AI 将把信号入库并进入后续生成流程。" + item.decision_reason = "信号虽然可执行,但归属判断仍存在不确定性。" + 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), + } + 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 ("operation_executor" 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/llm.py b/backend/app/services/llm.py index e98bfab6..d6cef769 100644 --- a/backend/app/services/llm.py +++ b/backend/app/services/llm.py @@ -1,35 +1,1209 @@ -"""LLM 服务兼容层。 +"""统一 LLM 服务(DeepSeek / GLM / OpenAI / Ollama)。 -在完整 AI 能力未接入时提供最小实现,保证 API 端点可运行。 +能力: +1. 多引擎配置持久化(Integration.type=llm_engine) +2. 动态选择激活引擎 +3. OpenAI-Compatible chat/completions 调用 +4. 基础 AI 调用日志写入(ai_call_logs) """ from __future__ import annotations -from typing import Any +import asyncio +from dataclasses import dataclass +import hashlib +import json +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 + +try: + import redis.asyncio as redis_async + + REDIS_AVAILABLE = True +except Exception: # pragma: no cover + REDIS_AVAILABLE = False + redis_async = None + +SUPPORTED_PROVIDERS = {"deepseek", "glm", "openai", "ollama"} +OPENCLAW_PRIMARY_GATEWAY_URL = "http://openclaw:18789/v1" +OPENCLAW_DEFAULT_LOW_MODEL = "zai/glm-4-flash" +OPENCLAW_DEFAULT_HIGH_MODEL = "minimax/MiniMax-M2.5" +OPENCLAW_DEFAULT_SAFETY_MODEL = "deepseek/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-flash", + "api_env": "GLM_API_KEY", + }, + "openai": { + "name": "OpenAI", + "base_url": OPENCLAW_PRIMARY_GATEWAY_URL, + "model": OPENCLAW_DEFAULT_LOW_MODEL, + "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_BACKEND_RULES_PATH = Path(__file__).resolve().parents[2] / ".openclaw" / "pmflow-routing-rules.json" +OPENCLAW_PROJECT_RULES_PATH = Path(__file__).resolve().parents[3] / ".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 = ( + "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", +) +DEFAULT_LOW_TIER_HINTS = ( + "feishu_level_classification", + "feishu_signal_extraction", + "feishu_humanized_reply", + "signal_source_onboarding_plan", + "openclaw_command_dispatch", + "signal_source_ai_worker_execution", + "extract_context_insights", + "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", + "openclaw_command_dispatch", + "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", +) +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", + "openclaw_command_dispatch", + "signal_source_ai_worker_execution", +) +FAST_LOCAL_PLANNER_FUNCTION_HINTS = ( + "signal_source_onboarding_plan", + "openclaw_command_dispatch", +) +FAST_LOCAL_WORKER_FUNCTION_HINTS = ("signal_source_ai_worker_execution",) +FAST_LOCAL_FALLBACK_FAST_TIMEOUT_SECONDS = 3.0 +FAST_LOCAL_FALLBACK_COMPLETE_TIMEOUT_SECONDS = 6.0 +STRUCTURED_CACHE_TTL_SECONDS = 300 +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 +STRUCTURED_OBJECT_SCHEMAS: dict[str, dict[str, Any]] = { + "signal_source_onboarding_plan": { + "required_keys": ("should_onboard", "summary", "worker_roles", "openclaw_instruction"), + "type_checks": { + "should_onboard": "bool", + "summary": "str", + "worker_roles": "list", + "openclaw_instruction": "str", + }, + "optional_type_checks": { + "source": ("dict_or_none",), + }, + }, + "openclaw_command_dispatch": { + "required_keys": ("summary", "confidence", "operations"), + "type_checks": { + "summary": "str", + "confidence": "number", + "operations": "list", + }, + }, + "signal_source_ai_worker_execution": { + "required_keys": ("summary", "deliverables", "risks", "next_actions"), + "type_checks": { + "summary": "str", + "deliverables": "list", + "risks": "list", + "next_actions": "list", + }, + }, +} + + +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 _runtime_log(scope: str, message: str) -> None: + print(f"[{_cn_now_text()}][{scope}] {message}") + + +@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: - async def generate(self, prompt: str, **_: Any) -> str: - return f"[stub] {prompt[:200]}" + _provider_circuit_state: dict[str, dict[str, Any]] = {} - async def generate_task_breakdown_async(self, description: str, **_: Any) -> list[dict]: + 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 + + @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_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 status_code in {408, 409, 429, 500, 502, 503, 504} + + @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 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], + } + + 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 = datetime.now(timezone.utc).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"] = datetime.now(timezone.utc).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: + 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, + ) + 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}" + 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 _generate_with_db(self, db: AsyncSession, prompt: str, **kwargs: Any) -> str: + 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": ""} + merged_prompt = self._merge_prompt_with_profile(prompt=prompt, prompt_profile=prompt_profile) + safety_attempted = False + 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, prompt_profile=prompt_profile) + 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, + 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") + + invalid_structured_output = self._is_invalid_structured_output( + function_name=function_name, + response_text=response_text, + ) + should_upgrade_to_high = False + if not self._prefer_fast_local_fallback(function_name): + should_upgrade_to_high = self._should_escalate_low_to_high( + function_name=function_name, + response_text=response_text, + ) or (invalid_structured_output and self._is_low_tier_function(function_name)) + + if should_upgrade_to_high: + high_engine = await self._resolve_high_tier_engine_for_escalation( + db, + tenant_id=tenant_id, + primary_engine=engine, + ) + if high_engine is None: + raise ValueError("high_tier_engine_unavailable") + high_provider = str(high_engine.get("provider") or "") + high_model = str(high_engine.get("model") or "") + _runtime_log( + "LLM升级", + ( + "低阶引擎触发升级:" + f"function={function_name}, from={provider}/{model}, to={high_provider}/{high_model}" + ), + ) + try: + escalated_text = await self._chat_completion( + provider=high_provider, + prompt=prompt, + system_prompt=prompt_profile.get("system_prompt") or "", + 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 escalated_text.strip(): + raise ValueError("high_tier_empty_response") + if self._is_invalid_structured_output(function_name=function_name, response_text=escalated_text): + raise ValueError("high_tier_invalid_structured_output") + response_text = escalated_text + engine_meta = high_engine + except Exception as escalation_exc: + safety_attempted = True + safety_text, safety_engine = await self._try_safety_engine( + db, + tenant_id=tenant_id, + function_name=function_name, + prompt=prompt, + system_prompt=prompt_profile.get("system_prompt") or "", + temperature=temperature, + max_tokens=max_tokens, + primary_engine=high_engine, + reason=f"high_tier_retry_failed:{escalation_exc}", + ) + if safety_engine is not None and safety_text.strip(): + routing_decision = "fallback_safety_engine" + status = "fallback_safety_engine" + engine_meta = safety_engine + response_text = safety_text + return response_text + raise ValueError(f"high_tier_retry_failed:{escalation_exc}") from escalation_exc + + 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: + upgraded_exc: Exception = exc + if self._is_fail_fast_exception(exc): + status = "failed_fast" + response_text = self._stub_response(prompt) + _runtime_log("LLM", f"生成快速失败,已跳过升级重试:{exc}") + return response_text + if self._is_low_tier_function(function_name): + if self._prefer_fast_local_fallback(function_name): + raise upgraded_exc + high_engine = await self._resolve_high_tier_engine_for_escalation( + db, + tenant_id=tenant_id, + primary_engine=engine_meta, + ) + if high_engine is not None: + high_provider = str(high_engine.get("provider") or "") + high_model = str(high_engine.get("model") or "") + _runtime_log( + "LLM升级", + ( + "低阶引擎异常,转高阶兜底:" + f"function={function_name}, from={engine_meta.get('provider')}/{engine_meta.get('model')}, " + f"to={high_provider}/{high_model}, error={exc}" + ), + ) + try: + escalated_text = await self._chat_completion( + provider=high_provider, + prompt=prompt, + system_prompt=prompt_profile.get("system_prompt") or "", + 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 escalated_text.strip(): + raise ValueError("high_tier_empty_response") + if self._is_invalid_structured_output(function_name=function_name, response_text=escalated_text): + raise ValueError("high_tier_invalid_structured_output") + routing_decision = "fallback_high_tier_engine" + status = "fallback_high_tier_engine" + engine_meta = high_engine + response_text = escalated_text + return response_text + except Exception as high_exc: + upgraded_exc = ValueError(f"high_tier_retry_failed:{high_exc}") + engine_meta = high_engine + + if not safety_attempted: + safety_attempted = True + safety_text, safety_engine = await self._try_safety_engine( + db, + tenant_id=tenant_id, + function_name=function_name, + prompt=prompt, + system_prompt=prompt_profile.get("system_prompt") or "", + temperature=temperature, + max_tokens=max_tokens, + primary_engine=engine_meta, + reason=str(upgraded_exc), + ) + if safety_engine is not None and safety_text.strip(): + routing_decision = "fallback_safety_engine" + status = "fallback_safety_engine" + engine_meta = safety_engine + response_text = safety_text + return response_text + + retry_text = "" + if self._direct_emergency_enabled(): + fallback_engines = self._build_fallback_engines(engine_meta) + for fallback_engine in fallback_engines: + try: + fb_provider = str(fallback_engine.get("provider") or "") + fb_model = str(fallback_engine.get("model") or "") + _runtime_log( + "LLM", + f"应急直连引擎重试:provider={fb_provider}, model={fb_model}, error={exc}", + ) + retry_text = await self._chat_completion( + provider=fb_provider, + prompt=prompt, + system_prompt=prompt_profile.get("system_prompt") or "", + api_key=str(fallback_engine.get("api_key") or ""), + base_url=str(fallback_engine.get("base_url") or ""), + model=fb_model, + temperature=temperature, + max_tokens=max_tokens, + function_name=function_name, + response_mode=response_mode, + ) + if retry_text.strip(): + routing_decision = "fallback_emergency_direct_engine" + status = "fallback_emergency_direct_engine" + engine_meta = fallback_engine + response_text = retry_text + return response_text + except Exception as retry_exc: + _runtime_log( + "LLM", + ( + "应急直连重试失败:" + f"provider={fallback_engine.get('provider')}, " + f"model={fallback_engine.get('model')}, error={retry_exc}" + ), + ) + + status = "failed" + response_text = self._stub_response(prompt) + _runtime_log("LLM", f"生成失败,已返回兜底 stub:{exc}") + return response_text + finally: + try: + merged_prompt = self._merge_prompt_with_profile(prompt=prompt, prompt_profile=prompt_profile) + 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": ""} + merged_prompt = prompt + provider = "" + model = "" + 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, prompt_profile=prompt_profile) + engine = await self._resolve_engine_for_call( + db, + tenant_id=tenant_id, + function_name=function_name, + ) + 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, + 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, + ) + is_fallback = extraction.error_code is not None + _runtime_log( + "LLM结构化", + ( + f"function={function_name}, structured_source={extraction.source_field or 'none'}, " + f"validation={extraction.validation}, fallback={'true' if is_fallback else 'false'}" + ), + ) + 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: + error_code = self._classify_structured_exception(exc) + status = error_code + result = StructuredExtractionResult( + payload=None, + source_field="", + provider=provider, + model=model, + error_code=error_code, + raw_text=str(exc)[:1000], + validation="invalid", + ) + _runtime_log( + "LLM结构化", + f"function={function_name}, structured_source=none, validation=invalid, fallback=true", + ) + return result + 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 = str(system_prompt or "").strip() + if cleaned_system_prompt: + messages.append({"role": "system", "content": cleaned_system_prompt}) + messages.append({"role": "user", "content": prompt}) + + 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" + payload = { + "model": model, + "messages": messages, + "temperature": temperature, + "max_tokens": max_tokens, + } + if is_openclaw_gateway_request: + payload["agents"] = { + "defaults": { + "timeoutSeconds": int(openclaw_agents_timeout_seconds), + } + } + headers = { + "Authorization": f"Bearer {key}", + "Content-Type": "application/json", + } + async with httpx.AsyncClient(timeout=request_timeout, trust_env=False) as client: + resp = await client.post(endpoint, headers=headers, json=payload) + resp.raise_for_status() + data = resp.json() + choices = data.get("choices") or [] + if not choices: + raise ValueError("LLM 响应无 choices") + message = choices[0].get("message") if isinstance(choices[0], dict) else {} + text = self._extract_text_from_chat_response( + message=message, + choice=choices[0] if isinstance(choices[0], dict) else {}, + response=data, + structured_output=structured_output, + ) + if not text and not structured_output: + msg_keys = list(message.keys()) if isinstance(message, dict) else [] + choice_keys = list(choices[0].keys()) if isinstance(choices[0], dict) else [] + choice_preview = "" + try: + choice_preview = json.dumps(choices[0], ensure_ascii=False)[:300] + except Exception: + choice_preview = str(choices[0])[:300] + raise ValueError( + "LLM 返回空内容: " + f"message_keys={msg_keys}, choice_keys={choice_keys}, choice_preview={choice_preview}" + ) + if use_provider_circuit_breaker: + self._record_provider_success(circuit_key) + return ChatCompletionResult(text=text, raw_response=data) + except Exception as exc: + 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", + "type": "ai", "priority": "medium", "estimated_hours": 4, "description": description[:200], } ] - async def get_embedding_async(self, text: str, **_: Any) -> list[float]: - seed = float(len(text) % 10) / 10.0 - return [seed for _ in range(16)] + 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, **_: Any) -> dict: + 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]: @@ -67,5 +1241,1320 @@ class LLMService: "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"), + } + + 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": []}, + 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 + 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) + 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)] + + 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) + provider = str(openclaw_routed.get("provider") or "") + api_key = str(openclaw_routed.get("api_key") or "").strip() + if provider != "ollama" and not api_key: + _runtime_log( + "OpenClaw路由", + ( + "命中 OpenClaw 路由但目标引擎缺少 API Key,已回退 active:" + f"function={function_name}, provider={provider}, model={openclaw_routed.get('model')}" + ), + ) + else: + return openclaw_routed + + if not self._mixed_routing_enabled(): + return active_engine + + tier = self._resolve_function_tier(function_name) + if tier not in {"high", "low"}: + return active_engine + + routed = self._resolve_tier_engine( + tier=tier, + engines=engines, + active_engine=active_engine, + ) + if not routed: + return active_engine + + provider = str(routed.get("provider") or "") + api_key = str(routed.get("api_key") or "").strip() + if provider != "ollama" and not api_key: + _runtime_log( + "LLM路由", + ( + "命中分层路由但目标引擎缺少 API Key,回退可用引擎:" + f"function={function_name}, tier={tier}, provider={provider}, model={routed.get('model')}" + ), + ) + fallback_engine = self._pick_tier_fallback_engine( + tier=tier, + engines=engines, + active_engine=active_engine, + ) + if fallback_engine: + return fallback_engine + return active_engine + _runtime_log( + "LLM路由", + ( + "函数路由生效:" + f"function={function_name}, tier={tier}, provider={provider}, model={routed.get('model')}" + ), + ) + return self._ensure_openclaw_control_plane_engine(routed) + + 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 _env_bool("OPENCLAW_ROUTING_ENABLED", True) + + def _openclaw_control_plane_only(self) -> bool: + return self._openclaw_routing_enabled() and not self._direct_emergency_enabled() + + def _ensure_openclaw_control_plane_engine(self, engine: dict[str, Any]) -> dict[str, Any]: + runtime = dict(engine or {}) + if not self._openclaw_control_plane_only(): + return runtime + + openai_default = self._env_provider_runtime("openai") + runtime["provider"] = "openai" + runtime["base_url"] = ( + str(os.getenv("OPENAI_BASE_URL", openai_default.get("base_url") or OPENCLAW_PRIMARY_GATEWAY_URL) or "") + .strip() + .rstrip("/") + or OPENCLAW_PRIMARY_GATEWAY_URL + ) + runtime["api_key"] = ( + str(os.getenv("OPENAI_API_KEY", "") or "").strip() + or str(runtime.get("api_key") or "").strip() + ) + runtime["model"] = ( + str(runtime.get("model") or "").strip() + or str(openai_default.get("model") or OPENCLAW_DEFAULT_LOW_MODEL).strip() + ) + runtime["name"] = str(runtime.get("name") or "OpenClaw Gateway").strip() or "OpenClaw Gateway" + runtime["id"] = str(runtime.get("id") or "openclaw-gateway") + return runtime + + def _openclaw_rules_path(self) -> str: + custom = str(os.getenv("OPENCLAW_ROUTING_RULES_PATH", "") or "").strip() + if custom: + return custom + if OPENCLAW_BACKEND_RULES_PATH.exists(): + return str(OPENCLAW_BACKEND_RULES_PATH) + if OPENCLAW_PROJECT_RULES_PATH.exists(): + return str(OPENCLAW_PROJECT_RULES_PATH) + return str(OPENCLAW_BACKEND_RULES_PATH) + + 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("/") + + 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 + 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 _merge_prompt_with_profile(*, prompt: str, prompt_profile: dict[str, str]) -> str: + system_prompt = str(prompt_profile.get("system_prompt") or "").strip() + 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 _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", + "openclaw_command_dispatch", + } + + 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) + + def _use_provider_circuit_breaker(self, function_name: str) -> bool: + 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 _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 == "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": "openai", + "high": "openai", + "safety": "openai", + } + provider_env = str( + os.getenv( + f"LLM_ROUTING_{upper}_PROVIDER", + default_provider_by_tier.get( + tier, + os.getenv("LLM_PROVIDER", "openai"), + ), + ) + 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": + routed["base_url"] = OPENCLAW_PRIMARY_GATEWAY_URL + + 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]: + if not engine_id: + engine = await self._resolve_active_engine(db, tenant_id=tenant_id) + if str(engine.get("provider") or "") != "ollama" and not str(engine.get("api_key") or "").strip(): + raise ValueError("当前激活引擎未配置 API Key") + return engine + + _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + 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) + if str(engine.get("provider") or "") != "ollama" and not str(engine.get("api_key") or "").strip(): + raise ValueError("目标引擎未配置 API Key") + return engine + 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", "deepseek")) + 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() diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py new file mode 100644 index 00000000..c1e1babe --- /dev/null +++ b/backend/app/services/notification_service.py @@ -0,0 +1,241 @@ +"""站内通知服务""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any, Iterable, Optional + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import Notification, User + + +class NotificationService: + def __init__(self, db: AsyncSession): + self.db = db + + async def create_notification( + self, + *, + tenant_id: int, + user_id: int, + title: str, + content: Optional[str] = None, + category: str = "system", + source: Optional[str] = None, + entity_type: Optional[str] = None, + entity_id: Optional[int] = None, + target_url: Optional[str] = None, + kind: Optional[str] = None, + priority: Optional[str] = None, + status: Optional[str] = None, + blocking_reason: Optional[str] = None, + recommended_action: Optional[str] = None, + dedupe_key: Optional[str] = None, + occurrence_count: int = 1, + payload: Optional[dict[str, Any]] = None, + commit: bool = False, + ) -> Notification: + normalized_dedupe_key = (dedupe_key or "").strip()[:255] or None + if normalized_dedupe_key: + existing = ( + await self.db.execute( + select(Notification) + .where( + and_( + Notification.tenant_id == int(tenant_id), + Notification.user_id == int(user_id), + Notification.dedupe_key == normalized_dedupe_key, + Notification.is_read.is_(False), + ) + ) + .order_by(Notification.updated_at.desc(), Notification.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if existing is not None: + existing.category = (category or existing.category or "system")[:50] + existing.title = (title or existing.title or "系统通知")[:200] + existing.content = (content or existing.content or "")[:4000] or None + existing.source = (source or existing.source or "")[:50] or None + existing.entity_type = (entity_type or existing.entity_type or "")[:50] or None + existing.entity_id = int(entity_id) if entity_id is not None else existing.entity_id + existing.target_url = (target_url or existing.target_url or "")[:500] or None + existing.kind = (kind or existing.kind or "")[:30] or None + existing.priority = (priority or existing.priority or "")[:20] or None + existing.status = (status or existing.status or "")[:30] or None + existing.blocking_reason = (blocking_reason or existing.blocking_reason or "")[:2000] or None + existing.recommended_action = (recommended_action or existing.recommended_action or "")[:80] or None + existing.payload = payload or existing.payload + existing.occurrence_count = max(1, int(existing.occurrence_count or 1)) + max(1, int(occurrence_count or 1)) + await self.db.flush() + if commit: + await self.db.commit() + return existing + + row = Notification( + tenant_id=int(tenant_id), + user_id=int(user_id), + category=(category or "system")[:50], + title=(title or "系统通知")[:200], + content=(content or "")[:4000] or None, + source=(source or "")[:50] or None, + entity_type=(entity_type or "")[:50] or None, + entity_id=int(entity_id) if entity_id is not None else None, + target_url=(target_url or "")[:500] or None, + kind=(kind or "")[:30] or None, + priority=(priority or "")[:20] or None, + status=(status or "")[:30] or None, + blocking_reason=(blocking_reason or "")[:2000] or None, + recommended_action=(recommended_action or "")[:80] or None, + dedupe_key=normalized_dedupe_key, + occurrence_count=max(1, int(occurrence_count or 1)), + payload=payload or None, + is_read=False, + ) + self.db.add(row) + await self.db.flush() + if commit: + await self.db.commit() + return row + + async def create_notifications( + self, + *, + tenant_id: int, + user_ids: Iterable[int], + title: str, + content: Optional[str] = None, + category: str = "system", + source: Optional[str] = None, + entity_type: Optional[str] = None, + entity_id: Optional[int] = None, + target_url: Optional[str] = None, + kind: Optional[str] = None, + priority: Optional[str] = None, + status: Optional[str] = None, + blocking_reason: Optional[str] = None, + recommended_action: Optional[str] = None, + dedupe_key: Optional[str] = None, + occurrence_count: int = 1, + payload: Optional[dict[str, Any]] = None, + commit: bool = False, + ) -> list[Notification]: + rows: list[Notification] = [] + for user_id in user_ids: + if user_id is None: + continue + rows.append( + await self.create_notification( + tenant_id=tenant_id, + user_id=int(user_id), + title=title, + content=content, + category=category, + source=source, + entity_type=entity_type, + entity_id=entity_id, + target_url=target_url, + kind=kind, + priority=priority, + status=status, + blocking_reason=blocking_reason, + recommended_action=recommended_action, + dedupe_key=dedupe_key, + occurrence_count=occurrence_count, + payload=payload, + commit=False, + ) + ) + if commit and rows: + await self.db.commit() + return rows + + async def notify_signal_created( + self, + *, + tenant_id: int, + signal_id: int, + signal_content: str, + source: str = "feishu", + target_url: Optional[str] = None, + recipient_user_ids: Optional[Iterable[int]] = None, + proposer: Optional[str] = None, + occurred_at: Optional[datetime] = None, + ) -> int: + """向指定用户(或租户内全部活跃用户)发送“新需求已生成”通知。""" + user_ids = await self._resolve_recipients( + tenant_id=tenant_id, + recipient_user_ids=recipient_user_ids, + ) + if not user_ids: + return 0 + + snippet = (signal_content or "").strip() + if len(snippet) > 80: + snippet = f"{snippet[:80]}..." + if not snippet: + snippet = "飞书消息触发了新的需求信号。" + + proposer_name = (proposer or "").strip() or "未知用户" + occurred_text = self._format_cn_time(occurred_at) + title = "AI 已识别新需求" + content = ( + f"提出人:{proposer_name}\n" + f"时间:{occurred_text}\n" + f"内容:{snippet}\n" + f"信号ID:{signal_id}" + ) + + rows = await self.create_notifications( + tenant_id=tenant_id, + user_ids=user_ids, + category="requirement", + title=title, + content=content, + source=source, + entity_type="signal", + entity_id=signal_id, + target_url=target_url, + kind="info", + priority="medium", + status="notified", + recommended_action="view_result", + dedupe_key=f"signal-created:{signal_id}", + payload={ + "message_class": "info", + "project_id": None, + "project_name": None, + "summary": snippet, + }, + commit=True, + ) + return len(rows) + + @staticmethod + def _format_cn_time(dt: Optional[datetime]) -> str: + if not dt: + dt = datetime.now(timezone.utc) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + cn_tz = timezone(timedelta(hours=8)) + return dt.astimezone(cn_tz).strftime("%Y-%m-%d %H:%M:%S") + + async def _resolve_recipients( + self, + *, + tenant_id: int, + recipient_user_ids: Optional[Iterable[int]] = None, + ) -> list[int]: + if recipient_user_ids: + return [int(uid) for uid in recipient_user_ids if uid is not None] + + result = await self.db.execute( + select(User.id).where( + and_( + User.tenant_id == tenant_id, + User.is_active.is_(True), + ) + ) + ) + return [int(uid) for uid in result.scalars().all()] diff --git a/backend/app/services/openclaw_commander_service.py b/backend/app/services/openclaw_commander_service.py new file mode 100644 index 00000000..2b4273f4 --- /dev/null +++ b/backend/app/services/openclaw_commander_service.py @@ -0,0 +1,2417 @@ +"""OpenClaw 指挥层与操作执行服务。""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +import hashlib +import json +import re +import time +from types import SimpleNamespace +from typing import Any, Optional +from uuid import uuid4 + +from sqlalchemy import and_, func, or_, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from ..config.ai_prompts import PROMPT_OPENCLAW_COMMAND_DISPATCH +from ..models import ( + OperationExecutionLedger, + Project, + SkillTag, + Task, + TruthNode, + User, + UserProject, + UserSkillBinding, + UserWorkload, +) +from . import workload_service +from .adaptive_rag_service import AdaptiveRAGService +from .ai_orchestration_run_service import AIOrchestrationRunService +from .context_cache import context_cache_service +from .llm import llm_service +from .operation_catalog_service import operation_catalog_service +from .operation_log_service import log_operation +from .notification_service import NotificationService +from .skill_score_service import process_task_completion +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.title import compact_title + +SUPPORTED_COMMANDER_OPERATION_CODES = ( + "project.create", + "project.update", + "project.archive", + "project.reopen", + "task.create", + "task.assign", + "task.prioritize", + "task.status_transition", + "notification.create", + "personnel.workload_update", +) +PERSISTENT_IDEMPOTENCY_OPERATION_CODES = {"notification.create", "project.create"} +SUPPORTED_PROJECT_STATUSES = {"active", "archived", "done"} +SUPPORTED_TASK_STATUSES = {"todo", "in_progress", "review", "done", "blocked", "closed"} +DONE_TASK_STATUSES = {"done", "completed", "closed"} +SOURCE_ONBOARDING_FALLBACK_RE = re.compile( + r"(新增|增加|添加|接入|配置|注册|建立).{0,10}(信号源|来源|监控源|github|飞书|webhook|rss|url|source|channel)", + re.IGNORECASE, +) + + +def _utc_now_naive() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class OpenClawCommanderService: + """将自然语言指令转成可执行 operation plan。""" + + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def dispatch_command( + self, + *, + tenant_id: int, + operator_user: User, + project_id: int, + instruction: str, + dry_run: bool, + max_operations: int, + response_mode: str = "fast", + trace_id: Optional[str] = None, + persist_run: bool = True, + ) -> dict[str, Any]: + project_row = await self._load_project(tenant_id=tenant_id, project_id=project_id) + if project_row is None: + raise ValueError("project not found") + project = self._build_project_snapshot(project_row) + + 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, + ) + tasks, task_summary = await self._load_project_tasks(tenant_id=tenant_id, project_id=project_id, members=members) + planner_role = await llm_service._resolve_active_prompt_profile(self.db, tenant_id=tenant_id) + trace_id = str(trace_id or f"openclaw-{tenant_id}-{project_id}-{int(time.time() * 1000)}-{uuid4().hex[:8]}") + + plan, planner_status, planner_diagnostics = await self._plan_operations( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + tasks=tasks, + task_summary=task_summary, + members=members, + instruction=instruction, + dry_run=dry_run, + max_operations=max_operations, + response_mode=response_mode, + project_directory=[self._serialize_project_directory_item(project_row)], + ) + + executor = OperationExecutorService(self.db) + execution = await executor.execute_operations( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + operations=plan.get("operations") or [], + dry_run=dry_run, + trace_id=trace_id, + ) + openclaw_status = self._resolve_openclaw_status(execution=execution, dry_run=dry_run) + run_status = openclaw_status if openclaw_status in {"completed", "partial", "failed"} else "completed" + + result = { + "trace_id": trace_id, + "response_mode": response_mode, + "run_status": run_status, + "planner_status": planner_status, + "planner_diagnostics": planner_diagnostics, + "openclaw_status": openclaw_status, + "project": { + "id": str(project.id), + "title": project.title or project.name, + "status": project.status, + }, + "prompt_role": { + "role_key": str(planner_role.get("role_key") or ""), + "role_name": str(planner_role.get("role_name") or ""), + }, + "instruction": instruction, + "dry_run": bool(dry_run), + "summary": str(plan.get("summary") or "").strip(), + "confidence": self._to_float(plan.get("confidence"), default=0.0), + "task_summary": task_summary, + "member_count": len(members), + "planned_operations": len(plan.get("operations") or []), + "execution": execution, + "pending_workers": [], + } + if persist_run: + run_service = AIOrchestrationRunService(self.db) + await run_service.create_run( + tenant_id=tenant_id, + trace_id=trace_id, + run_type="openclaw_dispatch", + project_id=int(project.id), + signal_id=None, + response_mode=response_mode, + status=run_status, + planner_status=planner_status, + openclaw_status=openclaw_status, + 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=int(operator_user.id) if getattr(operator_user, "id", None) else None, + completed_at=_utc_now_naive() if run_status in {"completed", "partial", "failed"} else None, + ) + await self.db.commit() + return result + + async def dispatch_global_command( + self, + *, + tenant_id: int, + operator_user: User, + instruction: str, + dry_run: bool, + max_operations: int, + response_mode: str = "fast", + trace_id: Optional[str] = None, + persist_run: bool = False, + ) -> dict[str, Any]: + project = self._build_global_project_snapshot() + project_directory = await self._load_project_directory(tenant_id=tenant_id) + planner_role = await llm_service._resolve_active_prompt_profile(self.db, tenant_id=tenant_id) + trace_id = str(trace_id or f"openclaw-{tenant_id}-global-{int(time.time() * 1000)}-{uuid4().hex[:8]}") + + plan, planner_status, planner_diagnostics = await self._plan_operations( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + tasks=[], + task_summary={"total": 0, "active": 0, "done": 0, "blocked": 0, "sampled": 0}, + members=[], + instruction=instruction, + dry_run=dry_run, + max_operations=max_operations, + response_mode=response_mode, + project_directory=project_directory, + ) + + executor = OperationExecutorService(self.db) + execution = await executor.execute_operations( + tenant_id=tenant_id, + operator_user=operator_user, + project=None, + operations=plan.get("operations") or [], + dry_run=dry_run, + trace_id=trace_id, + ) + openclaw_status = self._resolve_openclaw_status(execution=execution, dry_run=dry_run) + run_status = openclaw_status if openclaw_status in {"completed", "partial", "failed"} else "completed" + project_ref = self._extract_project_reference_from_execution(execution) or { + "id": None, + "title": "", + "status": "", + } + result = { + "trace_id": trace_id, + "response_mode": response_mode, + "run_status": run_status, + "planner_status": planner_status, + "planner_diagnostics": planner_diagnostics, + "openclaw_status": openclaw_status, + "project": project_ref, + "prompt_role": { + "role_key": str(planner_role.get("role_key") or ""), + "role_name": str(planner_role.get("role_name") or ""), + }, + "instruction": instruction, + "dry_run": bool(dry_run), + "summary": str(plan.get("summary") or "").strip(), + "confidence": self._to_float(plan.get("confidence"), default=0.0), + "task_summary": {"total": 0, "active": 0, "done": 0, "blocked": 0, "sampled": 0}, + "member_count": 0, + "planned_operations": len(plan.get("operations") or []), + "execution": execution, + "pending_workers": [], + } + resolved_project_id = self._to_int(project_ref.get("id")) if isinstance(project_ref, dict) else None + if persist_run and resolved_project_id is not None and resolved_project_id > 0: + run_service = AIOrchestrationRunService(self.db) + await run_service.create_run( + tenant_id=tenant_id, + trace_id=trace_id, + run_type="openclaw_dispatch", + project_id=int(resolved_project_id), + signal_id=None, + response_mode=response_mode, + status=run_status, + planner_status=planner_status, + openclaw_status=openclaw_status, + 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=int(operator_user.id) if getattr(operator_user, "id", None) else None, + completed_at=_utc_now_naive() if run_status in {"completed", "partial", "failed"} else None, + ) + await self.db.commit() + return result + + @staticmethod + def _build_project_snapshot(project: Project) -> SimpleNamespace: + return SimpleNamespace( + id=int(project.id), + owner_id=int(project.owner_id) if getattr(project, "owner_id", None) else None, + title=str(project.title or "").strip(), + name=str(project.name or "").strip(), + status=str(project.status or "").strip(), + assigned_role=str(project.assigned_role or "").strip(), + description=str(project.description or "").strip(), + ) + + @staticmethod + def _build_global_project_snapshot() -> SimpleNamespace: + return SimpleNamespace( + id=0, + owner_id=None, + title="", + name="", + status="", + assigned_role="", + description="", + ) + + @staticmethod + def _serialize_project_directory_item(project: Project) -> dict[str, Any]: + return { + "project_id": int(project.id), + "title": str(project.title or project.name or "").strip(), + "status": str(project.status or "").strip(), + } + + async def _plan_operations( + self, + *, + tenant_id: int, + operator_user: User, + project: Project, + tasks: list[dict[str, Any]], + task_summary: dict[str, Any], + members: list[dict[str, Any]], + instruction: str, + dry_run: bool, + max_operations: int, + response_mode: str, + project_directory: Optional[list[dict[str, Any]]] = None, + ) -> tuple[dict[str, Any], str, dict[str, Any]]: + command_payload = { + "instruction": instruction, + "operator_user_id": int(operator_user.id), + "operator_name": str(operator_user.name or operator_user.username or operator_user.id), + "operator_role": str(operator_user.role or ""), + "dry_run": bool(dry_run), + "max_operations": int(max_operations), + } + project_payload = { + "scope": "project" if self._to_int(project.id) and int(project.id) > 0 else "global", + "project_id": int(project.id) if self._to_int(project.id) and int(project.id) > 0 else None, + "title": str(project.title or project.name or "").strip(), + "description": str(project.description or "").strip()[:400], + "status": str(project.status or "").strip(), + "assigned_role": str(project.assigned_role or "").strip(), + "task_summary": task_summary, + "candidate_projects": project_directory or [], + } + operation_catalog_payload = [ + { + "operation_code": item["operation_code"], + "description": item["description"], + "allowed_fields": sorted( + list( + ( + (item.get("input_schema") or {}).get("properties") or {} + ).keys() + ) + ), + } + for item in self._commander_operation_catalog() + ] + + prompt = PROMPT_OPENCLAW_COMMAND_DISPATCH.format( + command_json=json.dumps(command_payload, ensure_ascii=False), + project_json=json.dumps(project_payload, ensure_ascii=False), + tasks_json=json.dumps(tasks, ensure_ascii=False), + members_json=json.dumps(members, ensure_ascii=False), + operation_catalog_json=json.dumps(operation_catalog_payload, ensure_ascii=False), + max_operations=int(max_operations), + ) + + try: + extraction = await llm_service.generate_structured_object( + prompt, + db=self.db, + tenant_id=tenant_id, + function_name="openclaw_command_dispatch", + use_prompt_profile=False, + temperature=0.0, + max_tokens=520, + response_mode=response_mode, + ) + diagnostics = self._build_planner_diagnostics(extraction) + if extraction.payload is None: + fallback = await self._build_fallback_plan( + tenant_id=tenant_id, + project_id=self._to_int(project.id), + instruction=instruction, + max_operations=max_operations, + error=str(extraction.error_code or "structured_unknown"), + project_directory=project_directory, + ) + return fallback, "fallback", diagnostics + plan = extraction.payload + normalized_ops = self._normalize_operations(plan.get("operations"), max_operations=max_operations) + return ( + { + "summary": str(plan.get("summary") or "").strip() or "OpenClaw 已生成操作计划。", + "confidence": self._to_float(plan.get("confidence"), default=0.0), + "operations": normalized_ops, + }, + "planned", + diagnostics, + ) + except Exception as exc: + fallback = await self._build_fallback_plan( + tenant_id=tenant_id, + project_id=self._to_int(project.id), + instruction=instruction, + max_operations=max_operations, + error=str(exc), + project_directory=project_directory, + ) + return ( + fallback, + "fallback", + { + "used_fallback": True, + "error_code": "structured_empty", + "source_field": None, + "provider": "", + "model": "", + }, + ) + + async def _build_fallback_plan( + self, + *, + tenant_id: int, + project_id: Optional[int], + instruction: str, + max_operations: int, + error: str, + project_directory: Optional[list[dict[str, Any]]] = None, + ) -> dict[str, Any]: + instruction_text = str(instruction or "").strip() + text = instruction_text.lower() + project_ops = await self._build_project_fallback_operations( + tenant_id=tenant_id, + project_id=project_id, + instruction=instruction_text, + max_operations=max_operations, + project_directory=project_directory, + ) + if project_ops: + return { + "summary": "OpenClaw 规划失败,已回退到项目命令最小执行闭环。", + "confidence": 0.58, + "operations": project_ops, + } + if self._looks_like_signal_source_instruction(instruction_text): + if project_id is None: + return { + "summary": "当前信号源指令缺少项目上下文,系统未自动执行。", + "confidence": 0.0, + "operations": [], + } + operations = await self._build_signal_source_fallback_operations( + tenant_id=tenant_id, + project_id=int(project_id), + instruction=instruction_text, + max_operations=max_operations, + ) + if operations: + return { + "summary": "OpenClaw 规划失败,已回退为信号源接入最小闭环。", + "confidence": 0.28, + "operations": operations, + } + return { + "summary": "OpenClaw 未生成可安全执行的信号源接入动作,等待人工补充上下文。", + "confidence": 0.0, + "operations": [], + } + + unassigned_only = any(token in text for token in ("未分配", "没人负责", "unassigned")) + rebalance = any(token in text for token in ("重平衡", "优化负载", "重新分配", "调度")) + assign_like = rebalance or any(token in text for token in ("分配", "负责人", "指派", "接管")) + + if not assign_like: + return { + "summary": "当前指令缺少足够上下文,系统未执行任何动作。", + "confidence": 0.0, + "operations": [], + } + if project_id is None: + return { + "summary": "当前指令缺少项目上下文,系统未自动执行分配动作。", + "confidence": 0.0, + "operations": [], + } + + proposals = await workload_service.build_ai_optimize_preview( + self.db, + tenant_id=tenant_id, + project_id=int(project_id), + max_moves=max_operations, + unassigned_only=unassigned_only and not rebalance, + ) + operations = [ + { + "operation_code": "task.assign", + "payload": { + "task_id": int(item.get("task_id")), + "assignee_id": int(item.get("new_assignee_id")), + }, + "reason": str(item.get("reason") or "fallback_workload_optimizer"), + } + for item in proposals[:max_operations] + if self._to_int(item.get("task_id")) and self._to_int(item.get("new_assignee_id")) + ] + + if operations: + return { + "summary": "OpenClaw 规划失败,已回退到内置负载优化器生成分配计划。", + "confidence": 0.35, + "operations": operations, + } + return { + "summary": "系统未生成可安全执行的分配动作,等待补充更明确的负责人或任务上下文。", + "confidence": 0.0, + "operations": [], + } + + @staticmethod + def _build_planner_diagnostics(extraction: Any) -> dict[str, Any]: + return { + "used_fallback": extraction.error_code is not None, + "error_code": extraction.error_code, + "source_field": extraction.source_field or None, + "provider": str(extraction.provider or ""), + "model": str(extraction.model or ""), + } + + @staticmethod + def _resolve_openclaw_status(*, execution: dict[str, Any], dry_run: bool) -> str: + if dry_run: + return "planned" + failed_count = int(execution.get("failed_count") or 0) + applied_count = int(execution.get("applied_count") or 0) + skipped_count = int(execution.get("skipped_count") or 0) + if failed_count > 0 and applied_count == 0 and skipped_count == 0: + return "failed" + if failed_count > 0: + return "partial" + return "completed" + + @staticmethod + def _looks_like_signal_source_instruction(text: str) -> bool: + normalized = re.sub(r"\s+", "", str(text or "")) + if len(normalized) < 6: + return False + if "信号源" in normalized and any(token in normalized for token in ("新增", "增加", "添加", "接入", "配置", "注册", "建立")): + return True + return bool(SOURCE_ONBOARDING_FALLBACK_RE.search(normalized)) + + async def _build_signal_source_fallback_operations( + self, + *, + tenant_id: int, + project_id: int, + instruction: str, + max_operations: int, + ) -> list[dict[str, Any]]: + if max_operations <= 0: + return [] + project = await self._load_project(tenant_id=tenant_id, project_id=project_id) + if project is None: + return [] + + source_name = self._extract_signal_source_name(instruction) + charter = await self._load_latest_aligned_charter(tenant_id=tenant_id, project_id=project_id) + recipient_id = await self._resolve_signal_source_notification_recipient( + tenant_id=tenant_id, + owner_id=int(project.owner_id) if project.owner_id else None, + ) + operations: list[dict[str, Any]] = [] + + if charter is not None and len(operations) < max_operations: + operations.append( + { + "operation_code": "task.create", + "payload": { + "project_id": int(project_id), + "title": f"接入{source_name}信号源"[:120], + "description": self._build_signal_source_task_description( + source_name=source_name, + instruction=instruction, + ), + "priority": "high", + }, + "reason": "fallback_signal_source_onboarding", + } + ) + + if recipient_id is not None and len(operations) < max_operations: + operations.append( + { + "operation_code": "notification.create", + "payload": { + "user_id": int(recipient_id), + "title": f"{source_name}信号源接入待确认"[:120], + "content": self._build_signal_source_notification_content( + source_name=source_name, + instruction=instruction, + charter_ready=charter is not None, + ), + }, + "reason": "fallback_signal_source_onboarding", + } + ) + + return operations[:max_operations] + + @staticmethod + def _extract_signal_source_name(text: str) -> str: + cleaned = re.sub(r"\s+", " ", str(text or "")).strip() + if not cleaned: + return "未命名" + repo_match = re.search(r"\b([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)\b", cleaned) + if repo_match: + return repo_match.group(1)[:80] + quoted_match = re.search(r"[\"'“](.+?)[\"'”]", cleaned) + if quoted_match: + candidate = quoted_match.group(1).strip(" []【】()()::\"'“”") + if candidate: + return candidate[:80] + for pattern in ( + r"(?:新增|增加|添加|接入|配置|注册|建立)(?:一个|1个|一条|一组|新的|一个新的)?\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"^(?:一个|1个|一条|一组|一个新的|新的|新增的)\s+", + "", + candidate, + flags=re.IGNORECASE, + ) + candidate = re.sub(r"(?:信号源|来源|监控源|source|channel)$", "", candidate, flags=re.IGNORECASE).strip() + if candidate: + return candidate[:80] + lower = cleaned.lower() + if "github" in lower: + return "GitHub" + if "飞书" in cleaned or "lark" in lower: + return "Feishu" + if "webhook" in lower: + return "Webhook" + return cleaned[:80] + + @staticmethod + def _build_signal_source_task_description(*, source_name: str, instruction: str) -> str: + return ( + f"为信号源“{source_name}”完成最小接入任务。" + "包含接入配置、权限校验、数据字段确认和回流验证。" + f"原始指令:{instruction[:400]}" + )[:5000] + + @staticmethod + def _build_signal_source_notification_content(*, source_name: str, instruction: str, charter_ready: bool) -> str: + suffix = ( + "已自动生成接入任务,请确认负责人和接入边界。" + if charter_ready + else "当前项目尚未有 aligned charter,系统只先发出确认通知,避免无宪章自动建任务。" + ) + return f"OpenClaw 已识别到新增信号源请求:{source_name}。{suffix} 原始指令:{instruction[:300]}".strip() + + async def _resolve_signal_source_notification_recipient( + self, + *, + tenant_id: int, + owner_id: Optional[int], + ) -> Optional[int]: + if owner_id is not None: + owner = ( + await self.db.execute( + select(User.id).where( + and_( + User.tenant_id == tenant_id, + User.id == int(owner_id), + User.is_active.is_(True), + User.status == "active", + ) + ) + ) + ).scalar_one_or_none() + if owner is not None: + return int(owner) + + manager = ( + await self.db.execute( + select(User.id).where( + and_( + User.tenant_id == tenant_id, + User.is_active.is_(True), + User.status == "active", + User.role.in_(["BOSS", "ADMIN"]), + ) + ).order_by(User.id.asc()).limit(1) + ) + ).scalar_one_or_none() + return int(manager) if manager is not None else None + + def _commander_operation_catalog(self) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + for code in SUPPORTED_COMMANDER_OPERATION_CODES: + row = operation_catalog_service.get_operation(code) + if row is not None: + items.append(row) + return items + + 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_project_directory(self, *, tenant_id: int, limit: int = 20) -> list[dict[str, Any]]: + rows = ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == tenant_id, + Project.status != "inactive", + ) + ).order_by(Project.updated_at.desc(), Project.id.desc()).limit(limit) + ) + ).scalars().all() + return [self._serialize_project_directory_item(row) for row in rows] + + async def _build_project_fallback_operations( + self, + *, + tenant_id: int, + project_id: Optional[int], + instruction: str, + max_operations: int, + project_directory: Optional[list[dict[str, Any]]], + ) -> list[dict[str, Any]]: + if max_operations <= 0: + return [] + title = self._extract_project_create_title(instruction) + if title: + payload: dict[str, Any] = {"title": title} + description = self._extract_project_create_description(instruction=instruction, title=title) + if description: + payload["description"] = description + return [ + { + "operation_code": "project.create", + "payload": payload, + "reason": "explicit_project_create", + } + ] + + resolved_project_id = project_id or await self._resolve_project_id_from_instruction( + tenant_id=tenant_id, + instruction=instruction, + project_directory=project_directory, + ) + if resolved_project_id is None: + return [] + + status_operation = self._build_project_status_fallback_operation( + project_id=int(resolved_project_id), + instruction=instruction, + ) + return [status_operation] if status_operation else [] + + async def _resolve_project_id_from_instruction( + self, + *, + tenant_id: int, + instruction: str, + project_directory: Optional[list[dict[str, Any]]], + ) -> Optional[int]: + directory = project_directory or await self._load_project_directory(tenant_id=tenant_id, limit=50) + normalized = re.sub(r"\s+", "", str(instruction or "")).lower() + for item in directory: + title = re.sub(r"\s+", "", str(item.get("title") or "")).lower() + project_id = self._to_int(item.get("project_id")) + if project_id is None or not title or len(title) < 2: + continue + if title in normalized: + return int(project_id) + return None + + @staticmethod + def _normalize_project_title_candidate(candidate: str) -> str: + cleaned = re.sub(r"\s+", " ", str(candidate or "")).strip(" []【】()()::\"'“”") + if not cleaned: + return "" + normalized = re.sub(r"\s+", "", cleaned).lower() + generic_tokens = { + "项目", + "一个项目", + "新项目", + "项目列表", + "项目列表里面", + "项目库", + "项目台账", + "项目主数据", + "pmflow", + "pmflow项目列表", + } + if normalized in generic_tokens: + return "" + if normalized.startswith(("在项目列表", "到项目列表", "进项目列表", "在pmflow", "到pmflow", "进pmflow")): + return "" + return compact_title(cleaned, max_len=24) + + @staticmethod + def _extract_project_create_title(text: str) -> str: + cleaned = re.sub(r"\s+", " ", str(text or "")).strip() + if not cleaned: + return "" + quoted_match = re.search(r"(?:项目|新项目)[^\"“”']{0,10}[\"“”']([^\"“”']{2,40})[\"“”']", cleaned) + if quoted_match: + return OpenClawCommanderService._normalize_project_title_candidate(quoted_match.group(1)) + for pattern in ( + r"(?:帮我|请|麻烦)?(?:创建|新建|建立|开个|开一个|建个|建一个|做个|做一个)(?:一个|个)?项目(?:叫|名为|名称是|名称为|是|为)?[::,,\s]*(.+)", + r"(?:创建|新建|建立)项目[::,,\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) + if candidate: + return OpenClawCommanderService._normalize_project_title_candidate(candidate) + return "" + + @staticmethod + def _extract_project_create_description(*, instruction: str, title: str) -> str: + cleaned = re.sub(r"\s+", " ", str(instruction or "")).strip() + if not cleaned: + return "" + if title: + cleaned = cleaned.replace(title, "", 1).strip(" ,,::") + cleaned = re.sub( + r"^(?:帮我|请|麻烦)?(?:创建|新建|建立|开个|开一个|建个|建一个|做个|做一个)(?:一个|个)?项目(?:叫|名为|名称是|名称为|是|为)?", + "", + cleaned, + flags=re.IGNORECASE, + ).strip(" ,,::") + if not cleaned: + return "" + return cleaned[:4000] + + @staticmethod + def _build_project_status_fallback_operation(*, project_id: int, instruction: str) -> Optional[dict[str, Any]]: + normalized = re.sub(r"\s+", "", str(instruction or "")).lower() + if any(token in normalized for token in ("归档", "archive", "停用项目", "项目归档", "状态改成archived", "状态改为archived")): + return { + "operation_code": "project.archive", + "payload": {"project_id": int(project_id)}, + "reason": "explicit_project_archive", + } + if any( + token in normalized + for token in ( + "重新打开", + "重开", + "恢复active", + "恢复为active", + "恢复项目", + "激活项目", + "设为active", + "设成active", + "改成active", + "改为active", + "状态改成active", + "状态改为active", + "状态设为active", + ) + ): + return { + "operation_code": "project.reopen", + "payload": {"project_id": int(project_id)}, + "reason": "explicit_project_reopen", + } + if any( + token in normalized + for token in ( + "设为done", + "设成done", + "改成done", + "改为done", + "状态改成done", + "状态改为done", + "状态设为done", + "标记完成", + ) + ): + return { + "operation_code": "project.update", + "payload": {"project_id": int(project_id), "status": "done"}, + "reason": "explicit_project_done", + } + return None + + @staticmethod + def _extract_project_reference_from_execution(execution: dict[str, Any]) -> Optional[dict[str, Any]]: + 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("entity_type") or "").strip() != "project": + continue + payload = row.get("result") if isinstance(row.get("result"), dict) else {} + project_id = payload.get("project_id") or row.get("entity_id") + title = payload.get("title") or payload.get("project_title") or "" + status = payload.get("status") or "" + if project_id is None: + continue + return { + "id": str(project_id), + "title": str(title or "").strip(), + "status": str(status or "").strip(), + } + return None + + async def _load_latest_aligned_charter(self, *, tenant_id: int, project_id: int) -> Optional[TruthNode]: + return ( + await self.db.execute( + select(TruthNode).where( + and_( + TruthNode.tenant_id == 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() + + async def _load_project_tasks( + self, + *, + tenant_id: int, + project_id: int, + members: list[dict[str, Any]], + ) -> tuple[list[dict[str, Any]], dict[str, Any]]: + task_rows = ( + await self.db.execute( + select(Task).where( + and_( + Task.tenant_id == tenant_id, + Task.project_id == project_id, + ) + ).order_by(Task.id.asc()) + ) + ).scalars().all() + + member_name_map = {int(item["user_id"]): str(item.get("name") or "") for item in members if self._to_int(item.get("user_id"))} + active_tasks = [task for task in task_rows if str(task.status or "").strip().lower() not in DONE_TASK_STATUSES] + selected_tasks = active_tasks[:40] if active_tasks else task_rows[:20] + serialized = [ + { + "task_id": int(task.id), + "title": str(task.title or "").strip(), + "status": str(task.status or "").strip().lower(), + "priority": str(task.priority or "").strip().lower(), + "assignee_id": int(task.assignee_id) if task.assignee_id else None, + "assignee_name": member_name_map.get(int(task.assignee_id), "") if task.assignee_id else "", + "required_skill_tag_ids": [int(item) for item in (task.required_skill_tag_ids or []) if self._to_int(item)], + "estimated_hours": float(task.estimated_hours or 0.0), + } + for task in selected_tasks + ] + summary = { + "total": len(task_rows), + "active": len(active_tasks), + "done": len([task for task in task_rows if str(task.status or "").strip().lower() in DONE_TASK_STATUSES]), + "blocked": len([task for task in task_rows if str(task.status or "").strip().lower() == "blocked"]), + "sampled": len(serialized), + } + return serialized, summary + + 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(user.id) for user 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 ""), + "active": bool(user.is_active and str(user.status or "").strip().lower() == "active"), + "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], + } + ) + members.sort(key=lambda item: (float(item.get("workload_ratio") or 0.0), int(item.get("user_id") or 0))) + return members + + @classmethod + def _parse_plan_response(cls, 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) + if isinstance(parsed, dict): + return parsed + except Exception: + pass + object_text = cls._extract_first_json_object(content) + if not object_text: + raise ValueError("llm json not found") + parsed = json.loads(object_text) + if not isinstance(parsed, dict): + raise ValueError("llm json is not object") + return parsed + + @classmethod + def _extract_first_json_object(cls, text: str) -> Optional[str]: + raw = str(text or "") + start = raw.find("{") + if start < 0: + return None + 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 == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return raw[start : idx + 1] + return None + + def _normalize_operations(self, raw_operations: Any, *, max_operations: int) -> list[dict[str, Any]]: + if not isinstance(raw_operations, list): + return [] + normalized: list[dict[str, Any]] = [] + for item in raw_operations: + if not isinstance(item, dict): + continue + operation_code = str(item.get("operation_code") or "").strip().lower() + payload = item.get("payload") + if not operation_code or not isinstance(payload, dict): + continue + normalized.append( + { + "operation_code": operation_code, + "payload": dict(payload), + "reason": str(item.get("reason") or "").strip()[:300], + } + ) + if len(normalized) >= max_operations: + break + return normalized + + @staticmethod + def _to_int(value: Any) -> Optional[int]: + if isinstance(value, bool): + return None + try: + return int(str(value).strip()) + except Exception: + return None + + @staticmethod + def _to_float(value: Any, *, default: float = 0.0) -> float: + try: + return float(value) + except Exception: + return float(default) + + +class OperationExecutorService: + """对白名单操作做 schema 校验并执行。""" + + def __init__(self, db: AsyncSession) -> None: + self.db = db + self._project_member_cache: dict[int, set[int]] = {} + + async def execute_operations( + self, + *, + tenant_id: int, + operator_user: User, + project: Optional[Project | SimpleNamespace], + operations: list[dict[str, Any]], + dry_run: bool, + trace_id: str, + ) -> dict[str, Any]: + seen_idempotency_keys: set[str] = set() + results: list[dict[str, Any]] = [] + applied_count = 0 + skipped_count = 0 + failed_count = 0 + context_project_id = self._project_context_id(project) + + for index, item in enumerate(operations, start=1): + operation_code = str(item.get("operation_code") or "").strip().lower() + raw_payload = item.get("payload") + reason = str(item.get("reason") or "").strip() + persistent_claim: OperationExecutionLedger | None = None + + try: + payload = self._validate_and_normalize_payload(operation_code=operation_code, payload=raw_payload) + idempotency_key = self._build_idempotency_key( + operation_code=operation_code, + payload=payload, + tenant_id=tenant_id, + project_id=context_project_id, + ) + if idempotency_key in seen_idempotency_keys: + skipped_count += 1 + results.append( + { + "index": index, + "operation_code": operation_code, + "payload": payload, + "idempotency_key": idempotency_key, + "status": "skipped", + "message": "duplicate_in_request", + } + ) + continue + seen_idempotency_keys.add(idempotency_key) + + if dry_run: + results.append( + { + "index": index, + "operation_code": operation_code, + "payload": payload, + "idempotency_key": idempotency_key, + "status": "planned", + "message": reason or "dry_run", + } + ) + continue + + persistent_claim, duplicate_result = await self._claim_persistent_idempotency( + tenant_id=tenant_id, + project_id=context_project_id if context_project_id > 0 else None, + operation_code=operation_code, + payload=payload, + idempotency_key=idempotency_key, + trace_id=trace_id, + ) + if duplicate_result is not None: + skipped_count += 1 + results.append( + { + "index": index, + "operation_code": operation_code, + "payload": payload, + "idempotency_key": idempotency_key, + **duplicate_result, + } + ) + continue + + result = await self._execute_single_operation( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + operation_code=operation_code, + payload=payload, + reason=reason, + trace_id=trace_id, + idempotency_key=idempotency_key, + persistent_claim=persistent_claim, + ) + status = str(result.get("status") or "applied") + if status == "applied": + applied_count += 1 + elif status == "skipped": + skipped_count += 1 + results.append( + { + "index": index, + "operation_code": operation_code, + "payload": payload, + "idempotency_key": idempotency_key, + **result, + } + ) + except Exception as exc: + await self._safe_rollback() + failed_count += 1 + results.append( + { + "index": index, + "operation_code": operation_code, + "payload": raw_payload if isinstance(raw_payload, dict) else {}, + "status": "failed", + "message": str(exc), + } + ) + + return { + "dry_run": bool(dry_run), + "requested_count": len(operations), + "applied_count": applied_count, + "skipped_count": skipped_count, + "failed_count": failed_count, + "results": results, + } + + async def _claim_persistent_idempotency( + self, + *, + tenant_id: int, + project_id: Optional[int], + operation_code: str, + payload: dict[str, Any], + idempotency_key: str, + trace_id: str, + ) -> tuple[Optional[OperationExecutionLedger], Optional[dict[str, Any]]]: + if operation_code not in PERSISTENT_IDEMPOTENCY_OPERATION_CODES: + return None, None + + claim = OperationExecutionLedger( + tenant_id=tenant_id, + operation_code=operation_code, + project_id=int(project_id) if project_id is not None and int(project_id) > 0 else None, + idempotency_key=idempotency_key, + trace_id=trace_id, + status="processing", + payload=dict(payload), + ) + self.db.add(claim) + try: + await self.db.flush() + return claim, None + except IntegrityError: + await self._safe_rollback() + existing = await self._load_execution_claim( + tenant_id=tenant_id, + operation_code=operation_code, + idempotency_key=idempotency_key, + ) + if existing is None: + raise + if str(existing.status or "").strip().lower() == "failed": + await self.db.delete(existing) + await self.db.flush() + return await self._claim_persistent_idempotency( + tenant_id=tenant_id, + project_id=project_id, + operation_code=operation_code, + payload=payload, + idempotency_key=idempotency_key, + trace_id=trace_id, + ) + return ( + None, + { + "status": "skipped", + "message": "duplicate_persistent" + if str(existing.status or "").strip().lower() == "applied" + else "duplicate_in_progress", + "entity_type": str(existing.entity_type or "").strip() or None, + "entity_id": str(existing.entity_id or "").strip() or None, + "trace_id": str(existing.trace_id or "").strip() or None, + }, + ) + + async def _load_execution_claim( + self, + *, + tenant_id: int, + operation_code: str, + idempotency_key: str, + ) -> Optional[OperationExecutionLedger]: + return ( + await self.db.execute( + select(OperationExecutionLedger).where( + and_( + OperationExecutionLedger.tenant_id == tenant_id, + OperationExecutionLedger.operation_code == operation_code, + OperationExecutionLedger.idempotency_key == idempotency_key, + ) + ) + ) + ).scalar_one_or_none() + + async def _safe_rollback(self) -> None: + try: + await self.db.rollback() + except Exception: + return + + def _validate_and_normalize_payload(self, *, operation_code: str, payload: Any) -> dict[str, Any]: + row = operation_catalog_service.get_operation(operation_code) + if row is None or operation_code not in SUPPORTED_COMMANDER_OPERATION_CODES: + raise ValueError(f"unsupported operation_code: {operation_code}") + if not isinstance(payload, dict): + raise ValueError(f"{operation_code} payload must be object") + + input_schema = row.get("input_schema") if isinstance(row.get("input_schema"), dict) else {} + properties = input_schema.get("properties") if isinstance(input_schema.get("properties"), dict) else {} + required = [str(item) for item in (input_schema.get("required") or [])] + normalized: dict[str, Any] = {} + + if input_schema.get("additionalProperties") is False: + extra_keys = sorted(set(payload.keys()) - set(properties.keys())) + if extra_keys: + raise ValueError(f"{operation_code} payload has unsupported fields: {', '.join(extra_keys)}") + + for field, spec in properties.items(): + if field not in payload: + continue + normalized[field] = self._coerce_value(field=field, value=payload[field], spec=spec if isinstance(spec, dict) else {}) + + missing = [field for field in required if field not in normalized] + if missing: + raise ValueError(f"{operation_code} missing required fields: {', '.join(missing)}") + return normalized + + @classmethod + def _coerce_value(cls, *, field: str, value: Any, spec: dict[str, Any]) -> Any: + value_type = str(spec.get("type") or "").strip().lower() + if value_type == "integer": + parsed = cls._to_int(value) + if parsed is None: + raise ValueError(f"{field} must be integer") + return parsed + if value_type == "number": + if isinstance(value, bool): + raise ValueError(f"{field} must be number") + try: + parsed_float = float(value) + except Exception as exc: + raise ValueError(f"{field} must be number") from exc + minimum = spec.get("minimum") + maximum = spec.get("maximum") + if minimum is not None and parsed_float < float(minimum): + raise ValueError(f"{field} below minimum") + if maximum is not None and parsed_float > float(maximum): + raise ValueError(f"{field} above maximum") + return parsed_float + if value_type == "string": + text = str(value or "").strip() + if not text: + raise ValueError(f"{field} must be non-empty string") + enum_values = spec.get("enum") + if isinstance(enum_values, list) and enum_values: + enum_map = {str(item).strip().lower(): str(item) for item in enum_values} + matched = enum_map.get(text.lower()) + if matched is None: + raise ValueError(f"{field} must be one of {', '.join(str(item) for item in enum_values)}") + text = matched + min_length = spec.get("minLength") + max_length = spec.get("maxLength") + if min_length is not None and len(text) < int(min_length): + raise ValueError(f"{field} shorter than minLength") + if max_length is not None and len(text) > int(max_length): + raise ValueError(f"{field} longer than maxLength") + return text + if value_type == "array": + if not isinstance(value, list): + raise ValueError(f"{field} must be array") + max_items = spec.get("maxItems") + if max_items is not None and len(value) > int(max_items): + raise ValueError(f"{field} too many items") + item_spec = spec.get("items") if isinstance(spec.get("items"), dict) else {} + return [ + cls._coerce_value(field=field, value=item, spec=item_spec) + for item in value + ] + return value + + async def _execute_single_operation( + self, + *, + tenant_id: int, + operator_user: User, + project: Optional[Project | SimpleNamespace], + operation_code: str, + payload: dict[str, Any], + reason: str, + trace_id: str, + idempotency_key: str, + persistent_claim: Optional[OperationExecutionLedger], + ) -> dict[str, Any]: + if operation_code == "project.create": + return await self._execute_project_create( + tenant_id=tenant_id, + operator_user=operator_user, + payload=payload, + reason=reason, + trace_id=trace_id, + ) + if operation_code == "project.update": + return await self._execute_project_update( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + payload=payload, + reason=reason, + trace_id=trace_id, + ) + if operation_code == "project.archive": + return await self._execute_project_archive( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + payload=payload, + reason=reason, + trace_id=trace_id, + ) + if operation_code == "project.reopen": + return await self._execute_project_reopen( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + payload=payload, + reason=reason, + trace_id=trace_id, + ) + if operation_code == "task.assign": + return await self._execute_task_assign( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + payload=payload, + reason=reason, + trace_id=trace_id, + ) + if operation_code == "task.create": + return await self._execute_task_create( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + payload=payload, + reason=reason, + trace_id=trace_id, + ) + if operation_code == "task.prioritize": + return await self._execute_task_prioritize( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + payload=payload, + reason=reason, + trace_id=trace_id, + ) + if operation_code == "task.status_transition": + return await self._execute_task_status_transition( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + payload=payload, + reason=reason, + trace_id=trace_id, + ) + if operation_code == "notification.create": + return await self._execute_notification_create( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + payload=payload, + reason=reason, + trace_id=trace_id, + idempotency_key=idempotency_key, + persistent_claim=persistent_claim, + ) + if operation_code == "personnel.workload_update": + return await self._execute_personnel_workload_update( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + payload=payload, + reason=reason, + trace_id=trace_id, + ) + raise ValueError(f"unsupported operation_code: {operation_code}") + + async def _execute_project_create( + self, + *, + tenant_id: int, + operator_user: User, + payload: dict[str, Any], + reason: str, + trace_id: str, + ) -> dict[str, Any]: + title = compact_title(str(payload["title"]), max_len=24) + if not title: + raise ValueError("project title required") + existing = await self._load_project_by_title(tenant_id=tenant_id, title=title) + 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(), + }, + } + + owner_id = self._to_int(payload.get("owner_id")) or int(operator_user.id) + if owner_id is not None: + owner = await self._load_active_user(tenant_id=tenant_id, user_id=int(owner_id)) + if owner is None: + owner_id = int(operator_user.id) + + await ensure_project_id_sequence(self.db) + project = Project( + tenant_id=tenant_id, + name=title, + title=title, + description=str(payload.get("description") or "").strip()[:4000] or None, + owner_id=int(owner_id) if owner_id is not None else None, + 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=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=tenant_id, + user_id=int(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=f"{trace_id}:{reason}"[:1000] if reason else trace_id, + ) + await self.db.commit() + await context_cache_service.invalidate_system_context(tenant_id=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 _execute_project_update( + self, + *, + tenant_id: int, + operator_user: User, + project: Optional[Project | SimpleNamespace], + payload: dict[str, Any], + reason: str, + trace_id: str, + ) -> dict[str, Any]: + target_project = await self._load_project_for_operation( + tenant_id=tenant_id, + project=project, + payload_project_id=int(payload["project_id"]), + operation_code="project.update", + ) + before = { + "title": str(target_project.title or target_project.name or "").strip(), + "description": str(target_project.description or "").strip(), + "status": str(target_project.status or "").strip(), + } + changed = False + if "title" in payload: + next_title = compact_title(str(payload.get("title") or ""), max_len=24) + if not next_title: + raise ValueError("project title required") + if next_title != str(target_project.title or target_project.name or "").strip(): + target_project.title = next_title + target_project.name = next_title + changed = True + if "description" in payload: + next_description = str(payload.get("description") or "").strip()[:4000] or None + if next_description != (target_project.description or None): + target_project.description = next_description + changed = True + if "status" in payload: + next_status = self._normalize_project_status(payload.get("status")) + if next_status != str(target_project.status or "").strip().lower(): + target_project.status = next_status + changed = True + if not changed: + return { + "status": "skipped", + "message": "already_in_target_state", + "entity_type": "project", + "entity_id": str(target_project.id), + "result": { + "project_id": int(target_project.id), + "title": str(target_project.title or target_project.name or "").strip(), + "status": str(target_project.status or "").strip(), + }, + } + + await log_operation( + self.db, + tenant_id=tenant_id, + user_id=int(operator_user.id), + project_id=int(target_project.id), + entity_type="project", + entity_id=str(target_project.id), + action="project.update", + before_data=before, + after_data={ + "title": str(target_project.title or target_project.name or "").strip(), + "description": str(target_project.description or "").strip(), + "status": str(target_project.status or "").strip(), + }, + remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id, + ) + await self.db.commit() + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + return { + "status": "applied", + "message": f"project updated #{int(target_project.id)}", + "entity_type": "project", + "entity_id": str(target_project.id), + "result": { + "project_id": int(target_project.id), + "title": str(target_project.title or target_project.name or "").strip(), + "status": str(target_project.status or "").strip(), + }, + } + + async def _execute_project_archive( + self, + *, + tenant_id: int, + operator_user: User, + project: Optional[Project | SimpleNamespace], + payload: dict[str, Any], + reason: str, + trace_id: str, + ) -> dict[str, Any]: + return await self._execute_project_update( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + payload={"project_id": int(payload["project_id"]), "status": "archived"}, + reason=reason, + trace_id=trace_id, + ) + + async def _execute_project_reopen( + self, + *, + tenant_id: int, + operator_user: User, + project: Optional[Project | SimpleNamespace], + payload: dict[str, Any], + reason: str, + trace_id: str, + ) -> dict[str, Any]: + return await self._execute_project_update( + tenant_id=tenant_id, + operator_user=operator_user, + project=project, + payload={"project_id": int(payload["project_id"]), "status": "active"}, + reason=reason, + trace_id=trace_id, + ) + + async def _execute_task_create( + self, + *, + tenant_id: int, + operator_user: User, + project: Optional[Project | SimpleNamespace], + payload: dict[str, Any], + reason: str, + trace_id: str, + ) -> dict[str, Any]: + project = self._require_project_context(project=project, operation_code="task.create") + project_id = int(payload["project_id"]) + if project_id != int(project.id): + raise ValueError("task.create project_id mismatch") + + charter = await self._load_latest_aligned_charter(tenant_id=tenant_id, project_id=project_id) + if charter is None: + raise ValueError("aligned charter required before task.create") + + assignee_id = self._to_int(payload.get("assignee_id")) + touched_user_ids: list[int] = [] + if assignee_id is not None: + assignee = await self._load_active_user(tenant_id=tenant_id, user_id=assignee_id) + if assignee is None: + raise ValueError("assignee not found or inactive") + member_ids = await self._get_project_member_ids( + tenant_id=tenant_id, + project_id=project_id, + owner_id=int(project.owner_id) if project.owner_id else None, + ) + if int(assignee.id) not in member_ids: + raise ValueError("assignee is not project member") + touched_user_ids.append(int(assignee.id)) + + due_date = self._parse_due_date_value(payload.get("due_date")) + required_skill_tag_ids = self._safe_int_list(payload.get("required_skill_tag_ids")) + task = await persist_task( + self.db, + tenant_id=tenant_id, + truth_node_id=int(charter.id), + project_id=project_id, + draft=build_task_create_draft( + title=str(payload["title"]), + description=str(payload.get("description") or "").strip() or None, + assignee_id=assignee_id, + required_skill_tag_ids=required_skill_tag_ids, + priority=payload.get("priority"), + status="todo", + estimated_hours=self._to_float(payload.get("estimated_hours"), default=0.0) if payload.get("estimated_hours") is not None else None, + due_date=due_date, + ), + actor_user_id=int(operator_user.id), + action="task.create", + remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id, + commit=not touched_user_ids, + refresh_workload=bool(touched_user_ids), + ) + + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + for user_id in touched_user_ids: + await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=user_id) + self._sync_task_rag(task=task, tenant_id=tenant_id) + + return { + "status": "applied", + "message": f"task created #{int(task.id)}", + "entity_type": "task", + "entity_id": str(task.id), + } + + async def _execute_task_assign( + self, + *, + tenant_id: int, + operator_user: User, + project: Optional[Project | SimpleNamespace], + payload: dict[str, Any], + reason: str, + trace_id: str, + ) -> dict[str, Any]: + project = self._require_project_context(project=project, operation_code="task.assign") + task = await self._load_task(tenant_id=tenant_id, task_id=int(payload["task_id"])) + if task is None or int(task.project_id) != int(project.id): + raise ValueError("task not found in target project") + + assignee = await self._load_active_user(tenant_id=tenant_id, user_id=int(payload["assignee_id"])) + if assignee is None: + raise ValueError("assignee not found or inactive") + + member_ids = await self._get_project_member_ids(tenant_id=tenant_id, project_id=int(project.id), owner_id=int(project.owner_id) if project.owner_id else None) + if int(assignee.id) not in member_ids: + raise ValueError("assignee is not project member") + + current_assignee_id = int(task.assignee_id) if task.assignee_id else None + if current_assignee_id == int(assignee.id): + return { + "status": "skipped", + "message": "already_assigned", + "entity_type": "task", + "entity_id": str(task.id), + } + + before = { + "assignee_id": current_assignee_id, + "status": task.status, + } + task.assignee_id = int(assignee.id) + await log_operation( + self.db, + tenant_id=tenant_id, + user_id=int(operator_user.id), + project_id=int(project.id), + entity_type="task", + entity_id=str(task.id), + action="task.assign", + before_data=before, + after_data={"assignee_id": int(assignee.id), "status": task.status}, + remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id, + ) + + touched_user_ids: list[int] = [] + if current_assignee_id: + touched_user_ids.append(current_assignee_id) + touched_user_ids.append(int(assignee.id)) + for uid in touched_user_ids: + await workload_service.refresh_user_assigned_hours_from_tasks(self.db, tenant_id=tenant_id, user_id=uid) + + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + for uid in sorted(set(touched_user_ids)): + await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=uid) + self._sync_task_rag(task=task, tenant_id=tenant_id) + + return { + "status": "applied", + "message": f"task assigned to user#{int(assignee.id)}", + "entity_type": "task", + "entity_id": str(task.id), + } + + async def _execute_task_prioritize( + self, + *, + tenant_id: int, + operator_user: User, + project: Optional[Project | SimpleNamespace], + payload: dict[str, Any], + reason: str, + trace_id: str, + ) -> dict[str, Any]: + project = self._require_project_context(project=project, operation_code="task.prioritize") + task = await self._load_task(tenant_id=tenant_id, task_id=int(payload["task_id"])) + if task is None or int(task.project_id) != int(project.id): + raise ValueError("task not found in target project") + + before_priority = self._normalize_task_priority(task.priority) + next_priority = self._normalize_task_priority(payload.get("priority")) + if before_priority == next_priority: + return { + "status": "skipped", + "message": "already_in_target_priority", + "entity_type": "task", + "entity_id": str(task.id), + } + + task.priority = next_priority + await log_operation( + self.db, + tenant_id=tenant_id, + user_id=int(operator_user.id), + project_id=int(project.id), + entity_type="task", + entity_id=str(task.id), + action="task.prioritize", + before_data={"priority": before_priority}, + after_data={"priority": next_priority}, + remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id, + ) + await self.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)) + self._sync_task_rag(task=task, tenant_id=tenant_id) + + return { + "status": "applied", + "message": f"task priority updated to {next_priority}", + "entity_type": "task", + "entity_id": str(task.id), + } + + async def _execute_task_status_transition( + self, + *, + tenant_id: int, + operator_user: User, + project: Optional[Project | SimpleNamespace], + payload: dict[str, Any], + reason: str, + trace_id: str, + ) -> dict[str, Any]: + project = self._require_project_context(project=project, operation_code="task.status_transition") + task = await self._load_task(tenant_id=tenant_id, task_id=int(payload["task_id"])) + if task is None or int(task.project_id) != int(project.id): + raise ValueError("task not found in target project") + + status = str(payload["status"]).strip().lower() + if status not in SUPPORTED_TASK_STATUSES: + raise ValueError("unsupported task status") + before_status = str(task.status or "").strip().lower() + if before_status == status: + return { + "status": "skipped", + "message": "already_in_target_status", + "entity_type": "task", + "entity_id": str(task.id), + } + + task.status = status + if status in DONE_TASK_STATUSES: + task.completed_at = datetime.now(timezone.utc).replace(tzinfo=None) + + await log_operation( + self.db, + tenant_id=tenant_id, + user_id=int(operator_user.id), + project_id=int(project.id), + entity_type="task", + entity_id=str(task.id), + action="task.status_transition", + before_data={"status": before_status}, + after_data={"status": status}, + remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id, + ) + + if task.assignee_id: + await workload_service.refresh_user_assigned_hours_from_tasks( + self.db, + tenant_id=tenant_id, + user_id=int(task.assignee_id), + ) + else: + await self.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)) + self._sync_task_rag(task=task, tenant_id=tenant_id) + + if status in DONE_TASK_STATUSES and before_status not in DONE_TASK_STATUSES and task.assignee_id: + asyncio.create_task( + process_task_completion( + task_id=int(task.id), + tenant_id=tenant_id, + operator_user_id=int(operator_user.id), + ) + ) + + return { + "status": "applied", + "message": f"task status updated to {status}", + "entity_type": "task", + "entity_id": str(task.id), + } + + async def _execute_notification_create( + self, + *, + tenant_id: int, + operator_user: User, + project: Optional[Project | SimpleNamespace], + payload: dict[str, Any], + reason: str, + trace_id: str, + idempotency_key: str, + persistent_claim: Optional[OperationExecutionLedger], + ) -> dict[str, Any]: + project = self._require_project_context(project=project, operation_code="notification.create") + user = await self._load_active_user(tenant_id=tenant_id, user_id=int(payload["user_id"])) + if user is None: + raise ValueError("notification target user not found or inactive") + + notification = await NotificationService(self.db).create_notification( + tenant_id=tenant_id, + user_id=int(user.id), + category="system", + title=str(payload["title"]).strip(), + content=str(payload["content"]).strip(), + source="openclaw_commander", + entity_type="project", + entity_id=int(project.id), + target_url=f"/projects/{int(project.id)}/board", + commit=False, + ) + if persistent_claim is not None: + persistent_claim.status = "applied" + persistent_claim.entity_type = "notification" + persistent_claim.entity_id = str(notification.id) + persistent_claim.result_payload = { + "status": "applied", + "notification_id": int(notification.id), + "idempotency_key": idempotency_key, + } + persistent_claim.error_message = None + await log_operation( + self.db, + tenant_id=tenant_id, + user_id=int(operator_user.id), + project_id=int(project.id), + entity_type="notification", + entity_id=str(notification.id), + action="notification.create", + before_data=None, + after_data={ + "user_id": int(user.id), + "title": notification.title, + "source": notification.source, + }, + remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id, + ) + await self.db.commit() + + return { + "status": "applied", + "message": f"notification created for user#{int(user.id)}", + "entity_type": "notification", + "entity_id": str(notification.id), + } + + async def _execute_personnel_workload_update( + self, + *, + tenant_id: int, + operator_user: User, + project: Optional[Project | SimpleNamespace], + payload: dict[str, Any], + reason: str, + trace_id: str, + ) -> dict[str, Any]: + project = self._require_project_context(project=project, operation_code="personnel.workload_update") + user = await self._load_active_user(tenant_id=tenant_id, user_id=int(payload["user_id"])) + if user is None: + raise ValueError("target user not found or inactive") + + member_ids = await self._get_project_member_ids( + tenant_id=tenant_id, + project_id=int(project.id), + owner_id=int(project.owner_id) if project.owner_id else None, + ) + if int(user.id) not in member_ids: + raise ValueError("target user is not project member") + + before_workload = await self._load_user_workload(tenant_id=tenant_id, user_id=int(user.id)) + result = await workload_service.update_user_max_weekly_hours( + self.db, + tenant_id=tenant_id, + user_id=int(user.id), + max_weekly_hours=float(payload["max_weekly_hours"]), + ) + if result is None: + raise ValueError("target user workload not found") + + await log_operation( + self.db, + tenant_id=tenant_id, + user_id=int(operator_user.id), + project_id=int(project.id), + entity_type="user_workload", + entity_id=str(user.id), + action="personnel.workload_update", + before_data=before_workload, + after_data=result, + remark=f"{trace_id}:{reason}"[:1000] if reason else trace_id, + ) + await self.db.commit() + await context_cache_service.invalidate_system_context(tenant_id=tenant_id) + await context_cache_service.invalidate_user_context(tenant_id=tenant_id, user_id=int(user.id)) + + return { + "status": "applied", + "message": f"user#{int(user.id)} max weekly hours updated", + "entity_type": "user_workload", + "entity_id": str(user.id), + } + + async def _load_task(self, *, tenant_id: int, task_id: int) -> Optional[Task]: + return ( + await self.db.execute( + select(Task).where( + and_( + Task.tenant_id == tenant_id, + Task.id == int(task_id), + ) + ) + ) + ).scalar_one_or_none() + + async def _load_project_by_title(self, *, tenant_id: int, title: str) -> Optional[Project]: + normalized = compact_title(str(title or ""), max_len=24) + if not normalized: + return None + return ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == tenant_id, + or_( + func.lower(Project.title) == normalized.lower(), + func.lower(Project.name) == normalized.lower(), + ), + Project.status != "inactive", + ) + ).limit(1) + ) + ).scalar_one_or_none() + + async def _load_project_for_operation( + self, + *, + tenant_id: int, + project: Optional[Project | SimpleNamespace], + payload_project_id: int, + operation_code: str, + ) -> Project: + context_project_id = self._project_context_id(project) + if context_project_id > 0 and context_project_id != int(payload_project_id): + raise ValueError(f"{operation_code} project_id mismatch") + row = await self._load_project(tenant_id=tenant_id, project_id=int(payload_project_id)) + if row is None: + raise ValueError("project not found") + return row + + async def _load_active_user(self, *, tenant_id: int, user_id: int) -> Optional[User]: + return ( + await self.db.execute( + select(User).where( + and_( + User.tenant_id == tenant_id, + User.id == int(user_id), + User.is_active.is_(True), + User.status == "active", + ) + ) + ) + ).scalar_one_or_none() + + async def _load_user_workload(self, *, tenant_id: int, user_id: int) -> Optional[dict[str, Any]]: + row = ( + await self.db.execute( + select(UserWorkload).where( + and_( + UserWorkload.tenant_id == tenant_id, + UserWorkload.user_id == int(user_id), + ) + ) + ) + ).scalar_one_or_none() + if row is None: + return None + return { + "user_id": str(user_id), + "current_assigned_hours": float(row.current_assigned_hours or 0.0), + "max_weekly_hours": float(row.max_available_hours_per_week or 40.0), + "workload_ratio": float(row.workload_ratio or 0.0), + } + + async def _get_project_member_ids(self, *, tenant_id: int, project_id: int, owner_id: Optional[int]) -> set[int]: + cached = self._project_member_cache.get(int(project_id)) + if cached is not None: + return set(cached) + rows = await self.db.execute( + select(UserProject.user_id).where( + and_( + UserProject.tenant_id == tenant_id, + UserProject.project_id == int(project_id), + ) + ) + ) + member_ids = {int(uid) for uid in rows.scalars().all()} + if owner_id: + member_ids.add(int(owner_id)) + self._project_member_cache[int(project_id)] = set(member_ids) + return member_ids + + async def _load_latest_aligned_charter(self, *, tenant_id: int, project_id: int) -> Optional[TruthNode]: + return ( + await self.db.execute( + select(TruthNode).where( + and_( + TruthNode.tenant_id == 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() + + @staticmethod + def _project_context_id(project: Optional[Project | SimpleNamespace]) -> int: + if project is None: + return 0 + try: + return max(0, int(getattr(project, "id", 0) or 0)) + except Exception: + return 0 + + def _require_project_context( + self, + *, + project: Optional[Project | SimpleNamespace], + operation_code: str, + ) -> Project | SimpleNamespace: + if self._project_context_id(project) <= 0: + raise ValueError(f"{operation_code} requires project context") + if project is None: + raise ValueError(f"{operation_code} requires project context") + return project + + def _build_idempotency_key( + self, + *, + operation_code: str, + payload: dict[str, Any], + tenant_id: int, + project_id: int, + ) -> str: + row = operation_catalog_service.get_operation(operation_code) + fields = [] + if isinstance(row, dict): + fields = [str(item) for item in ((row.get("idempotency_key") or {}).get("fields") or [])] + + parts: dict[str, Any] = {} + for field in fields: + if field == "tenant_id": + parts[field] = int(tenant_id) + elif field == "project_id": + parts[field] = int(project_id) + elif field == "content_hash": + parts[field] = hashlib.sha256(str(payload.get("content") or "").strip().encode("utf-8")).hexdigest()[:16] + else: + parts[field] = payload.get(field) + encoded = json.dumps(parts, ensure_ascii=False, sort_keys=True, default=str) + return hashlib.sha256(f"{operation_code}|{encoded}".encode("utf-8")).hexdigest()[:24] + + def _sync_task_rag(self, *, task: Task, tenant_id: int) -> None: + try: + content = ( + (task.description or "") + + f"\n状态: {task.status}" + + f"\n负责人: {int(task.assignee_id) if task.assignee_id else '未分配'}" + ) + asyncio.create_task( + AdaptiveRAGService.upsert_pmflow_record_async( + tenant_id=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 _to_int(value: Any) -> Optional[int]: + if isinstance(value, bool): + return None + try: + return int(str(value).strip()) + except Exception: + return None + + @staticmethod + def _safe_int_list(value: Any) -> list[int]: + if not isinstance(value, list): + return [] + items: list[int] = [] + for raw in value: + try: + items.append(int(raw)) + except Exception: + continue + return items + + @classmethod + def _normalize_task_priority(cls, value: Any) -> str: + text = str(value or "medium").strip().lower() + if text in {"p0", "critical", "high"}: + return "high" + if text in {"p2", "p3", "low"}: + return "low" + return "medium" + + @staticmethod + def _normalize_project_status(value: Any) -> str: + text = str(value or "active").strip().lower() + if text not in SUPPORTED_PROJECT_STATUSES: + raise ValueError("unsupported project status") + return text + + @staticmethod + def _parse_due_date_value(value: Any) -> Optional[datetime]: + text = str(value or "").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 diff --git a/backend/app/services/operation_catalog_service.py b/backend/app/services/operation_catalog_service.py new file mode 100644 index 00000000..ab51847b --- /dev/null +++ b/backend/app/services/operation_catalog_service.py @@ -0,0 +1,89 @@ +"""Operation Catalog 服务。""" + +from __future__ import annotations + +from collections import Counter +from typing import Any, Optional + +from ..config.operation_catalog import OPERATION_CATALOG, OperationCatalogEntry + + +REQUIRED_OPERATION_CODES = { + "project.create", + "project.update", + "project.archive", + "project.reopen", + "task.create", + "task.update", + "task.assign", + "task.prioritize", + "task.status_transition", + "task.delete", + "personnel.role_update", + "personnel.workload_update", + "personnel.availability_update", + "knowledge.doc_upsert", + "knowledge.chunk_upsert", + "knowledge.link_upsert", + "knowledge.reindex", + "notification.create", + "notification.ack", + "notification.close", + "file.write_whitelist", +} + + +class OperationCatalogService: + def __init__(self) -> None: + self._catalog: tuple[OperationCatalogEntry, ...] = OPERATION_CATALOG + + def list_operations(self, *, domain: Optional[str] = None) -> list[dict[str, Any]]: + rows = self._catalog + if domain: + domain_key = str(domain).strip().lower() + rows = tuple( + row for row in rows + if str(row.domain).strip().lower() == domain_key + ) + return [row.model_dump() for row in rows] + + def get_operation(self, operation_code: str) -> Optional[dict[str, Any]]: + target = str(operation_code or "").strip().lower() + for row in self._catalog: + if str(row.operation_code).strip().lower() == target: + return row.model_dump() + return None + + def lint_catalog(self) -> dict[str, Any]: + codes = [row.operation_code for row in self._catalog] + domains = [str(row.domain) for row in self._catalog] + code_counter = Counter(codes) + duplicate_codes = sorted([code for code, count in code_counter.items() if count > 1]) + missing_required = sorted(REQUIRED_OPERATION_CODES - set(codes)) + + invalid_entries: list[str] = [] + for row in self._catalog: + if not row.operation_code: + invalid_entries.append("empty_operation_code") + if not row.input_schema: + invalid_entries.append(f"{row.operation_code}:missing_input_schema") + if not row.rbac_policy: + invalid_entries.append(f"{row.operation_code}:missing_rbac_policy") + if not row.idempotency_key.fields: + invalid_entries.append(f"{row.operation_code}:missing_idempotency_fields") + if not row.audit_fields: + invalid_entries.append(f"{row.operation_code}:missing_audit_fields") + + ok = not duplicate_codes and not missing_required and not invalid_entries + return { + "ok": ok, + "operation_count": len(self._catalog), + "domain_count": len(set(domains)), + "domains": sorted(set(domains)), + "duplicate_codes": duplicate_codes, + "missing_required_codes": missing_required, + "invalid_entries": invalid_entries, + } + + +operation_catalog_service = OperationCatalogService() diff --git a/backend/app/services/project_knowledge_graph.py b/backend/app/services/project_knowledge_graph.py new file mode 100644 index 00000000..2c6ec012 --- /dev/null +++ b/backend/app/services/project_knowledge_graph.py @@ -0,0 +1,1769 @@ +"""Project Knowledge Graph service. + +第一阶段目标: +1. 构建统一实体/关系/证据写入能力 +2. 支持结构化数据(project/task/signal/user)同步 +3. 支持代码目录扫描快照 +4. 提供图谱概览统计 +""" + +from __future__ import annotations + +import hashlib +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 + + +def _utc_now_naive() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +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 + + 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"]} + + ended = _utc_now_naive() + if success_count == 6: + 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 + + 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(), + } diff --git a/backend/app/services/prompt_profile_service.py b/backend/app/services/prompt_profile_service.py new file mode 100644 index 00000000..73b6f15c --- /dev/null +++ b/backend/app/services/prompt_profile_service.py @@ -0,0 +1,241 @@ +"""AI 角色 Prompt 配置服务。""" + +from __future__ import annotations + +import json +import re +from datetime import datetime, timezone +from typing import Any, Optional + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..config.ai_prompts import build_default_role_prompt_bundle +from ..models import Integration + +INTEGRATION_TYPE = "ai_prompt_profiles" +ROLE_KEY_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{1,31}$") +MAX_ROLES = 12 + + +def _safe_json(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + try: + return json.loads(json.dumps(value)) + except Exception: + return dict(value) + if isinstance(value, str): + try: + parsed = json.loads(value) + if isinstance(parsed, dict): + return parsed + except Exception: + return {} + return {} + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +class PromptProfileService: + """管理租户级 AI 角色 Prompt 配置。""" + + async def get_profiles(self, db: AsyncSession, *, tenant_id: int) -> dict[str, Any]: + integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) + normalized = self._normalize_bundle(bundle) + if normalized != bundle: + integration.config = json.loads(json.dumps(normalized, ensure_ascii=False)) + integration.is_active = True + await db.commit() + return normalized + + async def update_profiles( + self, + db: AsyncSession, + *, + tenant_id: int, + roles: list[dict[str, Any]], + active_role_key: Optional[str] = None, + ) -> dict[str, Any]: + if not roles: + raise ValueError("至少需要保留一个角色") + if len(roles) > MAX_ROLES: + raise ValueError(f"角色数量不能超过 {MAX_ROLES} 个") + + dedup: dict[str, dict[str, Any]] = {} + for idx, raw in enumerate(roles): + key = str(raw.get("role_key") or "").strip().lower() + if not key: + raise ValueError(f"第 {idx + 1} 个角色缺少 role_key") + if not ROLE_KEY_RE.match(key): + raise ValueError(f"角色标识 {key} 格式不正确,仅支持小写字母/数字/_/-") + if key in dedup: + raise ValueError(f"角色标识重复: {key}") + + role_name = str(raw.get("role_name") or "").strip() + system_prompt = str(raw.get("system_prompt") or "").strip() + if not role_name: + raise ValueError(f"角色 {key} 缺少角色名称") + if len(role_name) > 32: + raise ValueError(f"角色 {key} 名称过长(最多 32 字)") + if len(system_prompt) < 20: + raise ValueError(f"角色 {key} 的提示词过短(至少 20 字)") + if len(system_prompt) > 8000: + raise ValueError(f"角色 {key} 的提示词过长(最多 8000 字)") + + role = { + "role_key": key, + "role_name": role_name, + "role_description": str(raw.get("role_description") or "").strip()[:300], + "system_prompt": system_prompt, + "enabled": bool(raw.get("enabled", True)), + "updated_at": str(raw.get("updated_at") or _now_iso()), + } + dedup[key] = role + + ordered_roles = list(dedup.values()) + enabled_keys = [str(r["role_key"]) for r in ordered_roles if bool(r.get("enabled"))] + if not enabled_keys: + raise ValueError("至少需要启用一个角色") + + requested_active = str(active_role_key or "").strip().lower() + if requested_active and requested_active in enabled_keys: + active = requested_active + else: + active = enabled_keys[0] + + bundle = { + "version": 1, + "active_role_key": active, + "roles": ordered_roles, + "updated_at": _now_iso(), + } + + integration, _ = await self._get_or_init_bundle(db, tenant_id=tenant_id) + integration.config = json.loads(json.dumps(bundle, ensure_ascii=False)) + integration.is_active = True + await db.commit() + return self._normalize_bundle(bundle) + + async def get_active_role(self, db: AsyncSession, *, tenant_id: int) -> dict[str, Any]: + data = await self.get_profiles(db, tenant_id=tenant_id) + active_key = str(data.get("active_role_key") or "") + roles = data.get("roles", []) + if not isinstance(roles, list): + roles = [] + active_role = next((r for r in roles if str(r.get("role_key")) == active_key), None) + if active_role is None and roles: + active_role = roles[0] + return { + "active_role_key": active_key, + "role": active_role, + } + + 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 == int(tenant_id), + Integration.type == INTEGRATION_TYPE, + ) + ) + ) + integration = result.scalar_one_or_none() + + if integration is None: + bundle = self._normalize_bundle(build_default_role_prompt_bundle()) + integration = Integration( + tenant_id=int(tenant_id), + type=INTEGRATION_TYPE, + config=json.loads(json.dumps(bundle, ensure_ascii=False)), + is_active=True, + ) + db.add(integration) + await db.commit() + return integration, bundle + + bundle = self._normalize_bundle(_safe_json(integration.config)) + return integration, bundle + + def _normalize_bundle(self, raw: dict[str, Any]) -> dict[str, Any]: + defaults = build_default_role_prompt_bundle() + default_roles_raw = defaults.get("roles", []) + default_roles: dict[str, dict[str, Any]] = {} + if isinstance(default_roles_raw, list): + for row in default_roles_raw: + if not isinstance(row, dict): + continue + key = str(row.get("role_key") or "").strip().lower() + if key: + default_roles[key] = { + "role_key": key, + "role_name": str(row.get("role_name") or key)[:32], + "role_description": str(row.get("role_description") or "")[:300], + "system_prompt": str(row.get("system_prompt") or "").strip(), + "enabled": bool(row.get("enabled", True)), + "updated_at": str(row.get("updated_at") or _now_iso()), + } + + incoming_roles = raw.get("roles") if isinstance(raw.get("roles"), list) else [] + roles: list[dict[str, Any]] = [] + seen: set[str] = set() + + for item in incoming_roles: + if not isinstance(item, dict): + continue + key = str(item.get("role_key") or "").strip().lower() + if not key or key in seen or not ROLE_KEY_RE.match(key): + continue + seen.add(key) + + fallback = default_roles.get(key, {}) + prompt_text = str(item.get("system_prompt") or fallback.get("system_prompt") or "").strip() + if len(prompt_text) < 20: + prompt_text = str(fallback.get("system_prompt") or "").strip() + if len(prompt_text) < 20: + continue + + roles.append( + { + "role_key": key, + "role_name": str(item.get("role_name") or fallback.get("role_name") or key).strip()[:32] or key, + "role_description": str(item.get("role_description") or fallback.get("role_description") or "").strip()[:300], + "system_prompt": prompt_text[:8000], + "enabled": bool(item.get("enabled", fallback.get("enabled", True))), + "updated_at": str(item.get("updated_at") or _now_iso()), + } + ) + if len(roles) >= MAX_ROLES: + break + + if not roles: + roles = list(default_roles.values())[:MAX_ROLES] + + enabled_keys = [str(r["role_key"]) for r in roles if bool(r.get("enabled"))] + if not enabled_keys and roles: + roles[0]["enabled"] = True + enabled_keys = [str(roles[0]["role_key"])] + + active_raw = str(raw.get("active_role_key") or "").strip().lower() + active_role_key = active_raw if active_raw in enabled_keys else (enabled_keys[0] if enabled_keys else str(roles[0]["role_key"])) + + return { + "version": 1, + "active_role_key": active_role_key, + "roles": roles, + "updated_at": str(raw.get("updated_at") or _now_iso()), + "tips": { + "active_role_key": "当前生效角色,影响 AI 回复语气与任务分派风格。", + "system_prompt": "建议写清角色职责、回复风格、输出边界;支持后续扩展多个角色。", + "role_key": "角色标识用于系统路由,保存后建议保持稳定。", + }, + } + + +prompt_profile_service = PromptProfileService() diff --git a/backend/app/services/recipe_service.py b/backend/app/services/recipe_service.py new file mode 100644 index 00000000..f437ba73 --- /dev/null +++ b/backend/app/services/recipe_service.py @@ -0,0 +1,266 @@ +"""食谱服务 - 包含热量识别与营养分析""" + +import base64 +import io +from typing import Optional +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import Recipe +from ..schemas import RecipeCreate, RecipeUpdate, RecipeResponse +from .ai_service import AIService +from ..config.ai_prompts import PROMPT_ANALYZE_FOOD_CALORIES + + +class RecipeService: + """食谱服务""" + + def __init__(self, ai_service: AIService): + self.ai_service = ai_service + + async def create_recipe( + self, + db: AsyncSession, + recipe_data: RecipeCreate, + tenant_id: int, + ) -> Recipe: + """创建食谱""" + recipe = Recipe( + **recipe_data.model_dump(), + tenant_id=tenant_id, + status="draft", + ) + db.add(recipe) + await db.commit() + await db.refresh(recipe) + return recipe + + async def get_recipe( + self, + db: AsyncSession, + recipe_id: int, + tenant_id: int, + ) -> Optional[Recipe]: + """获取单个食谱""" + result = await db.execute( + select(Recipe).where( + Recipe.id == recipe_id, + Recipe.tenant_id == tenant_id, + ) + ) + return result.scalar_one_or_none() + + async def list_recipes( + self, + db: AsyncSession, + tenant_id: int, + skip: int = 0, + limit: int = 20, + category: Optional[str] = None, + cuisine: Optional[str] = None, + status: Optional[str] = None, + is_favorite: Optional[bool] = None, + ) -> list[Recipe]: + """获取食谱列表""" + query = select(Recipe).where(Recipe.tenant_id == tenant_id) + + if category: + query = query.where(Recipe.category == category) + if cuisine: + query = query.where(Recipe.cuisine == cuisine) + if status: + query = query.where(Recipe.status == status) + if is_favorite is not None: + query = query.where(Recipe.is_favorite == is_favorite) + + query = query.order_by(Recipe.created_at.desc()).offset(skip).limit(limit) + result = await db.execute(query) + return list(result.scalars().all()) + + async def update_recipe( + self, + db: AsyncSession, + recipe_id: int, + recipe_data: RecipeUpdate, + tenant_id: int, + ) -> Optional[Recipe]: + """更新食谱""" + recipe = await self.get_recipe(db, recipe_id, tenant_id) + if not recipe: + return None + + update_data = recipe_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(recipe, field, value) + + await db.commit() + await db.refresh(recipe) + return recipe + + async def delete_recipe( + self, + db: AsyncSession, + recipe_id: int, + tenant_id: int, + ) -> bool: + """删除食谱""" + recipe = await self.get_recipe(db, recipe_id, tenant_id) + if not recipe: + return False + + await db.delete(recipe) + await db.commit() + return True + + async def analyze_food_from_description( + self, + db: AsyncSession, + description: str, + tenant_id: int, + ) -> dict: + """ + 基于食物描述分析热量和营养成分 + + 注意:此方法使用纯文本分析。对于图片识别, + 建议先使用图像识别服务将图片转为描述文本,再调用此方法。 + """ + tid = str(tenant_id) + prompt = PROMPT_ANALYZE_FOOD_CALORIES.format(food_description=description) + + try: + response_text = await self.ai_service._invoke_with_retry( + prompt, + db=db, + tenant_id=tid, + function_name="analyze_food_calories", + use_cache=True, + ) + result = self.ai_service._parse_json(response_text) + return { + "success": True, + "data": result, + "message": "分析成功", + } + except Exception as e: + return { + "success": False, + "data": None, + "message": f"分析失败: {str(e)}", + } + + async def create_recipe_with_ai_analysis( + self, + db: AsyncSession, + recipe_data: RecipeCreate, + food_description: str, + tenant_id: int, + ) -> tuple[Recipe, dict]: + """ + 创建食谱并进行 AI 热量分析 + + Args: + db: 数据库会话 + recipe_data: 食谱基础数据 + food_description: 食物描述(可以是图片识别后的描述) + tenant_id: 租户ID + + Returns: + (Recipe, analysis_result): 创建的食谱对象和分析结果 + """ + # 先创建食谱 + recipe = await self.create_recipe(db, recipe_data, tenant_id) + + # 进行 AI 分析 + analysis = await self.analyze_food_from_description( + db, food_description, tenant_id + ) + + if analysis["success"] and analysis["data"]: + # 更新食谱的营养信息 + data = analysis["data"] + recipe.calories = data.get("calories") + recipe.protein = data.get("protein") + recipe.fat = data.get("fat") + recipe.carbs = data.get("carbs") + recipe.fiber = data.get("fiber") + recipe.ai_recognition_confidence = data.get("confidence") + recipe.ai_analysis_result = data + recipe.category = data.get("category") or recipe_data.category + recipe.cuisine = data.get("cuisine") or recipe_data.cuisine + + await db.commit() + await db.refresh(recipe) + + return recipe, analysis + + def decode_base64_image(self, image_base64: str) -> tuple[bytes, str]: + """ + 解码 Base64 图片数据 + + Args: + image_base64: Base64 编码的图片数据(可能包含 data:image/xxx;base64, 前缀) + + Returns: + (image_bytes, mime_type): 图片字节数据和 MIME 类型 + """ + # 移除可能的数据 URI 前缀 + if "," in image_base64: + header, image_base64 = image_base64.split(",", 1) + # 提取 MIME 类型 + mime_type = header.split(";")[0].split(":")[-1] if ":" in header else "image/jpeg" + else: + mime_type = "image/jpeg" + + # 解码 Base64 + image_bytes = base64.b64decode(image_base64) + return image_bytes, mime_type + + async def upload_image( + self, + image_base64: str, + filename: Optional[str] = None, + ) -> str: + """ + 上传图片并返回 URL + + 注意:这是一个示例实现,实际项目中应该集成: + - AWS S3 + - 阿里云 OSS + - 腾讯云 COS + - MinIO + 等对象存储服务 + + Args: + image_base64: Base64 编码的图片数据 + filename: 原始文件名 + + Returns: + 图片 URL + """ + # TODO: 集成实际的文件存储服务 + # 这里只是示例,实际应该上传到对象存储 + image_bytes, mime_type = self.decode_base64_image(image_base64) + + # 示例:生成一个模拟 URL + # 实际应该上传后返回真实的 URL + import hashlib + import time + + file_hash = hashlib.md5(image_bytes).hexdigest() + timestamp = int(time.time()) + mock_url = f"https://example.com/recipe-images/{file_hash}_{timestamp}.jpg" + + return mock_url + + +# 单例 +_recipe_service: Optional[RecipeService] = None + + +def get_recipe_service() -> RecipeService: + """获取食谱服务单例""" + global _recipe_service + if _recipe_service is None: + from .ai_service import get_ai_service + _recipe_service = RecipeService(ai_service=get_ai_service()) + return _recipe_service diff --git a/backend/app/services/scheduler_center.py b/backend/app/services/scheduler_center.py index 543bbd5d..ff4ea6bd 100644 --- a/backend/app/services/scheduler_center.py +++ b/backend/app/services/scheduler_center.py @@ -3,12 +3,18 @@ from __future__ import annotations 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 @@ -92,3 +98,351 @@ class WorkloadRecalcScheduler: "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) diff --git a/backend/app/services/signal_capture.py b/backend/app/services/signal_capture.py index 93dcbb3c..b53e7db5 100644 --- a/backend/app/services/signal_capture.py +++ b/backend/app/services/signal_capture.py @@ -14,19 +14,158 @@ Proactive 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 text +from sqlalchemy import and_, text +from ..core.logging import get_logger from ..database import AsyncSessionLocal from ..models import ( Signal, Project, ProjectDomain, User, - ProjectSignalLink, Task + 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 + + +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: @@ -41,7 +180,12 @@ class ProactiveSignalCaptureService: group_chat_id: str, message_content: str, sender: str, - tenant_id: int = 1 + 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]: """ 从飞书群聊捕获信号 @@ -51,50 +195,574 @@ class ProactiveSignalCaptureService: - 讨论架构、技术选型 - 分享技术文章 """ - # AI 判断是否为有价值信号 - is_signal = await self._analyze_chat_message( - message_content, + trace_id = f"feishu-{uuid4().hex[:12]}" + captured_dt = captured_at or datetime.now(timezone.utc) + if captured_dt.tzinfo is None: + captured_dt = captured_dt.replace(tzinfo=timezone.utc) + + 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, ) - if not is_signal['is_valuable']: - return {'status': 'ignored', 'reason': is_signal['reason']} + 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")} # 创建信号 - from sqlalchemy import text - next_signal_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM signals"))).scalar() or 1 + 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( - id=next_signal_id, - source='Feishu', - content=f"[{sender}]: {message_content[:200]}", - impact_score=is_signal['impact_score'], - priority=is_signal['priority'], - processed=True, - strategic_analysis=json.dumps({ - 'captured_at': datetime.now(timezone.utc).isoformat(), - 'source_context': { - 'group_id': group_chat_id, - 'sender': sender - }, - 'ai_analysis': is_signal.get('analysis', {}) - }), - tasks_generated=json.dumps([]), + 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) - # 自动关联到相关项目 - await self._auto_link_to_projects(signal.id, is_signal, tenant_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)) - return { - 'signal_id': str(signal.id), - 'status': 'captured', - 'impact_score': signal.impact_score, - 'auto_tasks': is_signal.get('suggested_tasks', []) + 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("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, @@ -125,11 +793,9 @@ class ProactiveSignalCaptureService: return {'status': 'ignored', 'reason': analysis['reason']} # 创建信号 - from sqlalchemy import text - next_signal_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM signals"))).scalar() or 1 + await ensure_signal_id_sequence(self.db) signal = Signal( - id=next_signal_id, source='Browser', content=f"[浏览] {title}: {content_preview[:200]}", impact_score=analysis['impact_score'], @@ -184,11 +850,9 @@ class ProactiveSignalCaptureService: return {'status': 'ignored', 'reason': analysis['reason']} # 创建信号 - from sqlalchemy import text - next_signal_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM signals"))).scalar() or 1 + await ensure_signal_id_sequence(self.db) signal = Signal( - id=next_signal_id, source='GitHub', content=f"[{event_type}] {repo_url}: {event_data.get('title', event_data.get('message', ''))[:200]}", impact_score=analysis['impact_score'], @@ -228,7 +892,15 @@ class ProactiveSignalCaptureService: try: # 抓取 HackerNews 首页 from .fetcher import fetch_hn_stories - stories = await asyncio.to_thread(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 分析每条故事 @@ -237,13 +909,11 @@ class ProactiveSignalCaptureService: tenant_id=tenant_id, ) - if analysis['is_strategic']: + if analysis.get('is_valuable'): # 创建信号 - from sqlalchemy import text - next_signal_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM signals"))).scalar() or 1 + await ensure_signal_id_sequence(self.db) signal = Signal( - id=next_signal_id, source='HackerNews', content=f"HN: {story.get('title', '')[:200]}", impact_score=analysis['impact_score'], @@ -261,53 +931,997 @@ class ProactiveSignalCaptureService: self.db.add(signal) await self.db.commit() - print(f"[SignalCapture] Captured HN story: {story.get('title')[:50]}") + self._emit_ai_think( + "hn_story_captured", + title=str(story.get("title") or "")[:50], + ) # 等待下次抓取 await asyncio.sleep(interval_minutes * 60) except Exception as e: - print(f"[SignalCapture] HackerNews monitor error: {e}") + self._emit_ai_think("hn_monitor_error", error=str(e)) await asyncio.sleep(interval_minutes * 60) - async def _analyze_chat_message(self, message: str, tenant_id: int) -> Dict[str, Any]: - """AI 分析聊天消息是否有价值""" - prompt = f"""分析以下群聊消息是否包含有价值的项目或技术信号: + 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 -消息:{message} + 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(datetime.now(timezone.utc)) + print( + f"[{now_text}][AI思考][SignalCapture] {stage_zh} " + + json.dumps({"stage": stage, **safe_payload}, ensure_ascii=False) + ) + except Exception: + pass -请判断: -1. 是否与项目、产品、技术相关? -2. 是否包含重要信息或决策? -3. 影响程度(0-1)? + @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)" -以 JSON 格式返回: -{{ - "is_valuable": boolean, - "reason": "原因说明", - "impact_score": 0.8, - "priority": "high/medium/low", - "analysis": {{}}, - "suggested_tasks": [{{"task_name": "任务", "type": "backend", "estimated_hours": 8}}] -}} + 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, + } -如果不是有价值信号,is_valuable 设为 false。""" + 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", ) - # 清理可能的 markdown - 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 分析失败'} + 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=timezone.utc) + 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 分析网页价值""" @@ -442,40 +2056,217 @@ URL: {url} self, signal_id: int, analysis: Dict[str, Any], - tenant_id: int - ): + tenant_id: int, + message_content: Optional[str] = None, + ) -> List[int]: """自动关联信号到相关项目""" - # 根据信号内容中的关键词查找相关项目 - # 这里简化处理:将信号关联到所有活跃项目 - result = await self.db.execute( - select(Project).where( - Project.status == 'active', - Project.tenant_id == tenant_id - ) - ) - projects = result.scalars().all() + _ = message_content + classification = str(analysis.get("classification") or "").lower() + if classification in {"new_project", "pending_classification", "non_requirement"}: + return [] - from sqlalchemy import text - for project in projects: - # 检查是否已存在关联 + 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} + {"project_id": project_id, "signal_id": signal_id}, ) if existing.first() is None: - next_link_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM project_signal_links"))).scalar() or 1 from ..models import ProjectSignalLink link = ProjectSignalLink( - id=next_link_id, - project_id=project.id, + project_id=project_id, signal_id=signal_id, - link_type='auto_captured', - relevance_score=0.7, - tenant_id=tenant_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 datetime.now(timezone.utc) + if requested_dt.tzinfo is None: + requested_dt = requested_dt.replace(tzinfo=timezone.utc) + 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_source_orchestration_service.py b/backend/app/services/signal_source_orchestration_service.py new file mode 100644 index 00000000..5b926d2d --- /dev/null +++ b/backend/app/services/signal_source_orchestration_service.py @@ -0,0 +1,1927 @@ +"""信号源接入编排服务。""" + +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 + +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 datetime.now(timezone.utc).replace(tzinfo=None) + + +class SignalSourceOrchestrationService: + """把“新增信号源”转换为 OpenClaw 指挥与下级 AI worker 执行链。""" + + 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) < 6: + 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": [], + "openclaw": {"status": "skipped", "reason": "not_signal_source_request"}, + "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", + openclaw_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": [], + "openclaw": {"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, + openclaw_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) + openclaw_result = await self._dispatch_openclaw( + tenant_id=tenant_id, + operator_user=operator_ref, + project=project_ref, + instruction=str(plan.get("openclaw_instruction") or "").strip(), + dry_run=dry_run, + response_mode=response_mode, + ) + openclaw_status = str(openclaw_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, + openclaw_result=openclaw_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, + openclaw_result=openclaw_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, + openclaw_status=openclaw_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, + openclaw_result=openclaw_result, + worker_results=[], + ) + final_status = self._resolve_orchestration_status(openclaw_status=openclaw_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, + openclaw_result=openclaw_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, + openclaw_status=openclaw_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, + openclaw_status=openclaw_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(openclaw_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=420, + response_mode=response_mode, + ) + diagnostics = self._build_planner_diagnostics(extraction) + if extraction.payload is None: + return ( + self._heuristic_onboarding_plan( + initiating_text=initiating_text, + requested_via=requested_via, + error=str(extraction.error_code or "structured_unknown"), + ), + "fallback", + diagnostics, + ) + 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: + return ( + self._heuristic_onboarding_plan( + initiating_text=initiating_text, + requested_via=requested_via, + error=str(exc), + ), + "fallback", + { + "used_fallback": True, + "error_code": "structured_empty", + "source_field": None, + "provider": "", + "model": "", + }, + ) + + 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": [], + "openclaw_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, + "openclaw_instruction": ( + str(plan.get("openclaw_instruction") or "").strip() + or self._build_openclaw_instruction(normalized_source, initiating_text) + )[:600], + } + + def _heuristic_onboarding_plan( + self, + *, + initiating_text: str, + requested_via: str, + error: str, + ) -> dict[str, Any]: + source = self._normalize_source_payload( + source_payload={}, + fallback_text=initiating_text, + requested_via=requested_via, + ) + return { + "should_onboard": True, + "summary": "系统已按规则兜底为信号源接入请求,并生成最小接入闭环。", + "source": source, + "worker_roles": list(DEFAULT_WORKER_ROLE_KEYS), + "openclaw_instruction": self._build_openclaw_instruction(source, initiating_text), + } + + 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_openclaw_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(), + ) + self.db.add(row) + try: + await self.db.flush() + await self.db.commit() + 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: + await self.db.rollback() + 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.commit() + 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 _dispatch_openclaw( + self, + *, + tenant_id: int, + operator_user: Optional[User], + project: Project, + instruction: str, + dry_run: bool, + response_mode: str, + ) -> dict[str, Any]: + if not instruction: + return { + "status": "skipped", + "reason": "empty_instruction", + } + if operator_user is None: + return { + "status": "skipped", + "reason": "no_manager_operator", + } + try: + from .openclaw_commander_service import OpenClawCommanderService + + result = await OpenClawCommanderService(self.db).dispatch_command( + tenant_id=tenant_id, + operator_user=operator_user, + project_id=int(project.id), + instruction=instruction, + dry_run=dry_run, + max_operations=6, + response_mode=response_mode, + persist_run=False, + ) + return { + "status": "planned" if dry_run else "completed", + "result": result, + } + except Exception as exc: + return { + "status": "failed", + "reason": str(exc), + } + + 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), + openclaw_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 状态,避免 SQLite 事务跨 LLM 调用长期持有写锁。 + await self.db.commit() + + prompt = self._build_worker_prompt( + role=role, + project=project, + signal=signal, + source_payload=source_payload, + openclaw_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: + fallback = self._fallback_worker_output( + role_name=role_name, + source_name=str(source_payload.get("source_name") or "信号源"), + ) + if execution_row is not None: + execution_row.status = "completed" + execution_row.summary = str(fallback.get("summary") or "").strip()[:1000] + execution_row.output_payload = fallback + 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": "completed", + "reason": str(exc), + "summary": str(fallback.get("summary") or "").strip(), + "used_fallback": True, + "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, + openclaw_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, + openclaw_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"), + }, + openclaw_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, + openclaw_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, + openclaw_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": extraction.error_code is not None, + "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], + openclaw_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), + "openclaw": openclaw_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"), + }, + openclaw_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, + openclaw_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"), + }, + openclaw_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, + openclaw_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, + } + + @staticmethod + def _fallback_worker_output(*, role_name: str, source_name: str) -> dict[str, Any]: + return { + "summary": f"{role_name} 已为 {source_name} 生成兜底执行摘要。", + "deliverables": [f"梳理 {source_name} 的接入目标与边界"], + "risks": [f"{source_name} 的标识与接入细节仍需补充确认"], + "next_actions": ["等待 OpenClaw 生成任务并推进执行"], + "used_fallback": True, + } + + 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("openclaw")), + 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 diff --git a/backend/app/services/skill_service.py b/backend/app/services/skill_service.py index cd55ba33..c6d0eed7 100644 --- a/backend/app/services/skill_service.py +++ b/backend/app/services/skill_service.py @@ -13,6 +13,7 @@ from typing import List, Optional from sqlalchemy import and_, func, select, text from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from ..models import SkillTag, UserSkillBinding, UserWorkload, User, UserStatus, Task from ..database import _is_sqlite @@ -347,6 +348,7 @@ async def get_user_skill_bindings( """按用户查询技能绑定""" result = await db.execute( select(UserSkillBinding) + .options(selectinload(UserSkillBinding.skill_tag)) .where( and_( UserSkillBinding.tenant_id == tenant_id, @@ -366,6 +368,10 @@ async def get_skill_tag_user_bindings( """按技能标签查询用户绑定""" result = await db.execute( select(UserSkillBinding) + .options( + selectinload(UserSkillBinding.user), + selectinload(UserSkillBinding.skill_tag), + ) .where( and_( UserSkillBinding.tenant_id == tenant_id, diff --git a/backend/app/services/sstc_service.py b/backend/app/services/sstc_service.py index 6c49ad35..cacab6df 100644 --- a/backend/app/services/sstc_service.py +++ b/backend/app/services/sstc_service.py @@ -17,8 +17,10 @@ from ..models import ( Task, TruthNode, User, + UserProject, UserWorkload, ) +from ..utils.project_members import ensure_project_default_members DONE_STATUSES = {"done", "completed"} ACTIVE_STATUSES = {"in_progress", "review"} @@ -28,6 +30,7 @@ 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: @@ -66,7 +69,7 @@ class SSTCService: return result.scalar_one_or_none() async def get_signals(self, project_id: int) -> list[Signal]: - result = await self.db.execute( + linked_result = await self.db.execute( select(Signal) .join(ProjectSignalLink, ProjectSignalLink.signal_id == Signal.id) .where( @@ -77,7 +80,33 @@ class SSTCService: ) .order_by(Signal.created_at.desc()) ) - return list(result.scalars().all()) + 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( @@ -242,8 +271,8 @@ class SSTCService: charters_ids = [int(c.id) for c in charters] alignment = await self.get_alignment_stats(charters_ids) metrics = self._task_metrics(tasks) - assignee_ids = sorted({int(t.assignee_id) for t in tasks if t.assignee_id}) - workload = await self.get_workload_health(assignee_ids) + member_ids = await self.get_project_member_ids(project_id) + workload = await self.get_workload_health(member_ids) chain_id = str(charters[0].id) if charters else None return { "project": { @@ -310,26 +339,35 @@ class SSTCService: 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 execution_done: + 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 = "待发布" + 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 release_verified: + 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: @@ -337,7 +375,6 @@ class SSTCService: else: review_status = "草稿" - metrics = self._task_metrics(tasks) return [ { "key": "signal", @@ -380,17 +417,17 @@ class SSTCService: "label": "发布验证", "status": release_status, "summary": f"{len(releases)} 次发布", - "locked": not execution_done, - "editable": execution_done, - "risk_count": 0, + "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 release_verified, - "editable": release_verified, + "locked": not review_pre_unlocked, + "editable": review_pre_unlocked, "risk_count": 0, }, ] @@ -531,19 +568,27 @@ class SSTCService: return int(result.scalar() or 0) > 0 async def get_project_member_ids(self, project_id: int) -> list[int]: - # 现有 schema 未显式项目成员关系,按项目任务 assignee + owner 聚合 - task_result = await self.db.execute( - select(Task.assignee_id).where( - and_(Task.project_id == project_id, Task.tenant_id == self.tenant_id) + member_result = await self.db.execute( + select(UserProject.user_id).where( + and_( + UserProject.project_id == project_id, + UserProject.tenant_id == self.tenant_id, + ) ) ) - member_ids = {int(x) for x in task_result.scalars().all() if x is not None} + member_ids = {int(x) for x in member_result.scalars().all() if x is not None} 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() if project and project.owner_id: member_ids.add(int(project.owner_id)) - if not member_ids: - users_result = await self.db.execute( - select(User.id).where(User.tenant_id == self.tenant_id).limit(5) - ) - member_ids.update(int(x) for x in users_result.scalars().all()) return sorted(member_ids) diff --git a/backend/app/services/symbol_index_service.py b/backend/app/services/symbol_index_service.py new file mode 100644 index 00000000..a8a9a1d6 --- /dev/null +++ b/backend/app/services/symbol_index_service.py @@ -0,0 +1,776 @@ +"""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 + + +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=timezone.utc).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"] = datetime.now(timezone.utc).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": datetime.now(timezone.utc).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=datetime.now(timezone.utc).replace(tzinfo=None), + ) + 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, + } diff --git a/backend/app/services/task_persistence.py b/backend/app/services/task_persistence.py new file mode 100644 index 00000000..a4e0ca3b --- /dev/null +++ b/backend/app/services/task_persistence.py @@ -0,0 +1,324 @@ +"""共享任务持久化规范。""" + +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 . 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"} + + +@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(timezone.utc).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 diff --git a/backend/app/services/wecom_integration.py b/backend/app/services/wecom_integration.py index 5a5c8d04..510e0dc6 100644 --- a/backend/app/services/wecom_integration.py +++ b/backend/app/services/wecom_integration.py @@ -15,6 +15,7 @@ import base64 import struct from datetime import datetime, timezone from typing import Any, Dict, List, Optional +import httpx from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy import text @@ -46,18 +47,28 @@ class WeChatWorkIntegrationService: result = await self.db.execute( select(Integration).where( Integration.tenant_id == tenant_id, - Integration.provider == 'wecom' + Integration.type == 'wecom', + Integration.is_active.is_(True), ) ) integration = result.scalar_one_or_none() if integration: - config = json.loads(integration.config) if integration.config else {} + if isinstance(integration.config, str): + config = json.loads(integration.config) if integration.config else {} + else: + config = integration.config or {} self.corp_id = config.get('corp_id') self.agent_id = config.get('agent_id') self.app_secret = config.get('app_secret') self.token = config.get('token') self.encoding_aes_key = config.get('encoding_aes_key') + else: + self.corp_id = None + self.agent_id = None + self.app_secret = None + self.token = None + self.encoding_aes_key = None def is_configured(self) -> bool: """检查是否已配置""" @@ -91,26 +102,19 @@ class WeChatWorkIntegrationService: result = await self.db.execute( select(Integration).where( Integration.tenant_id == tenant_id, - Integration.provider == 'wecom' + Integration.type == 'wecom' ) ) integration = result.scalar_one_or_none() if integration: - integration.config = json.dumps(config) + integration.config = config integration.is_active = True - integration.last_synced_at = datetime.now(timezone.utc) else: - from sqlalchemy import text - next_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM integrations"))).scalar() or 1 - integration = Integration( - id=next_id, - provider='wecom', - name='企业微信集成', - config=json.dumps(config), + type='wecom', + config=config, is_active=True, - last_synced_at=datetime.now(timezone.utc), tenant_id=tenant_id ) self.db.add(integration) @@ -257,11 +261,10 @@ class WeChatWorkIntegrationService: message_data["chatid"] = chat_id message_data["msgtype"] = "text" - import aiohttp - async with aiohttp.ClientSession() as session: - async with session.post(url, params=params, json=message_data) as resp: - result = await resp.json() - return result + async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client: + resp = await client.post(url, params=params, json=message_data) + resp.raise_for_status() + return resp.json() async def _get_access_token(self) -> str: """获取企业微信 access_token""" @@ -274,15 +277,14 @@ class WeChatWorkIntegrationService: "corpsecret": self.app_secret } - import aiohttp - async with aiohttp.ClientSession() as session: - async with session.get(url, params=params) as resp: - result = await resp.json() + async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client: + resp = await client.get(url, params=params) + resp.raise_for_status() + result = resp.json() - if result.get('errcode') == 0: - return result.get('access_token') - else: - raise Exception(f"获取 token 失败: {result}") + if result.get('errcode') == 0: + return result.get('access_token') + raise Exception(f"获取 token 失败: {result}") # 全局服务实例 diff --git a/backend/app/services/workload_service.py b/backend/app/services/workload_service.py index 201cff9d..6f91ae3e 100644 --- a/backend/app/services/workload_service.py +++ b/backend/app/services/workload_service.py @@ -422,9 +422,7 @@ async def refresh_user_assigned_hours_from_tasks( base = float(task.estimated_hours or 0.0) if base <= 0: return 0.0 - required_ids = [ - int(v) for v in (task.required_skill_tag_ids or []) if v is not None - ] + required_ids = _safe_int_list(task.required_skill_tag_ids) if not required_ids: return base multipliers = [ @@ -455,6 +453,40 @@ def _skill_match_score(task_skill_ids: list[int], user_skill_ids: set[int]) -> f 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"): @@ -472,15 +504,17 @@ async def build_ai_optimize_preview( *, 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"]), - Task.assignee_id.isnot(None), ) ) + 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) @@ -557,7 +591,7 @@ async def build_ai_optimize_preview( "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": [int(v) for v in (task.required_skill_tag_ids or []) if v is not None], + "required_skill_tag_ids": _safe_int_list(task.required_skill_tag_ids), "estimated_hours": float(task.estimated_hours or 0), "status": task.status, } @@ -606,17 +640,18 @@ async def build_ai_optimize_preview( 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 = [int(x) for x in (task.required_skill_tag_ids or []) if x is not None] + 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 uid == current_assignee: + 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())) @@ -631,11 +666,15 @@ async def build_ai_optimize_preview( continue best_score, _best_ratio, best_user = ranked[0] - current_ratio = workload_map.get(current_assignee, 0.0) - if current_ratio <= 100 and best_score < 0.5: - continue - - reason = f"skill_match+workload_score={best_score:.2f}" + 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'}" @@ -644,9 +683,9 @@ async def build_ai_optimize_preview( "task_id": int(task.id), "task_title": task.title, "project_id": int(task.project_id), - "old_assignee_id": current_assignee, + "old_assignee_id": current_assignee if not is_unassigned else None, "new_assignee_id": int(best_user.id), - "old_ratio": round(current_ratio, 2), + "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, } diff --git a/backend/app/utils/project_members.py b/backend/app/utils/project_members.py new file mode 100644 index 00000000..0fb148fd --- /dev/null +++ b/backend/app/utils/project_members.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import re + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import User, UserProject + +_ROLE_SPLIT_RE = re.compile(r"[,/|\s]+") +_ROLE_ALIAS_MAP = { + "AI": {"AI"}, + "AI_ENGINEER": {"AI"}, + "FRONTEND": {"FE"}, + "FE": {"FE"}, + "BACKEND": {"BE"}, + "BE": {"BE"}, + "BOSS": {"BOSS"}, + "ADMIN": {"ADMIN"}, +} + + +def _parse_role_tokens(raw: str | None) -> set[str]: + tokens = { + token.strip().upper() + for token in _ROLE_SPLIT_RE.split(str(raw or "").strip()) + if token.strip() + } + normalized: set[str] = set() + for token in tokens: + normalized.update(_ROLE_ALIAS_MAP.get(token, {token})) + return normalized + + +def _user_matches_project_role(user_role: str | None, expected_roles: set[str]) -> bool: + if not expected_roles: + return False + return bool(_parse_role_tokens(user_role).intersection(expected_roles)) + + +async def ensure_project_default_members( + db: AsyncSession, + *, + tenant_id: int, + project_id: int, + owner_id: int | None, + assigned_role: str | None, +) -> list[int]: + member_ids: set[int] = set() + if owner_id is not None: + member_ids.add(int(owner_id)) + + expected_roles = _parse_role_tokens(assigned_role) + if expected_roles: + rows = await db.execute( + select(User.id, User.role).where( + and_( + User.tenant_id == int(tenant_id), + User.status == "active", + User.is_active.is_(True), + ) + ) + ) + for user_id, user_role in rows.all(): + if _user_matches_project_role(user_role, expected_roles): + member_ids.add(int(user_id)) + + if not member_ids: + fallback_rows = await db.execute( + select(User.id).where( + and_( + User.tenant_id == int(tenant_id), + User.status == "active", + User.is_active.is_(True), + ) + ).order_by(User.id.asc()).limit(1) + ) + member_ids.update(int(user_id) for user_id in fallback_rows.scalars().all()) + + existing_rows = await db.execute( + select(UserProject.user_id).where( + and_( + UserProject.tenant_id == int(tenant_id), + UserProject.project_id == int(project_id), + ) + ) + ) + existing_member_ids = {int(user_id) for user_id in existing_rows.scalars().all()} + + for user_id in sorted(member_ids - existing_member_ids): + db.add( + UserProject( + tenant_id=int(tenant_id), + user_id=int(user_id), + project_id=int(project_id), + ) + ) + + return sorted(member_ids.union(existing_member_ids)) diff --git a/backend/app/utils/sequence.py b/backend/app/utils/sequence.py new file mode 100644 index 00000000..79113604 --- /dev/null +++ b/backend/app/utils/sequence.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + + +async def _ensure_table_id_sequence(db: AsyncSession, *, table_name: str) -> None: + bind = db.get_bind() + dialect_name = str(getattr(getattr(bind, "dialect", None), "name", "")).lower() + if dialect_name != "postgresql": + return + + await db.execute( + text( + f""" + SELECT setval( + pg_get_serial_sequence('{table_name}', 'id'), + GREATEST( + COALESCE((SELECT MAX(id) FROM {table_name}), 0), + COALESCE((SELECT last_value FROM {table_name}_id_seq), 0), + 1 + ), + true + ) + """ + ) + ) + + +async def ensure_project_id_sequence(db: AsyncSession) -> None: + """Keep the PostgreSQL `projects.id` sequence ahead of existing rows.""" + await _ensure_table_id_sequence(db, table_name="projects") + + +async def ensure_signal_id_sequence(db: AsyncSession) -> None: + """Keep the PostgreSQL `signals.id` sequence ahead of existing rows.""" + await _ensure_table_id_sequence(db, table_name="signals") diff --git a/backend/evals/README.md b/backend/evals/README.md new file mode 100644 index 00000000..befe9a49 --- /dev/null +++ b/backend/evals/README.md @@ -0,0 +1,67 @@ +# AI Steward Eval Gate + +## 文件 +- `golden_qa_v1.jsonl`: 50 条金标准问答(含追问链)。 +- `golden_feishu_replay_v1.jsonl`: 飞书执行链路回放样本(执行/咨询/重复/噪音/多操作写库 split)。 +- `feishu_replay_eval.py`: 飞书回放评测,断言 DB + route_status + reply_text。 +- `reports/`: 评测结果归档目录,`latest.json` 指向最近一次结果。 +- `scripts/ai_steward_eval.py`: 执行 lint/在线评测/门禁。 +- `scripts/ai_steward_eval_compare.py`: 对比两版报告。 +- `backend/evals/rag_project_understanding_eval.py`: 项目语义理解回归(功能目录/入口/API 命中率)。 + +## 本地执行 +```bash +# 1) 校验金标准集 +python3 scripts/ai_steward_eval.py --lint-only + +# 2) 在线评测(需 JWT) +python3 scripts/ai_steward_eval.py \ + --api-url http://127.0.0.1:8000/rag/ask \ + --token '' \ + --fail-on-threshold + +# 3) 报告对比 +python3 scripts/ai_steward_eval_compare.py \ + --base backend/evals/reports/ai_steward_eval_xxx.json \ + --target backend/evals/reports/latest.json + +# 4) 项目语义理解回归 +python3 backend/evals/rag_project_understanding_eval.py --tenant-id 1 + +# 5) 飞书回放样本 lint +PYTHONPATH=backend python3 backend/evals/feishu_replay_eval.py \ + --lint-only \ + --input backend/evals/golden_feishu_replay_v1.jsonl + +# 5.1) OpenClaw 路由矩阵 lint(L/H/S 覆盖校验) +python3 scripts/openclaw_routing_lint.py --input backend/.openclaw/pmflow-routing-rules.json + +# 5.2) Operation Catalog lint(全系统写操作覆盖校验) +python3 scripts/operation_catalog_lint.py + +# 6) 飞书回放执行(严格模式) +PYTHONPATH=backend python3 backend/evals/feishu_replay_eval.py \ + --strict \ + --tenant-id 9901 \ + --input backend/evals/golden_feishu_replay_v1.jsonl \ + --output-dir backend/evals/reports + +# 7) 飞书链路 canary(4 场景) +bash scripts/feishu_canary_smoke.sh +``` + +注意: +- 回放默认 tenant 为 `9901`,并默认拒绝 `tenant_id=1`,避免误删主租户测试数据。 +- 仅在隔离数据库中才可使用 `--allow-tenant-1` 强制放行。 + +该回归会输出: +- `avg_keyword_hit_ratio`:问题关键词命中率 +- `avg_feature_evidence_ratio`:路由/页面/API 证据命中率 + +## 门禁阈值 +- accuracy >= 0.80 +- hallucination_rate <= 0.12 +- followup_hit_rate >= 0.75 +- query_type_hit_rate >= 0.80 + +任一指标不达标,`--fail-on-threshold` 会返回非 0 退出码,阻断发布。 diff --git a/backend/evals/feishu_replay_eval.py b/backend/evals/feishu_replay_eval.py new file mode 100644 index 00000000..e040f226 --- /dev/null +++ b/backend/evals/feishu_replay_eval.py @@ -0,0 +1,833 @@ +#!/usr/bin/env python3 +"""飞书执行链路回放评测。 + +目标: +1) 回放样本消息,驱动 FeishuSignalPipelineService。 +2) 断言 DB 结果 + route_status + reply_text 一致。 +3) 输出结构化报告,支持 CI 门禁。 +""" + +from __future__ import annotations + +import argparse +import asyncio +import contextvars +import json +import sys +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +from sqlalchemy import and_, delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import AsyncSessionLocal +from app.models import ( + AIProcessingLog, + FeishuItem, + Notification, + Project, + ProjectSignalLink, + ProjectSuggestion, + Signal, + User, +) +from app.services.adaptive_rag_service import AdaptiveRAGService +from app.services.feishu_integration import FeishuIntegrationService +from app.services.feishu_signal_pipeline import FeishuSignalPipelineService + +CURRENT_CASE_ID: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "feishu_replay_case_id", + default=None, +) +DEFAULT_REPLAY_TENANT_ID = 9901 + + +@dataclass +class ReplayCase: + id: str + message_content: str + sender: str + source_chat_id: str + feishu_message_id: str + mock: dict[str, Any] + expected: dict[str, Any] + + +def _load_cases(path: Path) -> list[ReplayCase]: + rows: list[ReplayCase] = [] + with path.open("r", encoding="utf-8") as f: + for idx, raw in enumerate(f, start=1): + line = raw.strip() + if not line: + continue + payload = json.loads(line) + case_id = str(payload.get("id") or f"CASE-{idx:03d}").strip() + rows.append( + ReplayCase( + id=case_id, + message_content=str(payload.get("message_content") or "").strip(), + sender=str(payload.get("sender") or "回放用户").strip(), + source_chat_id=str(payload.get("source_chat_id") or "oc-replay-chat").strip(), + feishu_message_id=str(payload.get("feishu_message_id") or "").strip(), + mock=payload.get("mock") if isinstance(payload.get("mock"), dict) else {}, + expected=payload.get("expected") if isinstance(payload.get("expected"), dict) else {}, + ) + ) + return rows + + +def _lint_cases(cases: list[ReplayCase]) -> dict[str, Any]: + unique_case_ids = len({c.id for c in cases}) + non_empty_messages = sum(1 for c in cases if c.message_content) + with_duplicates = sum(1 for c in cases if c.expected.get("status") == "ignored" and c.expected.get("reason") == "duplicate_filtered") + with_qa = sum(1 for c in cases if c.expected.get("scene") == "qa") + with_execute = sum(1 for c in cases if c.expected.get("scene") == "execute") + with_multi_write = sum(1 for c in cases if bool(c.expected.get("multi_write"))) + valid = ( + len(cases) >= 50 + and unique_case_ids == len(cases) + and non_empty_messages == len(cases) + and with_duplicates > 0 + and with_qa > 0 + and with_execute > 0 + and with_multi_write > 0 + ) + return { + "count": len(cases), + "unique_case_ids": unique_case_ids, + "non_empty_messages": non_empty_messages, + "duplicate_cases": with_duplicates, + "qa_cases": with_qa, + "execute_cases": with_execute, + "multi_write_cases": with_multi_write, + "valid": valid, + } + + +def _default_level_signal() -> dict[str, Any]: + return { + "build_intent_score": 0.2, + "scope_score": 0.2, + "timeline_score": 0.1, + "owner_score": 0.1, + "project_score": 0.2, + "level": "signal", + "level_confidence": 0.88, + "level_reasoning": "回放样本:明确执行动作,按 signal 处理。", + "should_split": False, + "split_items": [], + } + + +def _default_signal(project_id: int = 1001, confidence: float = 0.92) -> dict[str, Any]: + return { + "title": "新增信号源", + "type": "新需求", + "priority": "P1", + "matched_project_id": int(project_id), + "summary": "新增一个可执行的信号源需求。", + "reasoning": ["目标明确", "动作清晰"], + "confidence": float(confidence), + "recommended_action": "enter_only", + } + + +def _default_level_project() -> dict[str, Any]: + return { + "build_intent_score": 0.95, + "scope_score": 0.86, + "timeline_score": 0.8, + "owner_score": 0.75, + "project_score": 0.9, + "level": "project", + "level_confidence": 0.9, + "level_reasoning": "回放样本:范围较大,按 project 处理。", + "should_split": False, + "split_items": [], + } + + +def _default_project_prefill(index: int = 1) -> dict[str, Any]: + return { + "suggested_name": f"回放项目-{index}", + "suggested_description": "回放样本自动生成项目建议。", + "suggested_owner": "回放负责人", + "suggested_timeline": "2周", + "confidence": 0.86, + "reasoning": ["目标明确", "时间可控"], + } + + +def _default_routing_policy() -> dict[str, Any]: + return { + "cold_start_enabled": False, + "signal_auto_enter_threshold": 0.8, + "project_auto_create_threshold": 0.85, + "project_uncertain_threshold": 0.6, + "chat_context_auto_link_enabled": False, + } + + +def _case_by_id(cases: list[ReplayCase]) -> dict[str, ReplayCase]: + return {row.id: row for row in cases} + + +class ReplayPatcher: + """在回放期间替换外部依赖(RAG/LLM)为样本驱动。""" + + def __init__(self, cases: list[ReplayCase]) -> None: + self._case_map = _case_by_id(cases) + self._project_id_map: dict[int, int] = {1001: 1001, 1002: 1002, 1003: 1003} + self._orig_classify_intent = AdaptiveRAGService.classify_intent + self._orig_answer_question = AdaptiveRAGService.answer_question + self._orig_classify_level = FeishuSignalPipelineService._classify_level + self._orig_extract_signal = FeishuSignalPipelineService._extract_signal + self._orig_extract_project_prefill = FeishuSignalPipelineService._extract_project_prefill + self._orig_load_routing_policy = FeishuSignalPipelineService._load_routing_policy + self._orig_notify_project_created = FeishuSignalPipelineService._notify_project_created + self._orig_notify_signal_auto_entered = FeishuSignalPipelineService._notify_signal_auto_entered + + def _current_case(self) -> ReplayCase: + case_id = CURRENT_CASE_ID.get() + if not case_id: + raise RuntimeError("missing_current_case_id") + row = self._case_map.get(case_id) + if row is None: + raise RuntimeError(f"unknown_case_id:{case_id}") + return row + + def set_project_id_map(self, value: dict[int, int]) -> None: + mapped = {int(k): int(v) for k, v in (value or {}).items()} + if mapped: + self._project_id_map = mapped + + def _map_project_id(self, raw_value: Any) -> Optional[int]: + if raw_value is None: + return None + try: + source = int(raw_value) + except Exception: + return None + return int(self._project_id_map.get(source, source)) + + def install(self) -> None: + async def fake_classify_intent( + _: AdaptiveRAGService, + message: str, + ) -> dict[str, Any]: + case = self._current_case() + mock = case.mock + intent = str(mock.get("intent") or "statement").strip().lower() + if intent == "question": + return { + "intent": "question", + "question_part": message, + "statement_part": "", + } + if intent == "mixed": + question_part = str(mock.get("question_part") or message).strip() + statement_part = str(mock.get("statement_part") or message).strip() + return { + "intent": "mixed", + "question_part": question_part, + "statement_part": statement_part, + } + return { + "intent": "statement", + "question_part": "", + "statement_part": message, + } + + async def fake_answer_question( + _: AdaptiveRAGService, + *, + question: str, + asked_by: Optional[int], + asked_via: str, + project_id: Optional[int], + source_chat_id: Optional[str], + session_id: Optional[int] = None, + ) -> dict[str, Any]: + _ = (asked_by, asked_via, project_id, source_chat_id, session_id) + case = self._current_case() + mock = case.mock + answer = str(mock.get("qa_answer") or f"回放答案:{question[:30]}").strip() + sources = mock.get("qa_sources") + if not isinstance(sources, list): + sources = [{"label": "Replay Source"}] + return { + "answer": answer, + "sources": sources, + "query_type": "question", + "confidence": 0.9, + } + + async def fake_classify_level( + _: FeishuSignalPipelineService, + *, + message: str, + projects: list[dict[str, Any]], + ) -> dict[str, Any]: + _ = (message, projects) + case = self._current_case() + if bool(case.mock.get("force_schema_error")): + raise ValueError("level_classification_schema_invalid: replay_forced") + level = case.mock.get("level") + if isinstance(level, dict): + return level + return _default_level_signal() + + async def fake_extract_signal( + _: FeishuSignalPipelineService, + message: str, + *, + projects: list[dict[str, Any]], + ) -> dict[str, Any]: + _ = (message, projects) + case = self._current_case() + signal = case.mock.get("signal") + if isinstance(signal, dict): + payload = dict(signal) + mapped_project_id = self._map_project_id(payload.get("matched_project_id")) + if mapped_project_id is not None: + payload["matched_project_id"] = mapped_project_id + return payload + payload = _default_signal() + mapped_project_id = self._map_project_id(payload.get("matched_project_id")) + if mapped_project_id is not None: + payload["matched_project_id"] = mapped_project_id + return payload + + async def fake_extract_project_prefill( + _: FeishuSignalPipelineService, + message: str, + ) -> dict[str, Any]: + _ = message + case = self._current_case() + prefill = case.mock.get("project_prefill") + if isinstance(prefill, dict): + return prefill + return _default_project_prefill() + + async def fake_load_routing_policy(_: FeishuSignalPipelineService) -> dict[str, Any]: + case = self._current_case() + policy = case.mock.get("routing_policy") + merged = _default_routing_policy() + if isinstance(policy, dict): + merged.update(policy) + return merged + + async def fake_notify_project_created( + _: FeishuSignalPipelineService, + *, + project: Project, + ) -> None: + _ = project + return None + + async def fake_notify_signal_auto_entered( + _: FeishuSignalPipelineService, + *, + project_id: int, + signal: Any, + ) -> None: + _ = (project_id, signal) + return None + + AdaptiveRAGService.classify_intent = fake_classify_intent # type: ignore[assignment] + AdaptiveRAGService.answer_question = fake_answer_question # type: ignore[assignment] + FeishuSignalPipelineService._classify_level = fake_classify_level # type: ignore[assignment] + FeishuSignalPipelineService._extract_signal = fake_extract_signal # type: ignore[assignment] + FeishuSignalPipelineService._extract_project_prefill = fake_extract_project_prefill # type: ignore[assignment] + FeishuSignalPipelineService._load_routing_policy = fake_load_routing_policy # type: ignore[assignment] + FeishuSignalPipelineService._notify_project_created = fake_notify_project_created # type: ignore[assignment] + FeishuSignalPipelineService._notify_signal_auto_entered = fake_notify_signal_auto_entered # type: ignore[assignment] + + def restore(self) -> None: + AdaptiveRAGService.classify_intent = self._orig_classify_intent # type: ignore[assignment] + AdaptiveRAGService.answer_question = self._orig_answer_question # type: ignore[assignment] + FeishuSignalPipelineService._classify_level = self._orig_classify_level # type: ignore[assignment] + FeishuSignalPipelineService._extract_signal = self._orig_extract_signal # type: ignore[assignment] + FeishuSignalPipelineService._extract_project_prefill = self._orig_extract_project_prefill # type: ignore[assignment] + FeishuSignalPipelineService._load_routing_policy = self._orig_load_routing_policy # type: ignore[assignment] + FeishuSignalPipelineService._notify_project_created = self._orig_notify_project_created # type: ignore[assignment] + FeishuSignalPipelineService._notify_signal_auto_entered = self._orig_notify_signal_auto_entered # type: ignore[assignment] + + +async def _seed_minimal_data(session: AsyncSession, *, tenant_id: int) -> dict[int, int]: + users = ( + await session.execute( + select(User).where( + and_( + User.tenant_id == tenant_id, + User.username.in_(("replay_admin", "replay_owner")), + ) + ) + ) + ).scalars().all() + if not users: + session.add( + User( + tenant_id=tenant_id, + username="replay_admin", + email="replay_admin@example.com", + password_hash="x", + name="Replay Admin", + role="admin", + assigned_domains=[], + workload=0, + status="active", + is_active=True, + ) + ) + session.add( + User( + tenant_id=tenant_id, + username="replay_owner", + email="replay_owner@example.com", + password_hash="x", + name="Replay Owner", + role="ai_engineer", + assigned_domains=[], + workload=0, + status="active", + is_active=True, + ) + ) + await session.flush() + + alias_to_name = { + 1001: "回放项目-健身", + 1002: "回放项目-路由", + 1003: "回放项目-知识库", + } + project_map: dict[int, int] = {} + for alias, title in alias_to_name.items(): + existing = ( + await session.execute( + select(Project).where( + and_( + Project.tenant_id == tenant_id, + Project.name == title, + ) + ).limit(1) + ) + ).scalar_one_or_none() + if existing is None: + existing = Project( + tenant_id=tenant_id, + name=title, + title=title, + description="回放种子项目", + owner_id=None, + status="active", + domain_id=None, + assigned_role="ai_engineer", + signal_count=0, + ) + session.add(existing) + await session.flush() + project_map[int(alias)] = int(existing.id) + await session.commit() + return project_map + + +async def _count_items_by_message_id( + session: AsyncSession, + *, + tenant_id: int, + message_id: str, +) -> int: + return int( + ( + await session.execute( + select(func.count(FeishuItem.id)).where( + and_( + FeishuItem.tenant_id == tenant_id, + FeishuItem.feishu_message_id == message_id, + ) + ) + ) + ).scalar() + or 0 + ) + + +async def _latest_item_route_status( + session: AsyncSession, + *, + tenant_id: int, + message_id: str, +) -> Optional[str]: + row = ( + await session.execute( + select(FeishuItem.route_status) + .where( + and_( + FeishuItem.tenant_id == tenant_id, + FeishuItem.feishu_message_id == message_id, + ) + ) + .order_by(FeishuItem.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if row is None: + return None + return str(row) + + +async def _cleanup_tenant_replay_data(session: AsyncSession, *, tenant_id: int) -> None: + await session.execute(delete(ProjectSignalLink).where(ProjectSignalLink.tenant_id == tenant_id)) + await session.execute(delete(ProjectSuggestion).where(ProjectSuggestion.tenant_id == tenant_id)) + await session.execute(delete(Signal).where(Signal.tenant_id == tenant_id)) + await session.execute( + delete(AIProcessingLog).where( + and_( + AIProcessingLog.tenant_id == tenant_id, + AIProcessingLog.feishu_message_id.like("om_replay_%"), + ) + ) + ) + await session.execute( + delete(AIProcessingLog).where( + and_( + AIProcessingLog.tenant_id == tenant_id, + AIProcessingLog.feishu_message_id.like("feishu-replay-%"), + ) + ) + ) + await session.execute(delete(FeishuItem).where(FeishuItem.tenant_id == tenant_id)) + await session.execute(delete(Notification).where(Notification.tenant_id == tenant_id)) + await session.execute(delete(Project).where(Project.tenant_id == tenant_id)) + await session.execute(delete(User).where(User.tenant_id == tenant_id)) + await session.commit() + + +def _build_reply_text( + *, + service: FeishuIntegrationService, + message_content: str, + capture_result: dict[str, Any], + trace_id: str, +) -> Optional[str]: + scene = service._classify_message_scene(capture_result=capture_result) + if scene == "execute": + text = service._build_execution_receipt_reply( + message_content=message_content, + capture_result=capture_result, + ) + return service._guard_execute_reply_text(text, trace_id=trace_id) + if scene == "qa": + if bool(capture_result.get("direct_reply_required")): + return str(capture_result.get("direct_reply_text") or "").strip() + return None + if scene == "error": + return service._build_error_reply_text() + return None + + +def _match_reply(expected: dict[str, Any], reply: Optional[str]) -> bool: + expected_equals = expected.get("reply_text") + if expected_equals is not None: + return str(reply or "") == str(expected_equals) + expected_contains = expected.get("reply_contains") + if expected_contains is None: + return True + if isinstance(expected_contains, str): + expected_contains = [expected_contains] + if not isinstance(expected_contains, list): + return True + text = str(reply or "") + return all(str(token) in text for token in expected_contains) + + +def _build_report( + *, + results: list[dict[str, Any]], + started_at: datetime, +) -> dict[str, Any]: + total = len(results) + passed = sum(1 for row in results if row.get("passed")) + failed = total - passed + return { + "version": "feishu-replay-v1", + "generated_at": datetime.now(timezone.utc).isoformat(), + "started_at": started_at.isoformat(), + "metrics": { + "total": total, + "passed": passed, + "failed": failed, + "pass_rate": round((passed / total) if total else 0.0, 4), + }, + "cases": results, + } + + +def _write_report(report: dict[str, Any], output_dir: Path) -> Path: + output_dir.mkdir(parents=True, exist_ok=True) + ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + report_path = output_dir / f"feishu_replay_eval_{ts}.json" + report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + latest = output_dir / "feishu_replay_latest.json" + latest.write_text(report_path.read_text(encoding="utf-8"), encoding="utf-8") + return report_path + + +async def _run_replay( + cases: list[ReplayCase], + *, + tenant_id: int, + allow_tenant_1: bool = False, +) -> dict[str, Any]: + if int(tenant_id) == 1 and not allow_tenant_1: + raise RuntimeError("tenant_id=1 is blocked for replay cleanup; use dedicated replay tenant") + + started_at = datetime.now(timezone.utc) + patcher = ReplayPatcher(cases) + patcher.install() + case_results: list[dict[str, Any]] = [] + try: + async with AsyncSessionLocal() as db: + await _cleanup_tenant_replay_data(db, tenant_id=tenant_id) + project_id_map = await _seed_minimal_data(db, tenant_id=tenant_id) + patcher.set_project_id_map(project_id_map) + pipeline = FeishuSignalPipelineService(db, tenant_id=tenant_id) + integration = FeishuIntegrationService(db) + integration.tenant_id = tenant_id + + for case in cases: + token = CURRENT_CASE_ID.set(case.id) + before_count = await _count_items_by_message_id( + db, + tenant_id=tenant_id, + message_id=case.feishu_message_id, + ) + mismatches: list[str] = [] + try: + result = await pipeline.process_message( + message_content=case.message_content, + sender=case.sender, + source_chat_id=case.source_chat_id, + source_sender_open_id="ou_replay_user", + feishu_message_id=case.feishu_message_id, + captured_at=datetime.now(timezone.utc), + ) + except Exception as exc: + result = {"status": "error", "classification": "error", "error": str(exc)} + if str(case.expected.get("status") or "").strip().lower() != "error": + mismatches.append(f"process_exception:{exc}") + finally: + CURRENT_CASE_ID.reset(token) + + after_count = await _count_items_by_message_id( + db, + tenant_id=tenant_id, + message_id=case.feishu_message_id, + ) + item_delta = after_count - before_count + route_status = None + result_rows = result.get("results") if isinstance(result, dict) else None + result_count = len(result_rows) if isinstance(result_rows, list) else 0 + result_levels = sorted( + { + str((row or {}).get("level") or "").strip().lower() + for row in (result_rows or []) + if isinstance(row, dict) and str((row or {}).get("level") or "").strip() + } + ) + if isinstance(result_rows, list) and result_rows: + first = result_rows[0] if isinstance(result_rows[0], dict) else {} + route_status = str((first or {}).get("route_status") or "").strip() or None + if route_status is None: + route_status = await _latest_item_route_status( + db, + tenant_id=tenant_id, + message_id=case.feishu_message_id, + ) + + scene = integration._classify_message_scene(capture_result=result if isinstance(result, dict) else {}) + reply_text = _build_reply_text( + service=integration, + message_content=case.message_content, + capture_result=result if isinstance(result, dict) else {}, + trace_id=f"replay-{case.id}", + ) + + expected = case.expected + expected_status = expected.get("status") + if expected_status is not None and str(result.get("status")) != str(expected_status): + mismatches.append(f"status expected={expected_status} actual={result.get('status')}") + + expected_classification = expected.get("classification") + if expected_classification is not None and str(result.get("classification")) != str(expected_classification): + mismatches.append( + f"classification expected={expected_classification} actual={result.get('classification')}" + ) + + expected_reason = expected.get("reason") + if expected_reason is not None and str(result.get("reason")) != str(expected_reason): + mismatches.append(f"reason expected={expected_reason} actual={result.get('reason')}") + + expected_scene = expected.get("scene") + if expected_scene is not None and str(scene) != str(expected_scene): + mismatches.append(f"scene expected={expected_scene} actual={scene}") + + expected_route_status = expected.get("route_status") + if expected_route_status is not None and str(route_status) != str(expected_route_status): + mismatches.append(f"route_status expected={expected_route_status} actual={route_status}") + + expected_item_delta = expected.get("item_delta") + if expected_item_delta is not None and int(item_delta) != int(expected_item_delta): + mismatches.append(f"item_delta expected={expected_item_delta} actual={item_delta}") + + expected_result_count = expected.get("result_count") + if expected_result_count is not None and int(result_count) != int(expected_result_count): + mismatches.append(f"result_count expected={expected_result_count} actual={result_count}") + + expected_levels = expected.get("result_levels") + if isinstance(expected_levels, list): + expected_levels_set = sorted({str(x).strip().lower() for x in expected_levels if str(x).strip()}) + if expected_levels_set != result_levels: + mismatches.append(f"result_levels expected={expected_levels_set} actual={result_levels}") + + if bool(expected.get("multi_write")): + if result_count < 2: + mismatches.append(f"multi_write expected=true actual_result_count={result_count}") + if len(result_levels) < 2: + mismatches.append(f"multi_write expected mixed levels actual_levels={result_levels}") + + if not _match_reply(expected, reply_text): + mismatches.append(f"reply mismatch expected={expected.get('reply_text') or expected.get('reply_contains')} actual={reply_text}") + + case_results.append( + { + "id": case.id, + "passed": len(mismatches) == 0, + "mismatches": mismatches, + "expected": expected, + "actual": { + "status": result.get("status"), + "classification": result.get("classification"), + "reason": result.get("reason"), + "scene": scene, + "route_status": route_status, + "item_delta": item_delta, + "result_count": result_count, + "result_levels": result_levels, + "reply_text": reply_text, + }, + } + ) + + await _cleanup_tenant_replay_data(db, tenant_id=tenant_id) + finally: + patcher.restore() + + return _build_report(results=case_results, started_at=started_at) + + +async def _main_async(args: argparse.Namespace) -> int: + cases = _load_cases(Path(args.input)) + if args.case_ids: + tokens = [token.strip() for token in str(args.case_ids).split(",") if token.strip()] + wanted = set(tokens) + filtered = [row for row in cases if row.id in wanted] + missing = sorted(wanted - {row.id for row in filtered}) + if missing: + print(json.dumps({"error": "case_not_found", "missing": missing}, ensure_ascii=False, indent=2)) + return 2 + cases = filtered + lint = _lint_cases(cases) + if args.lint_only: + print(json.dumps({"lint": lint}, ensure_ascii=False, indent=2)) + return 0 if lint["valid"] else 2 + + if not lint["valid"] and not args.case_ids: + print(json.dumps({"lint": lint}, ensure_ascii=False, indent=2)) + return 2 + + tenant_id = int(args.tenant_id) + if tenant_id <= 0: + print(json.dumps({"error": "invalid_tenant_id", "tenant_id": args.tenant_id}, ensure_ascii=False, indent=2)) + return 2 + if tenant_id == 1 and not bool(args.allow_tenant_1): + print( + json.dumps( + { + "error": "tenant_id_blocked", + "message": "tenant_id=1 默认禁用,避免误删主租户数据;请改用回放专用租户(例如 9901)。", + }, + ensure_ascii=False, + indent=2, + ) + ) + return 2 + + report = await _run_replay( + cases, + tenant_id=tenant_id, + allow_tenant_1=bool(args.allow_tenant_1), + ) + report_path = _write_report(report, Path(args.output_dir)) + metrics = report.get("metrics", {}) + print( + f"[feishu-replay] total={metrics.get('total')} passed={metrics.get('passed')} " + f"failed={metrics.get('failed')} pass_rate={metrics.get('pass_rate')} " + f"report={report_path}" + ) + if int(metrics.get("failed") or 0) > 0 and args.strict: + return 2 + return 0 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Feishu replay eval") + parser.add_argument( + "--input", + default="backend/evals/golden_feishu_replay_v1.jsonl", + help="Replay dataset path (.jsonl)", + ) + parser.add_argument( + "--output-dir", + default="backend/evals/reports", + help="Report output directory", + ) + parser.add_argument( + "--tenant-id", + default=str(DEFAULT_REPLAY_TENANT_ID), + help="Replay tenant id", + ) + parser.add_argument( + "--allow-tenant-1", + action="store_true", + help="Allow tenant_id=1 (dangerous, only for isolated test db).", + ) + parser.add_argument( + "--lint-only", + action="store_true", + help="Only validate dataset structure", + ) + parser.add_argument( + "--strict", + action="store_true", + help="Exit non-zero when replay has failures", + ) + parser.add_argument( + "--case-ids", + default="", + help="Only run selected case ids, comma separated", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + code = asyncio.run(_main_async(args)) + raise SystemExit(code) + + +if __name__ == "__main__": + main() diff --git a/backend/evals/golden_feishu_replay_v1.jsonl b/backend/evals/golden_feishu_replay_v1.jsonl new file mode 100644 index 00000000..d6c8431f --- /dev/null +++ b/backend/evals/golden_feishu_replay_v1.jsonl @@ -0,0 +1,52 @@ +{"id": "FEI001", "message_content": "给项目1002新增一个信号源1,用于计算每个健身动作消耗的卡路里。", "sender": "宇航", "source_chat_id": "oc-replay-chat-a", "feishu_message_id": "om_replay_auto_001", "mock": {"intent": "statement", "level": {"build_intent_score": 0.18, "scope_score": 0.22, "timeline_score": 0.12, "owner_score": 0.11, "project_score": 0.2, "level": "signal", "level_confidence": 0.9, "level_reasoning": "回放样本:明确执行动作,按 signal 处理。", "should_split": false, "split_items": []}, "signal": {"title": "新增卡路里信号源1", "type": "新需求", "priority": "P1", "matched_project_id": 1002, "summary": "新增一个用于计算健身动作卡路里的信号源。", "reasoning": ["目标明确", "动作清晰"], "confidence": 0.93, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "auto_processed", "item_delta": 1, "reply_text": "已自动处理完成并入库,后续动作已继续自动推进。"}} +{"id": "FEI002", "message_content": "给项目1003新增一个信号源2,用于计算每个健身动作消耗的卡路里。", "sender": "宇航", "source_chat_id": "oc-replay-chat-a", "feishu_message_id": "om_replay_auto_002", "mock": {"intent": "statement", "level": {"build_intent_score": 0.18, "scope_score": 0.22, "timeline_score": 0.12, "owner_score": 0.11, "project_score": 0.2, "level": "signal", "level_confidence": 0.9, "level_reasoning": "回放样本:明确执行动作,按 signal 处理。", "should_split": false, "split_items": []}, "signal": {"title": "新增卡路里信号源2", "type": "新需求", "priority": "P1", "matched_project_id": 1003, "summary": "新增一个用于计算健身动作卡路里的信号源。", "reasoning": ["目标明确", "动作清晰"], "confidence": 0.93, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "auto_processed", "item_delta": 1, "reply_text": "已自动处理完成并入库,后续动作已继续自动推进。"}} +{"id": "FEI003", "message_content": "给项目1001新增一个信号源3,用于计算每个健身动作消耗的卡路里。", "sender": "宇航", "source_chat_id": "oc-replay-chat-a", "feishu_message_id": "om_replay_auto_003", "mock": {"intent": "statement", "level": {"build_intent_score": 0.18, "scope_score": 0.22, "timeline_score": 0.12, "owner_score": 0.11, "project_score": 0.2, "level": "signal", "level_confidence": 0.9, "level_reasoning": "回放样本:明确执行动作,按 signal 处理。", "should_split": false, "split_items": []}, "signal": {"title": "新增卡路里信号源3", "type": "新需求", "priority": "P1", "matched_project_id": 1001, "summary": "新增一个用于计算健身动作卡路里的信号源。", "reasoning": ["目标明确", "动作清晰"], "confidence": 0.93, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "auto_processed", "item_delta": 1, "reply_text": "已自动处理完成并入库,后续动作已继续自动推进。"}} +{"id": "FEI004", "message_content": "给项目1002新增一个信号源4,用于计算每个健身动作消耗的卡路里。", "sender": "宇航", "source_chat_id": "oc-replay-chat-a", "feishu_message_id": "om_replay_auto_004", "mock": {"intent": "statement", "level": {"build_intent_score": 0.18, "scope_score": 0.22, "timeline_score": 0.12, "owner_score": 0.11, "project_score": 0.2, "level": "signal", "level_confidence": 0.9, "level_reasoning": "回放样本:明确执行动作,按 signal 处理。", "should_split": false, "split_items": []}, "signal": {"title": "新增卡路里信号源4", "type": "新需求", "priority": "P1", "matched_project_id": 1002, "summary": "新增一个用于计算健身动作卡路里的信号源。", "reasoning": ["目标明确", "动作清晰"], "confidence": 0.93, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "auto_processed", "item_delta": 1, "reply_text": "已自动处理完成并入库,后续动作已继续自动推进。"}} +{"id": "FEI005", "message_content": "给项目1003新增一个信号源5,用于计算每个健身动作消耗的卡路里。", "sender": "宇航", "source_chat_id": "oc-replay-chat-a", "feishu_message_id": "om_replay_auto_005", "mock": {"intent": "statement", "level": {"build_intent_score": 0.18, "scope_score": 0.22, "timeline_score": 0.12, "owner_score": 0.11, "project_score": 0.2, "level": "signal", "level_confidence": 0.9, "level_reasoning": "回放样本:明确执行动作,按 signal 处理。", "should_split": false, "split_items": []}, "signal": {"title": "新增卡路里信号源5", "type": "新需求", "priority": "P1", "matched_project_id": 1003, "summary": "新增一个用于计算健身动作卡路里的信号源。", "reasoning": ["目标明确", "动作清晰"], "confidence": 0.93, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "auto_processed", "item_delta": 1, "reply_text": "已自动处理完成并入库,后续动作已继续自动推进。"}} +{"id": "FEI006", "message_content": "给项目1001新增一个信号源6,用于计算每个健身动作消耗的卡路里。", "sender": "宇航", "source_chat_id": "oc-replay-chat-a", "feishu_message_id": "om_replay_auto_006", "mock": {"intent": "statement", "level": {"build_intent_score": 0.18, "scope_score": 0.22, "timeline_score": 0.12, "owner_score": 0.11, "project_score": 0.2, "level": "signal", "level_confidence": 0.9, "level_reasoning": "回放样本:明确执行动作,按 signal 处理。", "should_split": false, "split_items": []}, "signal": {"title": "新增卡路里信号源6", "type": "新需求", "priority": "P1", "matched_project_id": 1001, "summary": "新增一个用于计算健身动作卡路里的信号源。", "reasoning": ["目标明确", "动作清晰"], "confidence": 0.93, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "auto_processed", "item_delta": 1, "reply_text": "已自动处理完成并入库,后续动作已继续自动推进。"}} +{"id": "FEI007", "message_content": "给项目1002新增一个信号源7,用于计算每个健身动作消耗的卡路里。", "sender": "宇航", "source_chat_id": "oc-replay-chat-a", "feishu_message_id": "om_replay_auto_007", "mock": {"intent": "statement", "level": {"build_intent_score": 0.18, "scope_score": 0.22, "timeline_score": 0.12, "owner_score": 0.11, "project_score": 0.2, "level": "signal", "level_confidence": 0.9, "level_reasoning": "回放样本:明确执行动作,按 signal 处理。", "should_split": false, "split_items": []}, "signal": {"title": "新增卡路里信号源7", "type": "新需求", "priority": "P1", "matched_project_id": 1002, "summary": "新增一个用于计算健身动作卡路里的信号源。", "reasoning": ["目标明确", "动作清晰"], "confidence": 0.93, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "auto_processed", "item_delta": 1, "reply_text": "已自动处理完成并入库,后续动作已继续自动推进。"}} +{"id": "FEI008", "message_content": "给项目1003新增一个信号源8,用于计算每个健身动作消耗的卡路里。", "sender": "宇航", "source_chat_id": "oc-replay-chat-a", "feishu_message_id": "om_replay_auto_008", "mock": {"intent": "statement", "level": {"build_intent_score": 0.18, "scope_score": 0.22, "timeline_score": 0.12, "owner_score": 0.11, "project_score": 0.2, "level": "signal", "level_confidence": 0.9, "level_reasoning": "回放样本:明确执行动作,按 signal 处理。", "should_split": false, "split_items": []}, "signal": {"title": "新增卡路里信号源8", "type": "新需求", "priority": "P1", "matched_project_id": 1003, "summary": "新增一个用于计算健身动作卡路里的信号源。", "reasoning": ["目标明确", "动作清晰"], "confidence": 0.93, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "auto_processed", "item_delta": 1, "reply_text": "已自动处理完成并入库,后续动作已继续自动推进。"}} +{"id": "FEI009", "message_content": "给项目1001新增一个信号源9,用于计算每个健身动作消耗的卡路里。", "sender": "宇航", "source_chat_id": "oc-replay-chat-a", "feishu_message_id": "om_replay_auto_009", "mock": {"intent": "statement", "level": {"build_intent_score": 0.18, "scope_score": 0.22, "timeline_score": 0.12, "owner_score": 0.11, "project_score": 0.2, "level": "signal", "level_confidence": 0.9, "level_reasoning": "回放样本:明确执行动作,按 signal 处理。", "should_split": false, "split_items": []}, "signal": {"title": "新增卡路里信号源9", "type": "新需求", "priority": "P1", "matched_project_id": 1001, "summary": "新增一个用于计算健身动作卡路里的信号源。", "reasoning": ["目标明确", "动作清晰"], "confidence": 0.93, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "auto_processed", "item_delta": 1, "reply_text": "已自动处理完成并入库,后续动作已继续自动推进。"}} +{"id": "FEI010", "message_content": "给项目1002新增一个信号源10,用于计算每个健身动作消耗的卡路里。", "sender": "宇航", "source_chat_id": "oc-replay-chat-a", "feishu_message_id": "om_replay_auto_010", "mock": {"intent": "statement", "level": {"build_intent_score": 0.18, "scope_score": 0.22, "timeline_score": 0.12, "owner_score": 0.11, "project_score": 0.2, "level": "signal", "level_confidence": 0.9, "level_reasoning": "回放样本:明确执行动作,按 signal 处理。", "should_split": false, "split_items": []}, "signal": {"title": "新增卡路里信号源10", "type": "新需求", "priority": "P1", "matched_project_id": 1002, "summary": "新增一个用于计算健身动作卡路里的信号源。", "reasoning": ["目标明确", "动作清晰"], "confidence": 0.93, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "auto_processed", "item_delta": 1, "reply_text": "已自动处理完成并入库,后续动作已继续自动推进。"}} +{"id": "FEI011", "message_content": "给项目1003新增一个信号源11,用于计算每个健身动作消耗的卡路里。", "sender": "宇航", "source_chat_id": "oc-replay-chat-a", "feishu_message_id": "om_replay_auto_011", "mock": {"intent": "statement", "level": {"build_intent_score": 0.18, "scope_score": 0.22, "timeline_score": 0.12, "owner_score": 0.11, "project_score": 0.2, "level": "signal", "level_confidence": 0.9, "level_reasoning": "回放样本:明确执行动作,按 signal 处理。", "should_split": false, "split_items": []}, "signal": {"title": "新增卡路里信号源11", "type": "新需求", "priority": "P1", "matched_project_id": 1003, "summary": "新增一个用于计算健身动作卡路里的信号源。", "reasoning": ["目标明确", "动作清晰"], "confidence": 0.93, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "auto_processed", "item_delta": 1, "reply_text": "已自动处理完成并入库,后续动作已继续自动推进。"}} +{"id": "FEI012", "message_content": "给项目1001新增一个信号源12,用于计算每个健身动作消耗的卡路里。", "sender": "宇航", "source_chat_id": "oc-replay-chat-a", "feishu_message_id": "om_replay_auto_012", "mock": {"intent": "statement", "level": {"build_intent_score": 0.18, "scope_score": 0.22, "timeline_score": 0.12, "owner_score": 0.11, "project_score": 0.2, "level": "signal", "level_confidence": 0.9, "level_reasoning": "回放样本:明确执行动作,按 signal 处理。", "should_split": false, "split_items": []}, "signal": {"title": "新增卡路里信号源12", "type": "新需求", "priority": "P1", "matched_project_id": 1001, "summary": "新增一个用于计算健身动作卡路里的信号源。", "reasoning": ["目标明确", "动作清晰"], "confidence": 0.93, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "auto_processed", "item_delta": 1, "reply_text": "已自动处理完成并入库,后续动作已继续自动推进。"}} +{"id": "FEI013", "message_content": "给项目1002新增一个信号源13,用于计算每个健身动作消耗的卡路里。", "sender": "宇航", "source_chat_id": "oc-replay-chat-a", "feishu_message_id": "om_replay_auto_013", "mock": {"intent": "statement", "level": {"build_intent_score": 0.18, "scope_score": 0.22, "timeline_score": 0.12, "owner_score": 0.11, "project_score": 0.2, "level": "signal", "level_confidence": 0.9, "level_reasoning": "回放样本:明确执行动作,按 signal 处理。", "should_split": false, "split_items": []}, "signal": {"title": "新增卡路里信号源13", "type": "新需求", "priority": "P1", "matched_project_id": 1002, "summary": "新增一个用于计算健身动作卡路里的信号源。", "reasoning": ["目标明确", "动作清晰"], "confidence": 0.93, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "auto_processed", "item_delta": 1, "reply_text": "已自动处理完成并入库,后续动作已继续自动推进。"}} +{"id": "FEI014", "message_content": "给项目1003新增一个信号源14,用于计算每个健身动作消耗的卡路里。", "sender": "宇航", "source_chat_id": "oc-replay-chat-a", "feishu_message_id": "om_replay_auto_014", "mock": {"intent": "statement", "level": {"build_intent_score": 0.18, "scope_score": 0.22, "timeline_score": 0.12, "owner_score": 0.11, "project_score": 0.2, "level": "signal", "level_confidence": 0.9, "level_reasoning": "回放样本:明确执行动作,按 signal 处理。", "should_split": false, "split_items": []}, "signal": {"title": "新增卡路里信号源14", "type": "新需求", "priority": "P1", "matched_project_id": 1003, "summary": "新增一个用于计算健身动作卡路里的信号源。", "reasoning": ["目标明确", "动作清晰"], "confidence": 0.93, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "auto_processed", "item_delta": 1, "reply_text": "已自动处理完成并入库,后续动作已继续自动推进。"}} +{"id": "FEI015", "message_content": "给项目1001新增一个信号源15,用于计算每个健身动作消耗的卡路里。", "sender": "宇航", "source_chat_id": "oc-replay-chat-a", "feishu_message_id": "om_replay_auto_015", "mock": {"intent": "statement", "level": {"build_intent_score": 0.18, "scope_score": 0.22, "timeline_score": 0.12, "owner_score": 0.11, "project_score": 0.2, "level": "signal", "level_confidence": 0.9, "level_reasoning": "回放样本:明确执行动作,按 signal 处理。", "should_split": false, "split_items": []}, "signal": {"title": "新增卡路里信号源15", "type": "新需求", "priority": "P1", "matched_project_id": 1001, "summary": "新增一个用于计算健身动作卡路里的信号源。", "reasoning": ["目标明确", "动作清晰"], "confidence": 0.93, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "auto_processed", "item_delta": 1, "reply_text": "已自动处理完成并入库,后续动作已继续自动推进。"}} +{"id": "FEI016", "message_content": "给项目1002新增一个信号源16,用于计算每个健身动作消耗的卡路里。", "sender": "宇航", "source_chat_id": "oc-replay-chat-a", "feishu_message_id": "om_replay_auto_016", "mock": {"intent": "statement", "level": {"build_intent_score": 0.18, "scope_score": 0.22, "timeline_score": 0.12, "owner_score": 0.11, "project_score": 0.2, "level": "signal", "level_confidence": 0.9, "level_reasoning": "回放样本:明确执行动作,按 signal 处理。", "should_split": false, "split_items": []}, "signal": {"title": "新增卡路里信号源16", "type": "新需求", "priority": "P1", "matched_project_id": 1002, "summary": "新增一个用于计算健身动作卡路里的信号源。", "reasoning": ["目标明确", "动作清晰"], "confidence": 0.93, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "auto_processed", "item_delta": 1, "reply_text": "已自动处理完成并入库,后续动作已继续自动推进。"}} +{"id": "FEI017", "message_content": "给项目1003新增一个信号源17,用于计算每个健身动作消耗的卡路里。", "sender": "宇航", "source_chat_id": "oc-replay-chat-a", "feishu_message_id": "om_replay_auto_017", "mock": {"intent": "statement", "level": {"build_intent_score": 0.18, "scope_score": 0.22, "timeline_score": 0.12, "owner_score": 0.11, "project_score": 0.2, "level": "signal", "level_confidence": 0.9, "level_reasoning": "回放样本:明确执行动作,按 signal 处理。", "should_split": false, "split_items": []}, "signal": {"title": "新增卡路里信号源17", "type": "新需求", "priority": "P1", "matched_project_id": 1003, "summary": "新增一个用于计算健身动作卡路里的信号源。", "reasoning": ["目标明确", "动作清晰"], "confidence": 0.93, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "auto_processed", "item_delta": 1, "reply_text": "已自动处理完成并入库,后续动作已继续自动推进。"}} +{"id": "FEI018", "message_content": "给项目1001新增一个信号源18,用于计算每个健身动作消耗的卡路里。", "sender": "宇航", "source_chat_id": "oc-replay-chat-a", "feishu_message_id": "om_replay_auto_018", "mock": {"intent": "statement", "level": {"build_intent_score": 0.18, "scope_score": 0.22, "timeline_score": 0.12, "owner_score": 0.11, "project_score": 0.2, "level": "signal", "level_confidence": 0.9, "level_reasoning": "回放样本:明确执行动作,按 signal 处理。", "should_split": false, "split_items": []}, "signal": {"title": "新增卡路里信号源18", "type": "新需求", "priority": "P1", "matched_project_id": 1001, "summary": "新增一个用于计算健身动作卡路里的信号源。", "reasoning": ["目标明确", "动作清晰"], "confidence": 0.93, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "auto_processed", "item_delta": 1, "reply_text": "已自动处理完成并入库,后续动作已继续自动推进。"}} +{"id": "FEI019", "message_content": "给项目1002新增一个信号源19,用于计算每个健身动作消耗的卡路里。", "sender": "宇航", "source_chat_id": "oc-replay-chat-a", "feishu_message_id": "om_replay_auto_019", "mock": {"intent": "statement", "level": {"build_intent_score": 0.18, "scope_score": 0.22, "timeline_score": 0.12, "owner_score": 0.11, "project_score": 0.2, "level": "signal", "level_confidence": 0.9, "level_reasoning": "回放样本:明确执行动作,按 signal 处理。", "should_split": false, "split_items": []}, "signal": {"title": "新增卡路里信号源19", "type": "新需求", "priority": "P1", "matched_project_id": 1002, "summary": "新增一个用于计算健身动作卡路里的信号源。", "reasoning": ["目标明确", "动作清晰"], "confidence": 0.93, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "auto_processed", "item_delta": 1, "reply_text": "已自动处理完成并入库,后续动作已继续自动推进。"}} +{"id": "FEI020", "message_content": "给项目1003新增一个信号源20,用于计算每个健身动作消耗的卡路里。", "sender": "宇航", "source_chat_id": "oc-replay-chat-a", "feishu_message_id": "om_replay_auto_020", "mock": {"intent": "statement", "level": {"build_intent_score": 0.18, "scope_score": 0.22, "timeline_score": 0.12, "owner_score": 0.11, "project_score": 0.2, "level": "signal", "level_confidence": 0.9, "level_reasoning": "回放样本:明确执行动作,按 signal 处理。", "should_split": false, "split_items": []}, "signal": {"title": "新增卡路里信号源20", "type": "新需求", "priority": "P1", "matched_project_id": 1003, "summary": "新增一个用于计算健身动作卡路里的信号源。", "reasoning": ["目标明确", "动作清晰"], "confidence": 0.93, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "auto_processed", "item_delta": 1, "reply_text": "已自动处理完成并入库,后续动作已继续自动推进。"}} +{"id": "FEI021", "message_content": "给项目1001补充信号点21,先记录一下,后续我再确认细节。", "sender": "宇航", "source_chat_id": "oc-replay-chat-b", "feishu_message_id": "om_replay_pending_021", "mock": {"intent": "statement", "level": {"build_intent_score": 0.2, "scope_score": 0.2, "timeline_score": 0.2, "owner_score": 0.1, "project_score": 0.24, "level": "signal", "level_confidence": 0.82, "level_reasoning": "回放样本:需求可执行但信息不完整,按 signal 入队。", "should_split": false, "split_items": []}, "signal": {"title": "待确认信号21", "type": "新需求", "priority": "P2", "matched_project_id": 1001, "summary": "需求进入待确认队列。", "reasoning": ["存在不确定项", "先入库待确认"], "confidence": 0.63, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "pending", "item_delta": 1, "reply_text": "已完成入库,当前进入待确认队列。"}} +{"id": "FEI022", "message_content": "给项目1002补充信号点22,先记录一下,后续我再确认细节。", "sender": "宇航", "source_chat_id": "oc-replay-chat-b", "feishu_message_id": "om_replay_pending_022", "mock": {"intent": "statement", "level": {"build_intent_score": 0.2, "scope_score": 0.2, "timeline_score": 0.2, "owner_score": 0.1, "project_score": 0.24, "level": "signal", "level_confidence": 0.82, "level_reasoning": "回放样本:需求可执行但信息不完整,按 signal 入队。", "should_split": false, "split_items": []}, "signal": {"title": "待确认信号22", "type": "新需求", "priority": "P2", "matched_project_id": 1002, "summary": "需求进入待确认队列。", "reasoning": ["存在不确定项", "先入库待确认"], "confidence": 0.63, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "pending", "item_delta": 1, "reply_text": "已完成入库,当前进入待确认队列。"}} +{"id": "FEI023", "message_content": "给项目1003补充信号点23,先记录一下,后续我再确认细节。", "sender": "宇航", "source_chat_id": "oc-replay-chat-b", "feishu_message_id": "om_replay_pending_023", "mock": {"intent": "statement", "level": {"build_intent_score": 0.2, "scope_score": 0.2, "timeline_score": 0.2, "owner_score": 0.1, "project_score": 0.24, "level": "signal", "level_confidence": 0.82, "level_reasoning": "回放样本:需求可执行但信息不完整,按 signal 入队。", "should_split": false, "split_items": []}, "signal": {"title": "待确认信号23", "type": "新需求", "priority": "P2", "matched_project_id": 1003, "summary": "需求进入待确认队列。", "reasoning": ["存在不确定项", "先入库待确认"], "confidence": 0.63, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "pending", "item_delta": 1, "reply_text": "已完成入库,当前进入待确认队列。"}} +{"id": "FEI024", "message_content": "给项目1001补充信号点24,先记录一下,后续我再确认细节。", "sender": "宇航", "source_chat_id": "oc-replay-chat-b", "feishu_message_id": "om_replay_pending_024", "mock": {"intent": "statement", "level": {"build_intent_score": 0.2, "scope_score": 0.2, "timeline_score": 0.2, "owner_score": 0.1, "project_score": 0.24, "level": "signal", "level_confidence": 0.82, "level_reasoning": "回放样本:需求可执行但信息不完整,按 signal 入队。", "should_split": false, "split_items": []}, "signal": {"title": "待确认信号24", "type": "新需求", "priority": "P2", "matched_project_id": 1001, "summary": "需求进入待确认队列。", "reasoning": ["存在不确定项", "先入库待确认"], "confidence": 0.63, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "pending", "item_delta": 1, "reply_text": "已完成入库,当前进入待确认队列。"}} +{"id": "FEI025", "message_content": "给项目1002补充信号点25,先记录一下,后续我再确认细节。", "sender": "宇航", "source_chat_id": "oc-replay-chat-b", "feishu_message_id": "om_replay_pending_025", "mock": {"intent": "statement", "level": {"build_intent_score": 0.2, "scope_score": 0.2, "timeline_score": 0.2, "owner_score": 0.1, "project_score": 0.24, "level": "signal", "level_confidence": 0.82, "level_reasoning": "回放样本:需求可执行但信息不完整,按 signal 入队。", "should_split": false, "split_items": []}, "signal": {"title": "待确认信号25", "type": "新需求", "priority": "P2", "matched_project_id": 1002, "summary": "需求进入待确认队列。", "reasoning": ["存在不确定项", "先入库待确认"], "confidence": 0.63, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "pending", "item_delta": 1, "reply_text": "已完成入库,当前进入待确认队列。"}} +{"id": "FEI026", "message_content": "给项目1003补充信号点26,先记录一下,后续我再确认细节。", "sender": "宇航", "source_chat_id": "oc-replay-chat-b", "feishu_message_id": "om_replay_pending_026", "mock": {"intent": "statement", "level": {"build_intent_score": 0.2, "scope_score": 0.2, "timeline_score": 0.2, "owner_score": 0.1, "project_score": 0.24, "level": "signal", "level_confidence": 0.82, "level_reasoning": "回放样本:需求可执行但信息不完整,按 signal 入队。", "should_split": false, "split_items": []}, "signal": {"title": "待确认信号26", "type": "新需求", "priority": "P2", "matched_project_id": 1003, "summary": "需求进入待确认队列。", "reasoning": ["存在不确定项", "先入库待确认"], "confidence": 0.63, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "pending", "item_delta": 1, "reply_text": "已完成入库,当前进入待确认队列。"}} +{"id": "FEI027", "message_content": "给项目1001补充信号点27,先记录一下,后续我再确认细节。", "sender": "宇航", "source_chat_id": "oc-replay-chat-b", "feishu_message_id": "om_replay_pending_027", "mock": {"intent": "statement", "level": {"build_intent_score": 0.2, "scope_score": 0.2, "timeline_score": 0.2, "owner_score": 0.1, "project_score": 0.24, "level": "signal", "level_confidence": 0.82, "level_reasoning": "回放样本:需求可执行但信息不完整,按 signal 入队。", "should_split": false, "split_items": []}, "signal": {"title": "待确认信号27", "type": "新需求", "priority": "P2", "matched_project_id": 1001, "summary": "需求进入待确认队列。", "reasoning": ["存在不确定项", "先入库待确认"], "confidence": 0.63, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "pending", "item_delta": 1, "reply_text": "已完成入库,当前进入待确认队列。"}} +{"id": "FEI028", "message_content": "给项目1002补充信号点28,先记录一下,后续我再确认细节。", "sender": "宇航", "source_chat_id": "oc-replay-chat-b", "feishu_message_id": "om_replay_pending_028", "mock": {"intent": "statement", "level": {"build_intent_score": 0.2, "scope_score": 0.2, "timeline_score": 0.2, "owner_score": 0.1, "project_score": 0.24, "level": "signal", "level_confidence": 0.82, "level_reasoning": "回放样本:需求可执行但信息不完整,按 signal 入队。", "should_split": false, "split_items": []}, "signal": {"title": "待确认信号28", "type": "新需求", "priority": "P2", "matched_project_id": 1002, "summary": "需求进入待确认队列。", "reasoning": ["存在不确定项", "先入库待确认"], "confidence": 0.63, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "pending", "item_delta": 1, "reply_text": "已完成入库,当前进入待确认队列。"}} +{"id": "FEI029", "message_content": "给项目1003补充信号点29,先记录一下,后续我再确认细节。", "sender": "宇航", "source_chat_id": "oc-replay-chat-b", "feishu_message_id": "om_replay_pending_029", "mock": {"intent": "statement", "level": {"build_intent_score": 0.2, "scope_score": 0.2, "timeline_score": 0.2, "owner_score": 0.1, "project_score": 0.24, "level": "signal", "level_confidence": 0.82, "level_reasoning": "回放样本:需求可执行但信息不完整,按 signal 入队。", "should_split": false, "split_items": []}, "signal": {"title": "待确认信号29", "type": "新需求", "priority": "P2", "matched_project_id": 1003, "summary": "需求进入待确认队列。", "reasoning": ["存在不确定项", "先入库待确认"], "confidence": 0.63, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "pending", "item_delta": 1, "reply_text": "已完成入库,当前进入待确认队列。"}} +{"id": "FEI030", "message_content": "给项目1001补充信号点30,先记录一下,后续我再确认细节。", "sender": "宇航", "source_chat_id": "oc-replay-chat-b", "feishu_message_id": "om_replay_pending_030", "mock": {"intent": "statement", "level": {"build_intent_score": 0.2, "scope_score": 0.2, "timeline_score": 0.2, "owner_score": 0.1, "project_score": 0.24, "level": "signal", "level_confidence": 0.82, "level_reasoning": "回放样本:需求可执行但信息不完整,按 signal 入队。", "should_split": false, "split_items": []}, "signal": {"title": "待确认信号30", "type": "新需求", "priority": "P2", "matched_project_id": 1001, "summary": "需求进入待确认队列。", "reasoning": ["存在不确定项", "先入库待确认"], "confidence": 0.63, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "signal", "scene": "execute", "route_status": "pending", "item_delta": 1, "reply_text": "已完成入库,当前进入待确认队列。"}} +{"id": "FEI031", "message_content": "我们要启动一个跨团队项目31,请立项并规划里程碑。", "sender": "宇航", "source_chat_id": "oc-replay-chat-c", "feishu_message_id": "om_replay_project_pending_031", "mock": {"intent": "statement", "level": {"build_intent_score": 0.95, "scope_score": 0.88, "timeline_score": 0.82, "owner_score": 0.78, "project_score": 0.9, "level": "project", "level_confidence": 0.92, "level_reasoning": "回放样本:跨团队立项需求,按 project 处理。", "should_split": false, "split_items": []}, "project_prefill": {"suggested_name": "回放立项-31", "suggested_description": "跨团队协作项目,需明确里程碑与负责人。", "suggested_owner": "回放负责人", "suggested_timeline": "4周", "confidence": 0.87, "reasoning": ["范围较大", "需要立项审批"]}, "routing_policy": {"cold_start_enabled": true, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "project", "scene": "execute", "route_status": "pending", "item_delta": 1, "reply_text": "已完成入库,当前进入待确认队列。"}} +{"id": "FEI032", "message_content": "我们要启动一个跨团队项目32,请立项并规划里程碑。", "sender": "宇航", "source_chat_id": "oc-replay-chat-c", "feishu_message_id": "om_replay_project_pending_032", "mock": {"intent": "statement", "level": {"build_intent_score": 0.95, "scope_score": 0.88, "timeline_score": 0.82, "owner_score": 0.78, "project_score": 0.9, "level": "project", "level_confidence": 0.92, "level_reasoning": "回放样本:跨团队立项需求,按 project 处理。", "should_split": false, "split_items": []}, "project_prefill": {"suggested_name": "回放立项-32", "suggested_description": "跨团队协作项目,需明确里程碑与负责人。", "suggested_owner": "回放负责人", "suggested_timeline": "4周", "confidence": 0.87, "reasoning": ["范围较大", "需要立项审批"]}, "routing_policy": {"cold_start_enabled": true, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "project", "scene": "execute", "route_status": "pending", "item_delta": 1, "reply_text": "已完成入库,当前进入待确认队列。"}} +{"id": "FEI033", "message_content": "我们要启动一个跨团队项目33,请立项并规划里程碑。", "sender": "宇航", "source_chat_id": "oc-replay-chat-c", "feishu_message_id": "om_replay_project_pending_033", "mock": {"intent": "statement", "level": {"build_intent_score": 0.95, "scope_score": 0.88, "timeline_score": 0.82, "owner_score": 0.78, "project_score": 0.9, "level": "project", "level_confidence": 0.92, "level_reasoning": "回放样本:跨团队立项需求,按 project 处理。", "should_split": false, "split_items": []}, "project_prefill": {"suggested_name": "回放立项-33", "suggested_description": "跨团队协作项目,需明确里程碑与负责人。", "suggested_owner": "回放负责人", "suggested_timeline": "4周", "confidence": 0.87, "reasoning": ["范围较大", "需要立项审批"]}, "routing_policy": {"cold_start_enabled": true, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "project", "scene": "execute", "route_status": "pending", "item_delta": 1, "reply_text": "已完成入库,当前进入待确认队列。"}} +{"id": "FEI034", "message_content": "我们要启动一个跨团队项目34,请立项并规划里程碑。", "sender": "宇航", "source_chat_id": "oc-replay-chat-c", "feishu_message_id": "om_replay_project_pending_034", "mock": {"intent": "statement", "level": {"build_intent_score": 0.95, "scope_score": 0.88, "timeline_score": 0.82, "owner_score": 0.78, "project_score": 0.9, "level": "project", "level_confidence": 0.92, "level_reasoning": "回放样本:跨团队立项需求,按 project 处理。", "should_split": false, "split_items": []}, "project_prefill": {"suggested_name": "回放立项-34", "suggested_description": "跨团队协作项目,需明确里程碑与负责人。", "suggested_owner": "回放负责人", "suggested_timeline": "4周", "confidence": 0.87, "reasoning": ["范围较大", "需要立项审批"]}, "routing_policy": {"cold_start_enabled": true, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "project", "scene": "execute", "route_status": "pending", "item_delta": 1, "reply_text": "已完成入库,当前进入待确认队列。"}} +{"id": "FEI035", "message_content": "我们要启动一个跨团队项目35,请立项并规划里程碑。", "sender": "宇航", "source_chat_id": "oc-replay-chat-c", "feishu_message_id": "om_replay_project_pending_035", "mock": {"intent": "statement", "level": {"build_intent_score": 0.95, "scope_score": 0.88, "timeline_score": 0.82, "owner_score": 0.78, "project_score": 0.9, "level": "project", "level_confidence": 0.92, "level_reasoning": "回放样本:跨团队立项需求,按 project 处理。", "should_split": false, "split_items": []}, "project_prefill": {"suggested_name": "回放立项-35", "suggested_description": "跨团队协作项目,需明确里程碑与负责人。", "suggested_owner": "回放负责人", "suggested_timeline": "4周", "confidence": 0.87, "reasoning": ["范围较大", "需要立项审批"]}, "routing_policy": {"cold_start_enabled": true, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "project", "scene": "execute", "route_status": "pending", "item_delta": 1, "reply_text": "已完成入库,当前进入待确认队列。"}} +{"id": "FEI036", "message_content": "新项目36请直接创建并开始执行,这个需求范围已经明确。", "sender": "宇航", "source_chat_id": "oc-replay-chat-d", "feishu_message_id": "om_replay_project_auto_036", "mock": {"intent": "statement", "level": {"build_intent_score": 0.96, "scope_score": 0.9, "timeline_score": 0.84, "owner_score": 0.8, "project_score": 0.93, "level": "project", "level_confidence": 0.93, "level_reasoning": "回放样本:项目范围明确且置信度高。", "should_split": false, "split_items": []}, "project_prefill": {"suggested_name": "回放自动立项-36", "suggested_description": "需求完整,可自动创建项目。", "suggested_owner": "回放负责人", "suggested_timeline": "3周", "confidence": 0.92, "reasoning": ["信息完整", "可直接落地"]}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "project", "scene": "execute", "route_status": "auto_processed", "item_delta": 1, "reply_text": "已自动处理完成并入库,后续动作已继续自动推进。"}} +{"id": "FEI037", "message_content": "新项目37请直接创建并开始执行,这个需求范围已经明确。", "sender": "宇航", "source_chat_id": "oc-replay-chat-d", "feishu_message_id": "om_replay_project_auto_037", "mock": {"intent": "statement", "level": {"build_intent_score": 0.96, "scope_score": 0.9, "timeline_score": 0.84, "owner_score": 0.8, "project_score": 0.93, "level": "project", "level_confidence": 0.93, "level_reasoning": "回放样本:项目范围明确且置信度高。", "should_split": false, "split_items": []}, "project_prefill": {"suggested_name": "回放自动立项-37", "suggested_description": "需求完整,可自动创建项目。", "suggested_owner": "回放负责人", "suggested_timeline": "3周", "confidence": 0.92, "reasoning": ["信息完整", "可直接落地"]}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "project", "scene": "execute", "route_status": "auto_processed", "item_delta": 1, "reply_text": "已自动处理完成并入库,后续动作已继续自动推进。"}} +{"id": "FEI038", "message_content": "系统管理员现在分配的工时是多少?(回放38)", "sender": "宇航", "source_chat_id": "oc-replay-chat-qa", "feishu_message_id": "om_replay_qa_038", "mock": {"intent": "question", "qa_answer": "回放问答答案38:当前记录显示工时分配需以项目看板数据为准。", "qa_sources": [{"label": "Replay Source"}]}, "expected": {"status": "answered", "classification": "question", "scene": "qa", "item_delta": 0, "reply_contains": ["回放问答答案38:当前记录显示工时分配需以项目看板数据为准。", "来源:"]}} +{"id": "FEI039", "message_content": "系统管理员现在分配的工时是多少?(回放39)", "sender": "宇航", "source_chat_id": "oc-replay-chat-qa", "feishu_message_id": "om_replay_qa_039", "mock": {"intent": "question", "qa_answer": "回放问答答案39:当前记录显示工时分配需以项目看板数据为准。", "qa_sources": [{"label": "Replay Source"}]}, "expected": {"status": "answered", "classification": "question", "scene": "qa", "item_delta": 0, "reply_contains": ["回放问答答案39:当前记录显示工时分配需以项目看板数据为准。", "来源:"]}} +{"id": "FEI040", "message_content": "系统管理员现在分配的工时是多少?(回放40)", "sender": "宇航", "source_chat_id": "oc-replay-chat-qa", "feishu_message_id": "om_replay_qa_040", "mock": {"intent": "question", "qa_answer": "回放问答答案40:当前记录显示工时分配需以项目看板数据为准。", "qa_sources": [{"label": "Replay Source"}]}, "expected": {"status": "answered", "classification": "question", "scene": "qa", "item_delta": 0, "reply_contains": ["回放问答答案40:当前记录显示工时分配需以项目看板数据为准。", "来源:"]}} +{"id": "FEI041", "message_content": "系统管理员现在分配的工时是多少?(回放41)", "sender": "宇航", "source_chat_id": "oc-replay-chat-qa", "feishu_message_id": "om_replay_qa_041", "mock": {"intent": "question", "qa_answer": "回放问答答案41:当前记录显示工时分配需以项目看板数据为准。", "qa_sources": [{"label": "Replay Source"}]}, "expected": {"status": "answered", "classification": "question", "scene": "qa", "item_delta": 0, "reply_contains": ["回放问答答案41:当前记录显示工时分配需以项目看板数据为准。", "来源:"]}} +{"id": "FEI042", "message_content": "系统管理员现在分配的工时是多少?(回放42)", "sender": "宇航", "source_chat_id": "oc-replay-chat-qa", "feishu_message_id": "om_replay_qa_042", "mock": {"intent": "question", "qa_answer": "回放问答答案42:当前记录显示工时分配需以项目看板数据为准。", "qa_sources": [{"label": "Replay Source"}]}, "expected": {"status": "answered", "classification": "question", "scene": "qa", "item_delta": 0, "reply_contains": ["回放问答答案42:当前记录显示工时分配需以项目看板数据为准。", "来源:"]}} +{"id": "FEI043", "message_content": "在吗", "sender": "宇航", "source_chat_id": "oc-replay-chat-noise", "feishu_message_id": "om_replay_noise_043", "mock": {"intent": "statement"}, "expected": {"status": "ignored", "reason": "noise_filtered", "classification": "non_requirement", "scene": "execute", "item_delta": 0, "reply_text": "这条消息已忽略(未入库)。如需执行,请明确目标、对象和动作。"}} +{"id": "FEI044", "message_content": "你是谁", "sender": "宇航", "source_chat_id": "oc-replay-chat-noise", "feishu_message_id": "om_replay_noise_044", "mock": {"intent": "statement"}, "expected": {"status": "ignored", "reason": "noise_filtered", "classification": "non_requirement", "scene": "execute", "item_delta": 0, "reply_text": "这条消息已忽略(未入库)。如需执行,请明确目标、对象和动作。"}} +{"id": "FEI045", "message_content": "今天几号", "sender": "宇航", "source_chat_id": "oc-replay-chat-noise", "feishu_message_id": "om_replay_noise_045", "mock": {"intent": "statement"}, "expected": {"status": "ignored", "reason": "noise_filtered", "classification": "non_requirement", "scene": "execute", "item_delta": 0, "reply_text": "这条消息已忽略(未入库)。如需执行,请明确目标、对象和动作。"}} +{"id": "FEI046", "message_content": "现在几点", "sender": "宇航", "source_chat_id": "oc-replay-chat-noise", "feishu_message_id": "om_replay_noise_046", "mock": {"intent": "statement"}, "expected": {"status": "ignored", "reason": "noise_filtered", "classification": "non_requirement", "scene": "execute", "item_delta": 0, "reply_text": "这条消息已忽略(未入库)。如需执行,请明确目标、对象和动作。"}} +{"id": "FEI047", "message_content": "说话", "sender": "宇航", "source_chat_id": "oc-replay-chat-noise", "feishu_message_id": "om_replay_noise_047", "mock": {"intent": "statement"}, "expected": {"status": "ignored", "reason": "noise_filtered", "classification": "non_requirement", "scene": "execute", "item_delta": 0, "reply_text": "这条消息已忽略(未入库)。如需执行,请明确目标、对象和动作。"}} +{"id": "FEI048", "message_content": "重复测试消息1", "sender": "宇航", "source_chat_id": "oc-replay-chat-a", "feishu_message_id": "om_replay_auto_001", "mock": {"intent": "statement"}, "expected": {"status": "ignored", "reason": "duplicate_filtered", "classification": "duplicate", "scene": "execute", "route_status": "auto_processed", "item_delta": 0, "reply_text": "这条消息我已处理过,未重复入库。"}} +{"id": "FEI049", "message_content": "重复测试消息2", "sender": "宇航", "source_chat_id": "oc-replay-chat-a", "feishu_message_id": "om_replay_auto_002", "mock": {"intent": "statement"}, "expected": {"status": "ignored", "reason": "duplicate_filtered", "classification": "duplicate", "scene": "execute", "route_status": "auto_processed", "item_delta": 0, "reply_text": "这条消息我已处理过,未重复入库。"}} +{"id": "FEI050", "message_content": "这是一条用于验证 schema 失败的回放消息。", "sender": "宇航", "source_chat_id": "oc-replay-chat-a", "feishu_message_id": "om_replay_schema_050", "mock": {"intent": "statement", "force_schema_error": true}, "expected": {"status": "error", "classification": "error", "scene": "error", "item_delta": 0, "reply_text": "系统处理中出现异常,本次未执行。请稍后重试,或补充更明确的目标、对象和动作。"}} +{"id": "FEI051", "message_content": "我们要启动一个跨团队项目 Alpha,并给项目1001补充一个风险告警信号。", "sender": "宇航", "source_chat_id": "oc-replay-chat-split", "feishu_message_id": "om_replay_split_051", "mock": {"intent": "statement", "level": {"build_intent_score": 0.92, "scope_score": 0.88, "timeline_score": 0.8, "owner_score": 0.76, "project_score": 0.9, "level": "project", "level_confidence": 0.91, "level_reasoning": "回放样本:同条消息含立项与执行需求,按 split 处理。", "should_split": true, "split_items": [{"level": "project", "content": "我们要启动一个跨团队项目 Alpha。"}, {"level": "signal", "content": "给项目1001补充一个风险告警信号。"}]}, "project_prefill": {"suggested_name": "回放Split项目-Alpha", "suggested_description": "跨团队协作项目,需立项并排期。", "suggested_owner": "回放负责人", "suggested_timeline": "4周", "confidence": 0.88, "reasoning": ["范围较大", "需要立项审批"]}, "signal": {"title": "补充风险告警信号", "type": "项目风险", "priority": "P1", "matched_project_id": 1001, "summary": "为项目增加风险监控告警信号。", "reasoning": ["执行对象明确", "动作可落地"], "confidence": 0.9, "recommended_action": "trigger_alert"}, "routing_policy": {"cold_start_enabled": true, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "split", "scene": "execute", "route_status": "pending", "item_delta": 2, "result_count": 2, "result_levels": ["project", "signal"], "multi_write": true, "reply_text": "已完成入库,当前进入待确认队列。"}} +{"id": "FEI052", "message_content": "请立项 Beta 项目并同时给项目1002新增一个性能监控信号。", "sender": "宇航", "source_chat_id": "oc-replay-chat-split", "feishu_message_id": "om_replay_split_052", "mock": {"intent": "statement", "level": {"build_intent_score": 0.94, "scope_score": 0.9, "timeline_score": 0.84, "owner_score": 0.8, "project_score": 0.93, "level": "project", "level_confidence": 0.92, "level_reasoning": "回放样本:拆分后两项都可自动执行。", "should_split": true, "split_items": [{"level": "project", "content": "请立项 Beta 项目。"}, {"level": "signal", "content": "给项目1002新增一个性能监控信号。"}]}, "project_prefill": {"suggested_name": "回放Split项目-Beta", "suggested_description": "可自动创建的立项请求。", "suggested_owner": "回放负责人", "suggested_timeline": "3周", "confidence": 0.93, "reasoning": ["信息完整", "可自动创建"]}, "signal": {"title": "新增性能监控信号", "type": "新需求", "priority": "P1", "matched_project_id": 1002, "summary": "补充性能监控与预警。", "reasoning": ["目标明确", "信息充分"], "confidence": 0.95, "recommended_action": "enter_only"}, "routing_policy": {"cold_start_enabled": false, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": false}}, "expected": {"status": "captured", "classification": "split", "scene": "execute", "route_status": "auto_processed", "item_delta": 2, "result_count": 2, "result_levels": ["project", "signal"], "multi_write": true, "reply_text": "已自动处理完成并入库,后续动作已继续自动推进。"}} diff --git a/backend/evals/golden_qa_v1.jsonl b/backend/evals/golden_qa_v1.jsonl new file mode 100644 index 00000000..2da42cc8 --- /dev/null +++ b/backend/evals/golden_qa_v1.jsonl @@ -0,0 +1,55 @@ +{"id":"Q001","question":"你是谁","expected_any":["PMFlow","AI 管家"],"expected_query_type":"explain","is_followup":false,"forbidden_any":["未找到足够相关的信息"]} +{"id":"Q002","question":"你能做什么","expected_any":["职责","项目"],"expected_query_type":"explain","is_followup":false,"forbidden_any":["未找到足够相关的信息"]} +{"id":"Q003","question":"介绍你自己","expected_any":["PMFlow","助手"],"expected_query_type":"explain","is_followup":false,"forbidden_any":["未找到足够相关的信息"]} +{"id":"Q004","question":"你叫什么","expected_any":["PMFlow","AI"],"expected_query_type":"explain","is_followup":false,"forbidden_any":["未找到足够相关的信息"]} +{"id":"Q005","question":"你负责什么","expected_any":["职责","任务"],"expected_query_type":"explain","is_followup":false,"forbidden_any":["未找到足够相关的信息"]} +{"id":"Q006","question":"你会什么","expected_any":["回答","项目"],"expected_query_type":"explain","is_followup":false,"forbidden_any":["未找到足够相关的信息"]} +{"id":"Q007","question":"系统架构是什么","expected_any":["FastAPI","数据库"],"expected_query_type":"explain","is_followup":false,"forbidden_any":["未找到足够相关的信息"]} +{"id":"Q008","question":"项目运行机制是怎样的","expected_any":["架构","缓存"],"expected_query_type":"explain","is_followup":false,"forbidden_any":["未找到足够相关的信息"]} +{"id":"Q009","question":"技术栈是什么","expected_any":["FastAPI","SQLAlchemy"],"expected_query_type":"explain","is_followup":false,"forbidden_any":["未找到足够相关的信息"]} +{"id":"Q010","question":"后端和数据库怎么组成","expected_any":["后端","数据库"],"expected_query_type":"explain","is_followup":false,"forbidden_any":["未找到足够相关的信息"]} +{"id":"Q011","question":"当前系统里接了几个项目","expected_any":["项目总数","个"],"expected_query_type":"count","is_followup":false} +{"id":"Q012","question":"有多少任务","expected_any":["任务总数"],"expected_query_type":"count","is_followup":false} +{"id":"Q013","question":"现在有多少信号","expected_any":["信号总数"],"expected_query_type":"count","is_followup":false} +{"id":"Q014","question":"把当前项目都列出来","expected_any":["项目列表"],"expected_query_type":"list","is_followup":false} +{"id":"Q015","question":"分别有哪些项目","expected_any":["项目列表"],"expected_query_type":"list","is_followup":true} +{"id":"Q016","question":"列出项目清单","expected_any":["#"],"expected_query_type":"list","is_followup":false} +{"id":"Q017","question":"系统里项目进度如何","expected_any":["状态","完成率"],"expected_query_type":"status","is_followup":false} +{"id":"Q018","question":"项目现在是什么状态","expected_any":["状态"],"expected_query_type":"status","is_followup":false} +{"id":"Q019","question":"有没有项目","expected_any":["项目"],"expected_query_type":"status","is_followup":false} +{"id":"Q020","question":"目前系统里面接了几个项目","expected_any":["项目总数"],"expected_query_type":"count","is_followup":false} +{"id":"Q021","question":"目前系统里接了几个项目","expected_any":["项目总数"],"expected_query_type":"count","is_followup":false,"group":"G1"} +{"id":"Q022","question":"分别是什么","expected_any":["项目列表"],"expected_query_type":"list","is_followup":true,"group":"G1"} +{"id":"Q023","question":"再具体一点","expected_any":["#"],"expected_query_type":"list","is_followup":true,"group":"G1"} +{"id":"Q024","question":"你是谁","expected_any":["PMFlow"],"expected_query_type":"explain","is_followup":false,"group":"G2"} +{"id":"Q025","question":"你能负责哪些事","expected_any":["职责"],"expected_query_type":"explain","is_followup":true,"group":"G2"} +{"id":"Q026","question":"系统架构","expected_any":["FastAPI"],"expected_query_type":"explain","is_followup":false,"group":"G3"} +{"id":"Q027","question":"数据库和缓存分别是什么","expected_any":["数据库","缓存"],"expected_query_type":"explain","is_followup":true,"group":"G3"} +{"id":"Q028","question":"当前有多少项目","expected_any":["项目总数"],"expected_query_type":"count","is_followup":false,"group":"G4"} +{"id":"Q029","question":"分别有哪些","expected_any":["项目列表"],"expected_query_type":"list","is_followup":true,"group":"G4"} +{"id":"Q030","question":"这些项目状态如何","expected_any":["状态"],"expected_query_type":"status","is_followup":true,"group":"G4"} +{"id":"Q031","question":"现在有多少任务","expected_any":["任务总数"],"expected_query_type":"count","is_followup":false,"group":"G5"} +{"id":"Q032","question":"这些任务谁在负责","expected_any":["负责人","ID"],"expected_query_type":"status","is_followup":true,"group":"G5"} +{"id":"Q033","question":"有哪些待处理信号","expected_any":["信号"],"expected_query_type":"list","is_followup":false} +{"id":"Q034","question":"当前系统有无信号","expected_any":["信号"],"expected_query_type":"count","is_followup":false} +{"id":"Q035","question":"系统里项目和任务关系是什么","expected_any":["任务","项目"],"expected_query_type":"explain","is_followup":false} +{"id":"Q036","question":"这个系统的api大概有哪些模块","expected_any":["api","模块"],"expected_query_type":"explain","is_followup":false} +{"id":"Q037","question":"redis 在哪里用","expected_any":["缓存","Redis"],"expected_query_type":"explain","is_followup":false} +{"id":"Q038","question":"AI 为什么之前答非所问","expected_any":["上下文","证据"],"expected_query_type":"explain","is_followup":false} +{"id":"Q039","question":"现在回答会先查结构化数据吗","expected_any":["结构化","查询"],"expected_query_type":"explain","is_followup":false} +{"id":"Q040","question":"当前系统里有多少用户","expected_any":["用户"],"expected_query_type":"count","is_followup":false} +{"id":"Q041","question":"给我项目状态列表","expected_any":["状态"],"expected_query_type":"status","is_followup":false} +{"id":"Q042","question":"项目清单","expected_any":["项目列表"],"expected_query_type":"list","is_followup":false} +{"id":"Q043","question":"分别列出项目id和名称","expected_any":["#"],"expected_query_type":"list","is_followup":false} +{"id":"Q044","question":"今天系统有几个项目","expected_any":["项目总数"],"expected_query_type":"count","is_followup":false} +{"id":"Q045","question":"当前系统整体架构简述","expected_any":["架构"],"expected_query_type":"explain","is_followup":false} +{"id":"Q046","question":"如果我问‘分别是什么’你会怎么处理","expected_any":["主语","澄清"],"expected_query_type":"explain","is_followup":false} +{"id":"Q047","question":"分别是什么","expected_any":["缺少明确主语","请先指明对象"],"expected_query_type":"list","is_followup":true} +{"id":"Q048","question":"你是否知道当前项目机制","expected_any":["系统","架构"],"expected_query_type":"explain","is_followup":false} +{"id":"Q049","question":"现在项目数量是多少","expected_any":["项目总数"],"expected_query_type":"count","is_followup":false} +{"id":"Q050","question":"请列出系统中的项目","expected_any":["项目列表"],"expected_query_type":"list","is_followup":false} +{"id":"Q051","question":"健身食谱项目里面有多少个信号源","expected_any":["信号","健身食谱"],"expected_query_type":"count","is_followup":false,"group":"G6"} +{"id":"Q052","question":"目前系统里面有多少个项目","expected_any":["项目总数"],"expected_query_type":"count","is_followup":false,"group":"G6"} +{"id":"Q053","question":"分别是什么","expected_any":["项目列表"],"expected_query_type":"list","is_followup":true,"group":"G6"} +{"id":"Q054","question":"健身食谱项目里面有多少个信号源","expected_any":["信号","健身食谱"],"expected_query_type":"count","is_followup":false,"group":"G7"} +{"id":"Q055","question":"分别什么信号源","expected_any":["信号清单","#"],"expected_query_type":"list","is_followup":true,"group":"G7"} diff --git a/backend/evals/rag_project_understanding_eval.py b/backend/evals/rag_project_understanding_eval.py new file mode 100644 index 00000000..ac95b55b --- /dev/null +++ b/backend/evals/rag_project_understanding_eval.py @@ -0,0 +1,151 @@ +"""Project understanding regression eval for AdaptiveRAGService. + +Usage: + python backend/evals/rag_project_understanding_eval.py --tenant-id 1 +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +def _bootstrap_path() -> None: + this_file = Path(__file__).resolve() + backend_root = this_file.parents[1] + if str(backend_root) not in sys.path: + sys.path.insert(0, str(backend_root)) + + +_bootstrap_path() + +from app.database import AsyncSessionLocal # noqa: E402 +from app.services.adaptive_rag_service import AdaptiveRAGService # noqa: E402 + + +@dataclass(slots=True) +class EvalCase: + question: str + expected_keywords: list[str] + + +CASES: list[EvalCase] = [ + EvalCase( + question="智能配置功能是用来干嘛的?", + expected_keywords=["智能配置", "配置", "/ai-config", "知识库"], + ), + EvalCase( + question="能力管理页面主要做什么?", + expected_keywords=["能力", "技能", "/skills", "推荐"], + ), + EvalCase( + question="AI 管家页面有哪些用途?", + expected_keywords=["AI 管家", "/ai-steward", "冲突", "问答"], + ), + EvalCase( + question="项目列表页面入口在哪,负责什么?", + expected_keywords=["项目", "/projects", "状态"], + ), + EvalCase( + question="团队负载功能的目标是什么?", + expected_keywords=["负载", "/workloads", "调配"], + ), +] + + +def _keyword_hit_ratio(text: str, keywords: list[str]) -> float: + haystack = str(text or "").lower() + if not keywords: + return 1.0 + hit = 0 + for kw in keywords: + if str(kw).strip().lower() in haystack: + hit += 1 + return round(hit / max(len(keywords), 1), 4) + + +async def _run_eval(tenant_id: int) -> dict[str, Any]: + async with AsyncSessionLocal() as db: + service = AdaptiveRAGService(db, tenant_id=tenant_id) + system_snapshot = await service.context_cache.get_system_snapshot(db=db, tenant_id=tenant_id) + + rows: list[dict[str, Any]] = [] + evidence_hits: list[float] = [] + for case in CASES: + feature_matches = service._match_feature_catalog_entries( + question=case.question, + system_snapshot=system_snapshot, + limit=2, + ) + direct_answer = service._build_snapshot_direct_answer( + case.question, + system_snapshot=system_snapshot, + user_snapshot={}, + ) or "" + retrieval = await service.multi_step_search( + question=case.question, + project_id=None, + source_chat_id=None, + session_state=None, + ) + retrieval_text = "\n".join( + str(item.get("label") or "") + " " + str(item.get("metadata") or "") + for item in retrieval.get("items") or [] + if isinstance(item, dict) + ) + merged_text = f"{direct_answer}\n{retrieval_text}" + hit_ratio = _keyword_hit_ratio(merged_text, case.expected_keywords) + evidence_ratio = 0.0 + if feature_matches: + top = feature_matches[0] + expected_evidence: list[str] = [] + route = str(top.get("route") or "").strip() + page_file = str(top.get("page_file") or "").strip() + apis = top.get("apis") if isinstance(top.get("apis"), list) else [] + if route: + expected_evidence.append(route) + if page_file: + expected_evidence.append(page_file) + for api in apis[:2]: + if str(api).strip(): + expected_evidence.append(str(api).strip()) + evidence_ratio = _keyword_hit_ratio(direct_answer, expected_evidence[:3]) if expected_evidence else 0.0 + evidence_hits.append(evidence_ratio) + rows.append( + { + "question": case.question, + "expected_keywords": case.expected_keywords, + "keyword_hit_ratio": hit_ratio, + "feature_evidence_ratio": evidence_ratio, + "direct_answer_preview": direct_answer[:240], + "retrieval_item_count": len(retrieval.get("items") or []), + } + ) + + avg_score = round(sum(float(x["keyword_hit_ratio"]) for x in rows) / max(len(rows), 1), 4) + avg_feature_evidence_ratio = round(sum(evidence_hits) / max(len(evidence_hits), 1), 4) + return { + "tenant_id": tenant_id, + "case_count": len(rows), + "avg_keyword_hit_ratio": avg_score, + "avg_feature_evidence_ratio": avg_feature_evidence_ratio, + "cases": rows, + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run project understanding eval.") + parser.add_argument("--tenant-id", type=int, default=1, help="Tenant ID") + args = parser.parse_args() + + result = asyncio.run(_run_eval(args.tenant_id)) + print(json.dumps(result, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 00000000..0c0701e8 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,24 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 +sqlalchemy>=2.0.30 +alembic>=1.13.2 +asyncpg>=0.29.0 +aiosqlite>=0.20.0 +psycopg[binary]>=3.2.0 +pgvector>=0.3.5 +python-dotenv>=1.0.1 +redis>=5.0.8 +httpx>=0.27.0 +pydantic>=2.8.2 +python-multipart>=0.0.9 +structlog>=24.2.0 +passlib[bcrypt]>=1.7.4 +python-jose[cryptography]>=3.3.0 +PyJWT>=2.9.0 +langchain-openai>=0.2.0 +langchain-core>=0.3.0 +openai>=1.47.0 +pypdf>=4.3.1 +python-docx>=1.1.2 +openpyxl>=3.1.5 +lark-oapi>=1.4.10 diff --git a/backend/scripts/migrate_sqlite_to_postgres.py b/backend/scripts/migrate_sqlite_to_postgres.py new file mode 100644 index 00000000..95a9c112 --- /dev/null +++ b/backend/scripts/migrate_sqlite_to_postgres.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +"""将本地 SQLite 数据迁移到当前 PostgreSQL(Docker)。 + +默认源库: /app/backend/neural_sync.db +默认目标库: 环境变量 DATABASE_URL +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sqlite3 +from datetime import datetime +from typing import Any + +import asyncpg + +TABLE_ORDER = [ + "skill_tags", + "users", + "projects", + "feishu_items", + "signals", + "truth_nodes", + "tasks", + "project_signal_links", + "user_skill_bindings", + "user_workloads", + "qa_sessions", + "qa_records", + "qa_feedback", +] + + +def _parse_json(value: Any, default: Any) -> Any: + if value is None: + return default + if isinstance(value, (dict, list)): + return value + text = str(value).strip() + if not text: + return default + try: + return json.loads(text) + except Exception: + return default + + +def _parse_ts(value: Any) -> Any: + if value is None: + return None + if isinstance(value, datetime): + return value + text = str(value).strip() + if not text: + return None + text = text.replace("T", " ").replace("Z", "") + try: + return datetime.fromisoformat(text) + except Exception: + return text + + +def _parse_int_array(value: Any) -> list[int]: + if value is None: + return [] + if isinstance(value, list): + raw = value + else: + raw = _parse_json(value, default=[]) + if not isinstance(raw, list): + raw = [] + result: list[int] = [] + for item in raw: + try: + num = int(item) + result.append(num) + except Exception: + continue + return result + + +def _transform(table: str, row: dict[str, Any], pg_types: dict[str, str]) -> dict[str, Any]: + data = dict(row) + + if table == "users": + data["assigned_domains"] = _parse_json(data.get("assigned_domains"), default=[]) + + if table == "truth_nodes": + content = data.get("content") + parsed = _parse_json(content, default=None) + if parsed is None: + parsed = {"raw": str(content or "")} + data["content"] = parsed + + if table == "tasks": + data["required_skill_tag_ids"] = _parse_int_array(data.get("required_skill_tag_ids")) + data["ai_recommended_assignees"] = _parse_json(data.get("ai_recommended_assignees"), default=[]) + + if table == "qa_records": + data["sources"] = _parse_json(data.get("sources"), default=[]) + data["domains_used"] = _parse_json(data.get("domains_used"), default=[]) + + if table == "qa_feedback": + data["error_types"] = _parse_json(data.get("error_types"), default=[]) + + # 通用类型修正 + for col, typ in pg_types.items(): + if col not in data: + continue + if typ in {"json", "jsonb"}: + value = data[col] + if value is None: + continue + if isinstance(value, str): + data[col] = value + else: + data[col] = json.dumps(value, ensure_ascii=False) + continue + if typ == "boolean": + value = data[col] + if isinstance(value, bool): + continue + if value is None: + data[col] = False + else: + data[col] = str(value).strip().lower() in {"1", "true", "t", "yes", "y"} + elif typ == "timestamp without time zone": + data[col] = _parse_ts(data.get(col)) + + return data + + +async def _pg_table_columns(conn: asyncpg.Connection, table: str) -> list[str]: + rows = await conn.fetch( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema='public' AND table_name=$1 + ORDER BY ordinal_position + """, + table, + ) + return [str(r["column_name"]) for r in rows] + + +async def _pg_table_types(conn: asyncpg.Connection, table: str) -> dict[str, str]: + rows = await conn.fetch( + """ + SELECT column_name, data_type + FROM information_schema.columns + WHERE table_schema='public' AND table_name=$1 + """, + table, + ) + return {str(r["column_name"]): str(r["data_type"]) for r in rows} + + +async def _pg_existing_tables(conn: asyncpg.Connection) -> set[str]: + rows = await conn.fetch( + """ + SELECT tablename + FROM pg_tables + WHERE schemaname='public' + """ + ) + return {str(r["tablename"]) for r in rows} + + +def _sqlite_columns(sqlite_conn: sqlite3.Connection, table: str) -> list[str]: + rows = sqlite_conn.execute(f"PRAGMA table_info({table})").fetchall() + return [str(r[1]) for r in rows] + + +def _sqlite_rows(sqlite_conn: sqlite3.Connection, table: str) -> list[dict[str, Any]]: + sqlite_conn.row_factory = sqlite3.Row + rows = sqlite_conn.execute(f"SELECT * FROM {table} ORDER BY id ASC").fetchall() + return [dict(r) for r in rows] + + +async def migrate(sqlite_path: str, postgres_url: str, *, mode: str = "merge") -> None: + sqlite_conn = sqlite3.connect(sqlite_path) + sqlite_conn.row_factory = sqlite3.Row + + pg_url = postgres_url.replace("postgresql+asyncpg://", "postgresql://") + pg = await asyncpg.connect(pg_url) + + try: + pg_tables = await _pg_existing_tables(pg) + sqlite_tables = { + r[0] + for r in sqlite_conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() + } + + tables = [t for t in TABLE_ORDER if t in pg_tables and t in sqlite_tables] + if not tables: + raise RuntimeError("无可迁移表,请检查库路径与目标库") + + print(f"[INFO] migrate tables: {tables}") + print(f"[INFO] mode: {mode}") + + if mode == "replace": + # 清空目标核心表(含级联) + await pg.execute("TRUNCATE TABLE " + ", ".join(tables) + " RESTART IDENTITY CASCADE") + + for table in tables: + pg_cols = await _pg_table_columns(pg, table) + pg_types = await _pg_table_types(pg, table) + sqlite_cols = _sqlite_columns(sqlite_conn, table) + common_cols = [c for c in pg_cols if c in sqlite_cols] + if not common_cols: + print(f"[WARN] skip {table}: no common cols") + continue + + rows = _sqlite_rows(sqlite_conn, table) + inserted = 0 + for row in rows: + row = _transform(table, row, pg_types) + values = [row.get(c) for c in common_cols] + placeholders = ", ".join(f"${i+1}" for i in range(len(common_cols))) + cols_sql = ", ".join(common_cols) + if mode == "merge" and "id" in common_cols and "id" in pg_cols: + sql = ( + f"INSERT INTO {table} ({cols_sql}) VALUES ({placeholders}) " + "ON CONFLICT (id) DO NOTHING" + ) + else: + sql = f"INSERT INTO {table} ({cols_sql}) VALUES ({placeholders})" + try: + await pg.execute(sql, *values) + inserted += 1 + except Exception as exc: + print(f"[WARN] {table} row id={row.get('id')} failed: {exc}") + + if "id" in common_cols: + await pg.execute( + f"SELECT setval(pg_get_serial_sequence('{table}','id'), COALESCE((SELECT MAX(id) FROM {table}), 1), true)" + ) + + print(f"[OK] {table}: {inserted}/{len(rows)}") + + # 摘要 + summary_tables = [ + "users", + "projects", + "signals", + "truth_nodes", + "tasks", + "qa_sessions", + "qa_records", + "qa_feedback", + ] + print("[SUMMARY]") + for table in summary_tables: + if table not in tables: + continue + count = await pg.fetchval(f"SELECT count(*) FROM {table}") + print(f" - {table}: {int(count or 0)}") + finally: + await pg.close() + sqlite_conn.close() + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--sqlite", default="/app/backend/neural_sync.db") + parser.add_argument("--postgres", default=os.getenv("DATABASE_URL", "")) + parser.add_argument("--mode", choices=["merge", "replace"], default="merge") + args = parser.parse_args() + + if not args.postgres: + print("[ERROR] DATABASE_URL 为空,请使用 --postgres 指定") + return 2 + + if not os.path.exists(args.sqlite): + print(f"[ERROR] SQLite 文件不存在: {args.sqlite}") + return 2 + + try: + asyncio.run(migrate(args.sqlite, args.postgres, mode=args.mode)) + return 0 + except Exception as exc: + print(f"[ERROR] migration failed: {exc}") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/storage/signal_attachments/tenant-1/project-46/signal-250/b8e8647a75fc485389c1812e2e68834b.docx b/backend/storage/signal_attachments/tenant-1/project-46/signal-250/b8e8647a75fc485389c1812e2e68834b.docx new file mode 100644 index 00000000..b9e52812 Binary files /dev/null and b/backend/storage/signal_attachments/tenant-1/project-46/signal-250/b8e8647a75fc485389c1812e2e68834b.docx differ diff --git a/backend/tests/test_adaptive_rag_intent_corrections.py b/backend/tests/test_adaptive_rag_intent_corrections.py new file mode 100644 index 00000000..2401989d --- /dev/null +++ b/backend/tests/test_adaptive_rag_intent_corrections.py @@ -0,0 +1,36 @@ +import unittest + +from app.services.adaptive_rag_service import AdaptiveRAGService + + +class AdaptiveRAGIntentCorrectionTest(unittest.TestCase): + def test_build_intent_correction_record_is_structured_and_stable(self) -> None: + first = AdaptiveRAGService._build_intent_correction_record( + user_utterance="不对,是新增进 PMFlow 的项目列表里面", + wrong_interpretation="signal_intake", + correct_intent="create_project", + canonical_action="project.create", + clarification_template="请补充项目名称、项目描述、项目范围。", + risk_level="high", + project_id=None, + ) + second = AdaptiveRAGService._build_intent_correction_record( + user_utterance="不对,是新增进 PMFlow 的项目列表里面", + wrong_interpretation="signal_intake", + correct_intent="create_project", + canonical_action="project.create", + clarification_template="请补充项目名称、项目描述、项目范围。", + risk_level="high", + project_id=None, + ) + + self.assertEqual(first[0], second[0]) + self.assertEqual(first[1], "意图纠错/create_project") + self.assertIn("用户原话:", first[2]) + self.assertEqual(first[3]["record_type"], "intent_correction") + self.assertEqual(first[3]["correct_intent"], "create_project") + self.assertEqual(first[3]["canonical_action"], "project.create") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_ai_logs_api.py b/backend/tests/test_ai_logs_api.py new file mode 100644 index 00000000..1ae3d2d0 --- /dev/null +++ b/backend/tests/test_ai_logs_api.py @@ -0,0 +1,184 @@ +import os +import unittest +from tempfile import NamedTemporaryFile + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.api.ai_logs import _apply_project_filter +from app.models import AIProcessingLog, Base, IngestEvent, Project, User, WorkItem + + +class AILogsApiTest(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + tmp = NamedTemporaryFile(prefix="pmflow-ai-logs-test-", suffix=".db", delete=False) + tmp.close() + self.db_path = tmp.name + self.engine = create_async_engine(f"sqlite+aiosqlite:///{self.db_path}", future=True) + self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession) + async with self.engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async with self.session_factory() as db: + user = User( + tenant_id=1, + username="boss", + email="boss@example.com", + password_hash="hash", + name="老板", + role="BOSS", + assigned_domains=[], + workload=0, + status="active", + is_active=True, + ) + db.add(user) + await db.flush() + + project_a = Project( + tenant_id=1, + name="项目A", + title="项目A", + description="A", + owner_id=int(user.id), + status="active", + domain_id=None, + assigned_role="pm", + signal_count=0, + ) + project_b = Project( + tenant_id=1, + name="项目B", + title="项目B", + description="B", + owner_id=int(user.id), + status="active", + domain_id=None, + assigned_role="pm", + signal_count=0, + ) + db.add_all([project_a, project_b]) + await db.flush() + + ingest_a = IngestEvent( + tenant_id=1, + channel="feishu_message", + event_key="om-project-a", + status="done", + payload={}, + retry_count=0, + error_message=None, + processed_at=None, + ) + ingest_b = IngestEvent( + tenant_id=1, + channel="feishu_message", + event_key="om-project-b", + status="done", + payload={}, + retry_count=0, + error_message=None, + processed_at=None, + ) + db.add_all([ingest_a, ingest_b]) + await db.flush() + + db.add_all( + [ + WorkItem( + tenant_id=1, + ingest_event_id=int(ingest_a.id), + source_system="feishu", + source_message_id="om-project-a", + state="succeeded", + intent="project.create", + decision_level="project", + project_id=int(project_a.id), + ), + WorkItem( + tenant_id=1, + ingest_event_id=int(ingest_b.id), + source_system="feishu", + source_message_id="om-project-b", + state="succeeded", + intent="signal.create", + decision_level="project", + project_id=int(project_b.id), + ), + ] + ) + db.add_all( + [ + AIProcessingLog( + tenant_id=1, + feishu_message_id="om-project-a", + feishu_item_id=None, + raw_message="创建项目A", + ai_raw_output="{}", + project_score=None, + signal_confidence=None, + level="ai_pmo", + normalized_text="创建项目A", + route="ai_pmo.project.create", + intent="project.create", + executor="ai_pmo_kernel", + operation_code="project.create", + operation_result={"project_id": int(project_a.id)}, + reply_source="action_succeeded", + routing_decision="ai_pmo.project.create", + routing_reason="AI PMO kernel route=ai_pmo.project.create", + reasoning_bullets=["work_item_id=1"], + processing_status="success", + error_message=None, + duration_ms=100, + ), + AIProcessingLog( + tenant_id=1, + feishu_message_id="om-project-b", + feishu_item_id=None, + raw_message="创建信号B", + ai_raw_output="{}", + project_score=None, + signal_confidence=None, + level="ai_pmo", + normalized_text="创建信号B", + route="ai_pmo.signal.create", + intent="signal.create", + executor="ai_pmo_kernel", + operation_code="signal.enter", + operation_result={"project_id": int(project_b.id)}, + reply_source="action_succeeded", + routing_decision="ai_pmo.signal.create", + routing_reason="AI PMO kernel route=ai_pmo.signal.create", + reasoning_bullets=["work_item_id=2"], + processing_status="success", + error_message=None, + duration_ms=100, + ), + ] + ) + await db.commit() + + async def asyncTearDown(self) -> None: + await self.engine.dispose() + if getattr(self, "db_path", None): + try: + os.remove(self.db_path) + except FileNotFoundError: + pass + + async def test_apply_project_filter_includes_ai_pmo_work_item_message_ids(self) -> None: + async with self.session_factory() as db: + stmt = select(AIProcessingLog).where(AIProcessingLog.tenant_id == 1).order_by(AIProcessingLog.id.asc()) + rows = (await db.execute(_apply_project_filter(stmt, tenant_id=1, project_id=1))).scalars().all() + + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0].feishu_message_id, "om-project-a") + + async def test_apply_project_filter_keeps_other_project_logs_out(self) -> None: + async with self.session_factory() as db: + stmt = select(AIProcessingLog).where(AIProcessingLog.tenant_id == 1).order_by(AIProcessingLog.id.asc()) + rows = (await db.execute(_apply_project_filter(stmt, tenant_id=1, project_id=2))).scalars().all() + + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0].feishu_message_id, "om-project-b") diff --git a/backend/tests/test_ai_orchestration_run_service.py b/backend/tests/test_ai_orchestration_run_service.py new file mode 100644 index 00000000..11b41c7a --- /dev/null +++ b/backend/tests/test_ai_orchestration_run_service.py @@ -0,0 +1,312 @@ +import asyncio +import os +import time +import unittest +from datetime import datetime, timedelta, timezone +from tempfile import NamedTemporaryFile +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.models import Base +from app.services.ai_orchestration_run_service import AIOrchestrationRunService +from app.services.signal_source_orchestration_service import SignalSourceOrchestrationService + + +def _utc_now_naive() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class _FastModeSignalService(SignalSourceOrchestrationService): + session_factory: async_sessionmaker[AsyncSession] | None = None + + async def _load_project(self, **_: object): + return SimpleNamespace(id=3, title="项目A", name="项目A", description="desc", owner_id=1) + + async def _load_signal(self, **_: object): + return SimpleNamespace( + id=9, + title="新增 GitHub 信号源", + summary="监控 issue", + signal_type="新需求", + recommended_action="enter_only", + content="新增 GitHub 信号源并自动拆任务", + ) + + async def _plan_onboarding(self, **_: object): + return ( + { + "should_onboard": True, + "summary": "接入 GitHub 仓库信号源。", + "source": { + "source_kind": "github", + "source_name": "核心仓库", + "source_identifier": "github:openai/codex", + "description": "监控 issue 与 PR", + }, + "worker_roles": ["pm_dispatcher", "tech_architect", "delivery_coach"], + "openclaw_instruction": "创建接入任务。", + }, + "planned", + { + "used_fallback": False, + "error_code": None, + "source_field": "message.content", + "provider": "glm", + "model": "GLM-4.7", + }, + ) + + async def _create_or_reuse_registry(self, **_: object): + return { + "status": "created", + "registry_id": 11, + "source_kind": "github", + "source_name": "核心仓库", + "source_identifier": "github:openai/codex", + "description": "监控 issue 与 PR", + } + + async def _resolve_operator_user(self, **_: object): + return SimpleNamespace(id=1, role="BOSS", name="Boss", username="boss") + + async def _dispatch_openclaw(self, **_: object): + return { + "status": "completed", + "result": {"trace_id": "openclaw-123"}, + } + + @classmethod + async def _execute_workers_and_finalize(cls, **kwargs: object): + await asyncio.sleep(0.2) + assert cls.session_factory is not None + async with cls.session_factory() as db: + service = cls(db) + trace_id = str(kwargs["trace_id"]) + summary = str(kwargs["summary"]) + planner_status = str(kwargs["planner_status"]) + planner_diagnostics = kwargs["planner_diagnostics"] + registry_result = dict(kwargs["registry_result"]) + openclaw_result = dict(kwargs["openclaw_result"]) + worker_role_keys = list(kwargs["worker_role_keys"]) + worker_results = [ + { + "worker_role_key": role_key, + "worker_role_name": role_key, + "status": "completed", + "summary": f"{role_key} done", + } + for role_key in worker_role_keys + ] + final_result = service._build_orchestration_result( + trace_id=trace_id, + response_mode="fast", + planner_status=planner_status, + planner_diagnostics=planner_diagnostics, + summary=summary, + registry_result=registry_result, + source_payload=registry_result, + openclaw_result=openclaw_result, + worker_results=worker_results, + pending_workers=[], + status="completed", + run_status="completed", + ) + await AIOrchestrationRunService(db).update_run( + tenant_id=int(kwargs["tenant_id"]), + trace_id=trace_id, + status="completed", + planner_status=planner_status, + openclaw_status=str(openclaw_result.get("status") or ""), + worker_total=len(worker_role_keys), + worker_completed=len(worker_role_keys), + worker_failed=0, + summary=summary, + planner_diagnostics=planner_diagnostics if isinstance(planner_diagnostics, dict) else {}, + result_payload=final_result, + completed_at=_utc_now_naive(), + ) + await db.commit() + return final_result + + +class _ConcurrentWorkerSignalService(SignalSourceOrchestrationService): + @classmethod + async def _execute_single_worker_role_isolated(cls, **kwargs: object): + await asyncio.sleep(0.2) + role = dict(kwargs["role"]) + return { + "worker_role_key": str(role["role_key"]), + "worker_role_name": str(role["role_name"]), + "status": "completed", + "summary": f"{role['role_key']} done", + } + + +class AIOrchestrationRunServiceTest(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + tmp = NamedTemporaryFile(prefix="pmflow-orchestration-test-", suffix=".db", delete=False) + tmp.close() + self.db_path = tmp.name + self.engine = create_async_engine(f"sqlite+aiosqlite:///{self.db_path}", future=True) + self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession) + async with self.engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + _FastModeSignalService.session_factory = self.session_factory + _ConcurrentWorkerSignalService.session_factory = self.session_factory + + async def asyncTearDown(self) -> None: + await self.engine.dispose() + if getattr(self, "db_path", None): + try: + os.remove(self.db_path) + except FileNotFoundError: + pass + + async def test_fast_mode_returns_before_background_workers_finish_and_run_is_queryable(self) -> None: + async with self.session_factory() as db: + service = _FastModeSignalService(db) + started = time.perf_counter() + result = await service.orchestrate_from_signal( + tenant_id=1, + project_id=3, + signal_id=9, + initiating_text="新增一个 GitHub 信号源,监控核心仓库 issue。", + requested_via="pmflow", + operator_user_id=1, + dry_run=False, + response_mode="fast", + ) + elapsed = time.perf_counter() - started + + self.assertLess(elapsed, 0.15) + self.assertEqual(result["run_status"], "running") + self.assertEqual(len(result["pending_workers"]), 3) + + async with self.session_factory() as db: + row = await AIOrchestrationRunService(db).get_run(tenant_id=1, trace_id=result["trace_id"]) + self.assertIsNotNone(row) + self.assertEqual(row.status, "running") + + await asyncio.sleep(0.3) + + async with self.session_factory() as db: + row = await AIOrchestrationRunService(db).get_run(tenant_id=1, trace_id=result["trace_id"]) + snapshot = AIOrchestrationRunService.serialize_run(row) + + self.assertEqual(snapshot["run_status"], "completed") + self.assertEqual(snapshot["worker_completed"], 3) + self.assertEqual(snapshot["worker_pending"], 0) + + async def test_complete_mode_worker_execution_is_concurrent(self) -> None: + async with self.session_factory() as db: + service = _ConcurrentWorkerSignalService(db) + initial_result = service._build_orchestration_result( + trace_id="trace-concurrent", + response_mode="complete", + planner_status="planned", + planner_diagnostics={"used_fallback": False}, + summary="接入 GitHub 仓库信号源。", + registry_result={"registry_id": 11, "status": "created"}, + source_payload={ + "source_kind": "github", + "source_name": "核心仓库", + "source_identifier": "github:openai/codex", + }, + openclaw_result={"status": "completed", "result": {"trace_id": "openclaw-123"}}, + worker_results=[], + pending_workers=["pm_dispatcher", "tech_architect", "delivery_coach"], + status="partial", + run_status="running", + ) + await AIOrchestrationRunService(db).create_run( + tenant_id=1, + trace_id="trace-concurrent", + run_type="signal_source_onboarding", + project_id=3, + signal_id=9, + response_mode="complete", + status="running", + planner_status="planned", + openclaw_status="completed", + worker_total=3, + worker_completed=0, + worker_failed=0, + summary="接入 GitHub 仓库信号源。", + planner_diagnostics={"used_fallback": False}, + result_payload=initial_result, + created_by_user_id=1, + ) + await db.commit() + + fake_roles = { + "roles": [ + {"role_key": "pm_dispatcher", "role_name": "PM"}, + {"role_key": "tech_architect", "role_name": "Arch"}, + {"role_key": "delivery_coach", "role_name": "Coach"}, + ] + } + with patch("app.services.signal_source_orchestration_service.prompt_profile_service.get_profiles", AsyncMock(return_value=fake_roles)): + started = time.perf_counter() + final_result = await _ConcurrentWorkerSignalService._execute_workers_and_finalize( + tenant_id=1, + trace_id="trace-concurrent", + project_payload={"id": 3, "title": "项目A", "name": "项目A", "description": "desc", "owner_id": 1}, + signal_payload={"id": 9, "title": "新增 GitHub 信号源", "summary": "监控 issue", "signal_type": "新需求", "recommended_action": "enter_only", "content": "content"}, + registry_result={ + "registry_id": 11, + "status": "created", + "source_kind": "github", + "source_name": "核心仓库", + "source_identifier": "github:openai/codex", + "description": "监控 issue 与 PR", + }, + openclaw_result={"status": "completed", "result": {"trace_id": "openclaw-123"}}, + worker_role_keys=["pm_dispatcher", "tech_architect", "delivery_coach"], + operator_user_payload={"id": 1, "name": "Boss", "username": "boss", "role": "BOSS"}, + planner_status="planned", + planner_diagnostics={"used_fallback": False}, + summary="接入 GitHub 仓库信号源。", + signal_id=None, + ) + elapsed = time.perf_counter() - started + + self.assertLess(elapsed, 0.45) + self.assertEqual(final_result["status"], "completed") + self.assertEqual(len(final_result["worker_executions"]), 3) + + async def test_mark_stale_runs_as_interrupted(self) -> None: + async with self.session_factory() as db: + service = AIOrchestrationRunService(db) + await service.create_run( + tenant_id=1, + trace_id="trace-stale", + run_type="signal_source_onboarding", + project_id=3, + signal_id=9, + response_mode="fast", + status="running", + planner_status="planned", + openclaw_status="completed", + worker_total=3, + worker_completed=1, + worker_failed=0, + summary="stale run", + planner_diagnostics={"used_fallback": False}, + result_payload={"pending_workers": ["pm_dispatcher"], "run_status": "running"}, + created_by_user_id=1, + started_at=_utc_now_naive() - timedelta(minutes=11), + ) + await db.commit() + + updated = await service.mark_stale_runs_interrupted(older_than_minutes=10) + await db.commit() + row = await service.get_run(tenant_id=1, trace_id="trace-stale") + + self.assertEqual(updated, 1) + self.assertEqual(row.status, "interrupted") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_ai_pmo_full_scenario.py b/backend/tests/test_ai_pmo_full_scenario.py new file mode 100644 index 00000000..ae513df2 --- /dev/null +++ b/backend/tests/test_ai_pmo_full_scenario.py @@ -0,0 +1,640 @@ +import json +import os +import unittest +from datetime import datetime +from tempfile import NamedTemporaryFile +from unittest.mock import AsyncMock, patch + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.api.users import _create_user_with_defaults +from app.models import ( + Base, + Budget, + Project, + ProjectDomain, + ProjectSignalLink, + Signal, + SkillTag, + Task, + TruthNode, + User, + UserProject, + UserSkillBinding, + UserWorkload, + WorkItem, +) +from app.schemas import UserCreate +from app.services.ai_pmo_cadence_service import AIPMOCadenceService +from app.services.ai_pmo_kernel_service import AIPMOKernelService +from app.services.signal_capture import ProactiveSignalCaptureService +from app.services.skill_service import recommend_personnel +from app.services import workload_service + + +class AIPMOFullScenarioTest(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + tmp = NamedTemporaryFile(prefix="pmflow-ai-pmo-scenario-", suffix=".db", delete=False) + tmp.close() + self.db_path = tmp.name + self.engine = create_async_engine(f"sqlite+aiosqlite:///{self.db_path}", future=True) + self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession) + async with self.engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async with self.session_factory() as db: + self.policy_skill_id = await self._create_skill_tag(db, name="政策分析", slug="policy-analysis") + self.research_skill_id = await self._create_skill_tag(db, name="行业研究", slug="market-research") + self.web_skill_id = await self._create_skill_tag(db, name="爬虫采集", slug="web-crawling") + self.graph_skill_id = await self._create_skill_tag(db, name="知识图谱", slug="knowledge-graph") + self.pipeline_skill_id = await self._create_skill_tag(db, name="数据管道", slug="data-pipeline") + + boss = User( + tenant_id=1, + username="boss", + email="boss@example.com", + password_hash="hash", + name="老板", + role="BOSS", + assigned_domains=[], + workload=0, + status="active", + is_active=True, + ) + db.add(boss) + await db.flush() + self.boss_id = int(boss.id) + + domain = ProjectDomain( + tenant_id=1, + name="医药", + slug="pharma", + description="医药产业情报域", + config={}, + is_active=True, + is_default=False, + signal_count=0, + context_node_count=0, + experiment_count=0, + owner_id=str(boss.id), + ) + db.add(domain) + await db.flush() + self.domain_id = int(domain.id) + + project = Project( + tenant_id=1, + name="医药行业十五五规划布局研究", + title="医药行业十五五规划布局研究", + description="跟踪医保支付改革、器械招采与创新药方向。", + owner_id=int(boss.id), + status="active", + domain_id=int(domain.id), + assigned_role="pm", + signal_count=0, + ) + db.add(project) + await db.flush() + self.base_project_id = int(project.id) + + charter = TruthNode( + tenant_id=1, + node_type="charter", + project_id=int(project.id), + signal_id=None, + created_by=int(boss.id), + title="医药研究项目宪章", + content={"success_criteria": ["两周内形成华东医保支付改革观察报告"]}, + version=1, + status="active", + ) + db.add(charter) + await db.flush() + self.base_charter_id = int(charter.id) + + db.add( + Budget( + tenant_id=1, + budget_type="project", + project_id=int(project.id), + period="monthly", + budget_amount_usd=1500.0, + alert_threshold_percent=80.0, + hard_limit_enabled=True, + current_usage_usd=620.0, + current_period_start=datetime(2026, 3, 1), + current_period_end=datetime(2026, 3, 31), + status="active", + override_history={}, + ) + ) + db.add(UserProject(tenant_id=1, user_id=int(boss.id), project_id=int(project.id))) + db.add( + UserWorkload( + tenant_id=1, + user_id=int(boss.id), + current_assigned_hours=18.0, + max_available_hours_per_week=40.0, + workload_ratio=0.0, + ) + ) + await self._bind_skill(db, user_id=int(boss.id), skill_tag_id=self.policy_skill_id, level="advanced", proficiency=86) + await self._bind_skill(db, user_id=int(boss.id), skill_tag_id=self.research_skill_id, level="expert", proficiency=92) + db.add( + Task( + tenant_id=1, + truth_node_id=int(charter.id), + project_id=int(project.id), + assignee_id=int(boss.id), + ai_recommended_assignees=None, + title="拆解华东医保支付改革样本", + description="需要比对江苏/浙江 DRG-DIP 改革口径和器械招采变化。", + required_skill_tag_ids=[self.policy_skill_id, self.web_skill_id], + status="blocked", + priority="high", + estimated_hours=12.0, + actual_hours=0.0, + quality_score=None, + due_date=datetime(2026, 3, 12), + completed_at=None, + ) + ) + await workload_service.recalculate_user_workload_ratio(db, tenant_id=1, user_id=int(boss.id), commit=False) + await db.commit() + + async def asyncTearDown(self) -> None: + await self.engine.dispose() + if getattr(self, "db_path", None): + try: + os.remove(self.db_path) + except FileNotFoundError: + pass + + async def _create_skill_tag(self, db: AsyncSession, *, name: str, slug: str) -> int: + row = SkillTag( + tenant_id=1, + name=name, + slug=slug, + category="skill", + description=f"{name}能力", + is_system=False, + sort_order=0, + ) + db.add(row) + await db.flush() + return int(row.id) + + async def _bind_skill( + self, + db: AsyncSession, + *, + user_id: int, + skill_tag_id: int, + level: str, + proficiency: int, + ) -> None: + db.add( + UserSkillBinding( + tenant_id=1, + user_id=int(user_id), + skill_tag_id=int(skill_tag_id), + proficiency_level=level, + proficiency=int(proficiency), + level=level, + years_experience=5.0, + quality_score=8.5, + efficiency_score=8.0, + ) + ) + await db.flush() + + async def _set_workload_hours(self, db: AsyncSession, *, user_id: int, assigned_hours: float) -> None: + workload = ( + await db.execute( + select(UserWorkload).where( + and_( + UserWorkload.tenant_id == 1, + UserWorkload.user_id == int(user_id), + ) + ) + ) + ).scalar_one() + workload.current_assigned_hours = float(assigned_hours) + await workload_service.recalculate_user_workload_ratio(db, tenant_id=1, user_id=int(user_id), commit=False) + + async def test_full_difficult_scenario_covers_feishu_context_project_signal_workflow_and_workload(self) -> None: + async with self.session_factory() as db: + strategy_user = await _create_user_with_defaults( + db, + tenant_id=1, + user_data=UserCreate( + username="hanqing", + password="password-1", + name="韩青", + email="hanqing@example.com", + role="ADMIN", + assigned_domains=["pharma"], + project_ids=[self.base_project_id], + ), + actor_id=self.boss_id, + commit=False, + ) + crawler_user = await _create_user_with_defaults( + db, + tenant_id=1, + user_data=UserCreate( + username="zhouzhan", + password="password-2", + name="周湛", + email="zhouzhan@example.com", + role="BE", + assigned_domains=["pharma"], + project_ids=[], + ), + actor_id=self.boss_id, + commit=False, + ) + graph_user = await _create_user_with_defaults( + db, + tenant_id=1, + user_data=UserCreate( + username="guyan", + password="password-3", + name="顾言", + email="guyan@example.com", + role="AI", + assigned_domains=["pharma"], + project_ids=[], + ), + actor_id=self.boss_id, + commit=False, + ) + await self._bind_skill(db, user_id=int(strategy_user.id), skill_tag_id=self.policy_skill_id, level="expert", proficiency=93) + await self._bind_skill(db, user_id=int(strategy_user.id), skill_tag_id=self.research_skill_id, level="expert", proficiency=90) + await self._bind_skill(db, user_id=int(crawler_user.id), skill_tag_id=self.web_skill_id, level="expert", proficiency=95) + await self._bind_skill(db, user_id=int(crawler_user.id), skill_tag_id=self.policy_skill_id, level="advanced", proficiency=84) + await self._bind_skill(db, user_id=int(crawler_user.id), skill_tag_id=self.pipeline_skill_id, level="advanced", proficiency=88) + await self._bind_skill(db, user_id=int(graph_user.id), skill_tag_id=self.graph_skill_id, level="expert", proficiency=91) + await self._bind_skill(db, user_id=int(graph_user.id), skill_tag_id=self.pipeline_skill_id, level="advanced", proficiency=82) + await self._set_workload_hours(db, user_id=int(strategy_user.id), assigned_hours=28.0) + await self._set_workload_hours(db, user_id=int(crawler_user.id), assigned_hours=8.0) + await self._set_workload_hours(db, user_id=int(graph_user.id), assigned_hours=10.0) + await db.commit() + + kernel = AIPMOKernelService(db, tenant_id=1) + capture_service = ProactiveSignalCaptureService(db) + + hard_question = ( + "项目ID: 1 如果把样本窗口收紧到 2025Q4-2026Q1,只看江苏/浙江的 DRG-DIP 支付、" + "三甲院器械招采与设备更新预算,当前状态下最可能拖慢结论闭环的到底是预算、" + "blocked task 还是技能缺口?为什么?别给套话,按证据优先级直接判断。" + ) + with ( + patch( + "app.services.ai_pmo_kernel_service.AdaptiveRAGService.answer_question", + AsyncMock( + return_value={ + "answer": "优先级 1 是 blocked task,其次是技能缺口,预算暂未触顶。", + "sources": [], + } + ), + ), + patch( + "app.services.signal_capture.context_cache_service.get_session_messages", + AsyncMock(return_value=[]), + ), + patch( + "app.services.signal_capture.context_cache_service.append_session_messages", + AsyncMock(), + ) as cache_append_mock, + patch( + "app.services.signal_capture.AdaptiveRAGService.ingest_feishu_message_async", + AsyncMock(), + ), + ): + qa_result = await capture_service.capture_from_feishu( + group_chat_id="oc_complex_case", + message_content=hard_question, + sender="老板", + tenant_id=1, + proposer_open_id="ou_boss", + captured_at=datetime(2026, 3, 9, 9, 0, 0), + feishu_message_id="om-qa-hard-1", + chat_type="group", + ) + + async def create_project_side_effect(*, input_text: str, creator_id: int, tenant_id: int) -> dict: + project = Project( + tenant_id=int(tenant_id), + name="华东医保控费与器械国产替代观察台", + title="华东医保控费与器械国产替代观察台", + description="聚焦华东 DRG-DIP、器械带量采购和国产替代节奏。", + owner_id=int(creator_id), + status="active", + domain_id=self.domain_id, + assigned_role="ai_engineer", + signal_count=0, + ) + db.add(project) + await db.flush() + + charter = TruthNode( + tenant_id=1, + node_type="charter", + project_id=int(project.id), + signal_id=None, + created_by=int(creator_id), + title="华东医保观察项目宪章", + content={"success_criteria": ["10 天内交付策略 memo、政策雷达和竞品跟踪框架"]}, + version=1, + status="active", + ) + db.add(charter) + await db.flush() + + bootstrap_signal = Signal( + tenant_id=1, + source="PMFlow", + title="医保控费与器械国产替代立项信号", + content=input_text, + summary="立项信号", + signal_type="新需求", + priority="high", + impact_score=0.85, + matched_project_id=int(project.id), + reasoning=json.dumps(["来自高强度情景立项指令"], ensure_ascii=False), + confidence=0.92, + recommended_action="enter_only", + processed=True, + tasks_generated=json.dumps([], ensure_ascii=False), + status="已处理", + ) + db.add(bootstrap_signal) + await db.flush() + db.add( + ProjectSignalLink( + tenant_id=1, + project_id=int(project.id), + signal_id=int(bootstrap_signal.id), + link_type="related", + relevance_score=1.0, + ) + ) + db.add( + Budget( + tenant_id=1, + budget_type="project", + project_id=int(project.id), + period="monthly", + budget_amount_usd=3200.0, + alert_threshold_percent=80.0, + hard_limit_enabled=True, + current_usage_usd=240.0, + current_period_start=datetime(2026, 3, 1), + current_period_end=datetime(2026, 3, 31), + status="active", + override_history={}, + ) + ) + db.add(UserProject(tenant_id=1, user_id=int(creator_id), project_id=int(project.id))) + task_one = Task( + tenant_id=1, + truth_node_id=int(charter.id), + project_id=int(project.id), + assignee_id=None, + ai_recommended_assignees=None, + title="搭建政策抓取与公告解析管道", + description="每天跟踪江苏/浙江/安徽 医保支付改革、招采公告与价格体系拐点。", + required_skill_tag_ids=[self.policy_skill_id, self.web_skill_id], + status="todo", + priority="high", + estimated_hours=18.0, + actual_hours=0.0, + quality_score=None, + due_date=datetime(2026, 3, 19), + completed_at=None, + ) + task_two = Task( + tenant_id=1, + truth_node_id=int(charter.id), + project_id=int(project.id), + assignee_id=None, + ai_recommended_assignees=None, + title="沉淀器械中标结构知识图谱", + description="把区域政策、招采公告和竞品动作串成结构化图谱。", + required_skill_tag_ids=[self.graph_skill_id], + status="todo", + priority="medium", + estimated_hours=12.0, + actual_hours=0.0, + quality_score=None, + due_date=datetime(2026, 3, 20), + completed_at=None, + ) + db.add_all([task_one, task_two]) + await db.flush() + return { + "project": {"id": int(project.id), "title": project.title, "name": project.name}, + "signal": {"id": int(bootstrap_signal.id), "title": bootstrap_signal.title}, + "tasks": [ + {"id": int(task_one.id), "title": task_one.title}, + {"id": int(task_two.id), "title": task_two.title}, + ], + } + + with patch( + "app.services.ai_pmo_kernel_service.AIStewardService.create_project_from_natural_language", + AsyncMock(side_effect=create_project_side_effect), + ): + project_result = await kernel.process_ingress( + source_system="feishu", + source_message_id="om-project-hard-1", + message_text=( + "不要复用现在这个宏观研究盘子,单独立一个项目,名字就叫" + "“华东医保控费与器械国产替代观察台”,目标不是写周报," + "而是 10 天内拿出策略 memo、政策雷达和竞品跟踪框架。" + ), + sender="老板", + channel_ref="oc_complex_case", + source_sender_open_id="ou_boss", + ) + + new_project_id = int(project_result["created_project_id"]) + + signal_result = await kernel.process_ingress( + source_system="feishu", + source_message_id="om-signal-hard-1", + message_text=( + f"给项目ID: {new_project_id} 新增一条信号:持续盯江苏、浙江、安徽 2026 年器械带量采购、" + "DRG-DIP 支付改革和国产替代中标结构,只要价格体系或准入口径出现拐点就预警," + "不要只抓新闻标题。" + ), + sender="老板", + channel_ref="oc_complex_case", + source_sender_open_id="ou_boss", + project_hint_id=new_project_id, + ) + + source_result = await kernel.process_ingress( + source_system="feishu", + source_message_id="om-source-hard-1", + message_text=( + f"给项目ID: {new_project_id} 接一个复合信号源,不是简单 RSS:每天自动抓医保支付改革、" + "器械招采公告、上市公司电话会和 GitHub 开源竞品动态,优先走自动接入。" + ), + sender="老板", + channel_ref="oc_complex_case", + source_sender_open_id="ou_boss", + project_hint_id=new_project_id, + ) + + db.add_all( + [ + UserProject(tenant_id=1, user_id=int(strategy_user.id), project_id=new_project_id), + UserProject(tenant_id=1, user_id=int(crawler_user.id), project_id=new_project_id), + UserProject(tenant_id=1, user_id=int(graph_user.id), project_id=new_project_id), + ] + ) + await db.flush() + + with patch( + "app.services.ai_pmo_kernel_service.SignalSourceDomainWorkflow.run", + AsyncMock( + return_value={ + "status": "completed", + "registry_id": 9, + "registry_status": "active", + "onboarding_status": "completed", + "openclaw_status": "completed", + "typed_actions": [{"operation_code": "task.create", "status": "applied"}], + "material_action_count": 1, + "worker_executions": [{"used_fallback": False}], + "pending_workers": [], + "next_step": "继续自动执行后续任务", + "fallback_used": False, + "raw_result": {}, + } + ), + ) as onboarding_run_mock: + resume_result = await kernel.process_ingress( + source_system="ai_pmo_ui", + source_message_id="resume-work-item-hard-1", + message_text=f"继续处理 work_item #{int(source_result['work_item_id'])}", + sender="老板", + channel_ref="ai_pmo_ui", + project_hint_id=new_project_id, + ) + + target_work_item = ( + await db.execute( + select(WorkItem).where( + and_( + WorkItem.tenant_id == 1, + WorkItem.id == int(source_result["work_item_id"]), + ) + ) + ) + ).scalar_one() + + new_project_tasks = ( + await db.execute( + select(Task) + .where(and_(Task.tenant_id == 1, Task.project_id == new_project_id)) + .order_by(Task.id.asc()) + ) + ).scalars().all() + task_policy_stream = next(task for task in new_project_tasks if "抓取" in task.title) + task_graph = next(task for task in new_project_tasks if "知识图谱" in task.title) + + recommendation_result = await recommend_personnel( + db=db, + tenant_id=1, + required_skills=["policy-analysis", "web-crawling"], + task_description=task_policy_stream.description, + task_id=str(task_policy_stream.id), + limit=5, + ) + + with patch( + "app.services.workload_service.llm_service.generate", + AsyncMock( + return_value=json.dumps( + { + "proposals": [ + { + "task_id": int(task_policy_stream.id), + "new_assignee_id": int(crawler_user.id), + "reason": "crawler engineer has exact skill mix and lower load", + }, + { + "task_id": int(task_graph.id), + "new_assignee_id": int(graph_user.id), + "reason": "graph engineer owns ontology and extraction pipeline", + }, + ] + }, + ensure_ascii=False, + ) + ), + ): + optimize_preview = await workload_service.build_ai_optimize_preview( + db, + tenant_id=1, + project_id=new_project_id, + max_moves=5, + unassigned_only=True, + ) + + optimize_result = await workload_service.apply_ai_optimize_plan( + db, + tenant_id=1, + proposals=optimize_preview, + ) + board_rows = await workload_service.get_team_workload_board(db, tenant_id=1, project_id=new_project_id) + cadence_service = AIPMOCadenceService(db, tenant_id=1) + program_view = await cadence_service.build_program_view() + + self.assertEqual(qa_result["status"], "answered") + self.assertIn("blocked task", qa_result["direct_reply_text"]) + cache_append_mock.assert_awaited_once() + self.assertEqual(cache_append_mock.await_args.kwargs["question"], hard_question) + + self.assertEqual(project_result["status"], "captured") + self.assertIsNotNone(project_result["created_project_id"]) + self.assertIn("已立项", project_result["direct_reply_text"]) + + self.assertEqual(signal_result["status"], "captured") + self.assertEqual(signal_result["linked_project_ids"], [new_project_id]) + self.assertIsNotNone(signal_result["signal_id"]) + + self.assertEqual(source_result["status"], "captured") + self.assertIn("技能缺口", source_result["direct_reply_text"]) + self.assertFalse(bool(source_result["review_summary"]["verified"])) + + self.assertEqual(onboarding_run_mock.await_count, 1) + self.assertEqual(resume_result["status"], "answered") + self.assertIn("已继续处理", resume_result["direct_reply_text"]) + self.assertEqual(target_work_item.state, "succeeded") + self.assertEqual(target_work_item.execution_result["signal_source_workflow"]["status"], "completed") + + self.assertGreaterEqual(recommendation_result["total_count"], 1) + self.assertEqual(recommendation_result["recommendations"][0]["user_name"], "周湛") + + self.assertGreaterEqual(len(optimize_preview), 2) + self.assertEqual(optimize_result["applied_count"], 2) + + task_assignee_map = {int(task.id): int(task.assignee_id or 0) for task in new_project_tasks} + self.assertEqual(task_assignee_map[int(task_policy_stream.id)], int(crawler_user.id)) + self.assertEqual(task_assignee_map[int(task_graph.id)], int(graph_user.id)) + + board_by_name = {str(row["user_name"]): row for row in board_rows} + self.assertIn("周湛", board_by_name) + self.assertIn("顾言", board_by_name) + self.assertGreater(float(board_by_name["周湛"]["current_assigned_hours"]), 8.0) + self.assertGreaterEqual(float(board_by_name["顾言"]["current_assigned_hours"]), 9.5) + + self.assertGreaterEqual(program_view["summary"]["program_count"], 1) + self.assertTrue( + any(new_project_id in program["project_ids"] for program in program_view["programs"]) + ) diff --git a/backend/tests/test_ai_pmo_kernel_service.py b/backend/tests/test_ai_pmo_kernel_service.py new file mode 100644 index 00000000..60363902 --- /dev/null +++ b/backend/tests/test_ai_pmo_kernel_service.py @@ -0,0 +1,590 @@ +import os +import unittest +from datetime import datetime +from tempfile import NamedTemporaryFile +from unittest.mock import AsyncMock, patch + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.models import Base, Budget, PortfolioSnapshot, Project, ProjectDomain, Task, TruthNode, User, UserProject, UserSkillBinding, UserWorkload, WorkItem +from app.services.ai_pmo_cadence_service import AIPMOCadenceService +from app.services.ai_pmo_kernel_service import ( + AIPMOKernelService, + INTENT_SIGNAL_SOURCE_ONBOARD, + IntentResolver, +) +from app.services.ai_pmo_domain_workflow import SignalSourceDomainWorkflow +from app.services.signal_source_orchestration_service import SignalSourceOrchestrationService + + +class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + tmp = NamedTemporaryFile(prefix="pmflow-ai-pmo-test-", suffix=".db", delete=False) + tmp.close() + self.db_path = tmp.name + self.engine = create_async_engine(f"sqlite+aiosqlite:///{self.db_path}", future=True) + self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession) + async with self.engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async with self.session_factory() as db: + user = User( + tenant_id=1, + username="boss", + email="boss@example.com", + password_hash="hash", + name="老板", + role="BOSS", + assigned_domains=[], + workload=0, + status="active", + is_active=True, + ) + db.add(user) + await db.flush() + + domain = ProjectDomain( + tenant_id=1, + name="医药", + slug="pharma", + description="医药研究域", + config={}, + is_active=True, + is_default=False, + signal_count=0, + context_node_count=0, + experiment_count=0, + owner_id=str(user.id), + ) + db.add(domain) + await db.flush() + + project = Project( + tenant_id=1, + name="医药行业十五五规划布局研究", + title="医药行业十五五规划布局研究", + description="研究医药行业方向和机会。", + owner_id=int(user.id), + status="active", + domain_id=int(domain.id), + assigned_role="pm", + signal_count=0, + ) + db.add(project) + await db.flush() + + charter = TruthNode( + tenant_id=1, + node_type="charter", + project_id=int(project.id), + signal_id=None, + created_by=int(user.id), + title="项目宪章", + content={"success_criteria": ["两周内形成研究报告"]}, + version=1, + status="active", + ) + db.add(charter) + await db.flush() + db.add( + Budget( + tenant_id=1, + budget_type="project", + project_id=int(project.id), + period="monthly", + budget_amount_usd=1000.0, + alert_threshold_percent=80.0, + hard_limit_enabled=True, + current_usage_usd=500.0, + current_period_start=datetime(2026, 3, 1), + current_period_end=datetime(2026, 3, 31), + status="active", + override_history={}, + ) + ) + db.add(UserProject(tenant_id=1, user_id=int(user.id), project_id=int(project.id))) + db.add( + UserWorkload( + tenant_id=1, + user_id=int(user.id), + current_assigned_hours=20.0, + max_available_hours_per_week=40.0, + workload_ratio=0.5, + ) + ) + db.add( + UserSkillBinding( + tenant_id=1, + user_id=int(user.id), + skill_tag_id=1, + proficiency_level="expert", + proficiency=5, + level="expert", + years_experience=8.0, + quality_score=9.0, + efficiency_score=8.5, + ) + ) + db.add( + Task( + tenant_id=1, + truth_node_id=int(charter.id), + project_id=int(project.id), + assignee_id=int(user.id), + ai_recommended_assignees=None, + title="梳理外部依赖", + description="当前依赖较多,任务处于 blocked。", + required_skill_tag_ids=[2], + status="blocked", + priority="high", + estimated_hours=8.0, + actual_hours=0.0, + quality_score=None, + due_date=datetime(2026, 3, 1), + completed_at=None, + ) + ) + await db.commit() + + async def asyncTearDown(self) -> None: + await self.engine.dispose() + if getattr(self, "db_path", None): + try: + os.remove(self.db_path) + except FileNotFoundError: + pass + + def test_intent_resolver_detects_signal_source_request(self) -> None: + intent = IntentResolver.resolve( + text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex 仓库。", + project_id=1, + ) + self.assertEqual(intent.name, INTENT_SIGNAL_SOURCE_ONBOARD) + + async def test_build_world_model_includes_budget_capacity_and_skill_graph(self) -> None: + async with self.session_factory() as db: + service = AIPMOKernelService(db, tenant_id=1) + world_model = await service._build_world_model( + message_text="给项目ID: 1 新增一个医药信号。", + sender="老板", + project_hint_id=1, + ) + + self.assertEqual(world_model["budget"]["budget_id"], 1) + self.assertEqual(len(world_model["capacity"]), 1) + self.assertEqual(len(world_model["skill_graph"]), 1) + self.assertEqual(world_model["resolved_project_id"], 1) + self.assertEqual(world_model["project_domain"]["slug"], "pharma") + self.assertEqual(world_model["dependencies"]["pressure"], "high") + self.assertEqual(world_model["task_summary"]["uncovered_skill_tag_ids"], [2]) + + async def test_process_query_message_persists_work_item_and_reply_receipt(self) -> None: + async with self.session_factory() as db: + service = AIPMOKernelService(db, tenant_id=1) + with patch( + "app.services.ai_pmo_kernel_service.AdaptiveRAGService.answer_question", + AsyncMock(return_value={"answer": "当前项目状态正常。", "sources": []}), + ): + result = await service.process_ingress( + source_system="pmflow_manual", + source_message_id="msg-query-1", + message_text="项目ID: 1 现在状态如何?", + sender="老板", + channel_ref="pmflow", + project_hint_id=1, + ) + + work_item = ( + await db.execute( + select(WorkItem).where( + and_( + WorkItem.tenant_id == 1, + WorkItem.source_message_id == "msg-query-1", + ) + ) + ) + ).scalar_one() + review_cycle = ( + await db.execute( + select(PortfolioSnapshot).where( + and_( + PortfolioSnapshot.tenant_id == 1, + PortfolioSnapshot.scope_type == "review_cycle", + PortfolioSnapshot.scope_ref == f"work_item:{int(work_item.id)}", + ) + ) + ) + ).scalar_one() + + self.assertTrue(result["handled"]) + self.assertEqual(result["status"], "answered") + self.assertTrue(result["direct_reply_required"]) + self.assertEqual(result["direct_reply_text"], "当前项目状态正常。") + self.assertEqual(result["decision_level"], "project") + self.assertIn("agent_quality_score", work_item.review_summary) + self.assertTrue(bool(work_item.review_summary.get("verified"))) + self.assertEqual(review_cycle.summary["work_item_id"], int(work_item.id)) + + async def test_duplicate_message_is_filtered(self) -> None: + async with self.session_factory() as db: + service = AIPMOKernelService(db, tenant_id=1) + with patch( + "app.services.ai_pmo_kernel_service.AdaptiveRAGService.answer_question", + AsyncMock(return_value={"answer": "OK", "sources": []}), + ): + first = await service.process_ingress( + source_system="pmflow_manual", + source_message_id="dup-1", + message_text="项目ID: 1 现在状态如何?", + sender="老板", + channel_ref="pmflow", + project_hint_id=1, + ) + second = await service.process_ingress( + source_system="pmflow_manual", + source_message_id="dup-1", + message_text="项目ID: 1 现在状态如何?", + sender="老板", + channel_ref="pmflow", + project_hint_id=1, + ) + + self.assertEqual(first["status"], "answered") + self.assertEqual(second["status"], "ignored") + self.assertFalse(second["direct_reply_required"]) + + async def test_policy_gate_blocks_archive_like_request(self) -> None: + async with self.session_factory() as db: + service = AIPMOKernelService(db, tenant_id=1) + result = await service.process_ingress( + source_system="pmflow_manual", + source_message_id="gate-1", + message_text="请把项目ID: 1 直接归档。", + sender="老板", + channel_ref="pmflow", + project_hint_id=1, + ) + + self.assertTrue(result["handled"]) + self.assertEqual(result["status"], "captured") + self.assertIn("人工确认", result["direct_reply_text"]) + + async def test_process_project_create_uses_new_kernel(self) -> None: + async with self.session_factory() as db: + service = AIPMOKernelService(db, tenant_id=1) + with patch( + "app.services.ai_pmo_kernel_service.AIStewardService.create_project_from_natural_language", + AsyncMock( + return_value={ + "project": {"id": 9, "title": "医药新项目", "name": "医药新项目"}, + "signal": {"id": 11}, + "tasks": [{"id": 21, "title": "任务A"}], + } + ), + ): + result = await service.process_ingress( + source_system="pmflow_manual", + source_message_id="project-1", + message_text="请创建项目:医药新项目,做医药趋势研究。", + sender="老板", + channel_ref="pmflow", + ) + work_item = ( + await db.execute( + select(WorkItem).where( + and_( + WorkItem.tenant_id == 1, + WorkItem.source_message_id == "project-1", + ) + ) + ) + ).scalar_one() + + self.assertEqual(result["status"], "captured") + self.assertEqual(result["created_project_id"], 9) + self.assertEqual(result["linked_project_ids"], [9]) + self.assertTrue(result["direct_reply_required"]) + self.assertEqual(work_item.project_id, 9) + + async def test_signal_source_request_hits_simulation_policy_gate_when_skill_gap_exists(self) -> None: + async with self.session_factory() as db: + service = AIPMOKernelService(db, tenant_id=1) + result = await service.process_ingress( + source_system="pmflow_manual", + source_message_id="source-1", + message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。", + sender="老板", + channel_ref="pmflow", + project_hint_id=1, + ) + + self.assertTrue(result["handled"]) + self.assertEqual(result["status"], "captured") + self.assertIn("技能缺口", result["direct_reply_text"]) + + async def test_signal_create_stays_auto_executable_under_dependency_pressure(self) -> None: + async with self.session_factory() as db: + service = AIPMOKernelService(db, tenant_id=1) + result = await service.process_ingress( + source_system="pmflow_manual", + source_message_id="signal-auto-1", + message_text="给项目ID: 1 新增一条信号:跟踪江苏医保支付改革和器械招采拐点。", + sender="老板", + channel_ref="pmflow", + project_hint_id=1, + ) + + self.assertTrue(result["handled"]) + self.assertEqual(result["status"], "captured") + self.assertIsNotNone(result["signal_id"]) + self.assertEqual(result["linked_project_ids"], [1]) + self.assertIn("已入库", result["direct_reply_text"]) + + async def test_final_phase_generates_risk_cadence_and_program_view(self) -> None: + async with self.session_factory() as db: + service = AIPMOKernelService(db, tenant_id=1) + result = await service.process_ingress( + source_system="pmflow_manual", + source_message_id="source-risk-1", + message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。", + sender="老板", + channel_ref="pmflow", + project_hint_id=1, + ) + cadence_service = AIPMOCadenceService(db, tenant_id=1) + latest_risk = await cadence_service.get_latest_snapshot( + cadence_type="risk_review", + project_id=1, + ) + program_view = await cadence_service.build_program_view() + + self.assertTrue(result["handled"]) + self.assertEqual(result["status"], "captured") + self.assertIsNotNone(latest_risk) + self.assertEqual(latest_risk["scope_type"], "risk_review") + self.assertGreaterEqual(int(latest_risk["summary"]["blocked_task_count"]), 1) + self.assertEqual(program_view["summary"]["program_count"], 1) + self.assertGreaterEqual(int(program_view["summary"]["pending_manual_total"]), 1) + + async def test_followup_continue_resumes_single_waiting_manual_work_item(self) -> None: + async with self.session_factory() as db: + service = AIPMOKernelService(db, tenant_id=1) + first = await service.process_ingress( + source_system="feishu", + source_message_id="feishu-source-1", + message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。", + sender="老板", + channel_ref="oc_phase3", + source_sender_open_id="ou_boss", + project_hint_id=1, + ) + with patch( + "app.services.ai_pmo_kernel_service.SignalSourceDomainWorkflow.run", + AsyncMock( + return_value={ + "status": "completed", + "registry_id": 7, + "typed_actions": [{"operation_code": "task.create", "status": "applied"}], + "material_action_count": 1, + "fallback_used": False, + "raw_result": {}, + } + ), + ): + followup = await service.process_ingress( + source_system="feishu", + source_message_id="feishu-followup-1", + message_text="继续处理", + sender="老板", + channel_ref="oc_phase3", + source_sender_open_id="ou_boss", + project_hint_id=1, + ) + original = ( + await db.execute( + select(WorkItem).where( + and_( + WorkItem.tenant_id == 1, + WorkItem.source_message_id == "feishu-source-1", + ) + ) + ) + ).scalar_one() + + self.assertEqual(first["status"], "captured") + self.assertEqual(followup["status"], "answered") + self.assertIn("已继续处理", followup["direct_reply_text"]) + self.assertEqual(original.state, "succeeded") + + async def test_followup_continue_requires_explicit_target_when_multiple_waiting_manual_items(self) -> None: + async with self.session_factory() as db: + service = AIPMOKernelService(db, tenant_id=1) + await service.process_ingress( + source_system="feishu", + source_message_id="feishu-source-2a", + message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。", + sender="老板", + channel_ref="oc_phase3_multi", + source_sender_open_id="ou_boss", + project_hint_id=1, + ) + await service.process_ingress( + source_system="feishu", + source_message_id="feishu-source-2b", + message_text="给项目ID: 1 新增一个 Web 搜索信号源,监控医保动态。", + sender="老板", + channel_ref="oc_phase3_multi", + source_sender_open_id="ou_boss", + project_hint_id=1, + ) + followup = await service.process_ingress( + source_system="feishu", + source_message_id="feishu-followup-2", + message_text="继续处理", + sender="老板", + channel_ref="oc_phase3_multi", + source_sender_open_id="ou_boss", + project_hint_id=1, + ) + followup_item = ( + await db.execute( + select(WorkItem).where( + and_( + WorkItem.tenant_id == 1, + WorkItem.source_message_id == "feishu-followup-2", + ) + ) + ) + ).scalar_one() + + self.assertEqual(followup["status"], "captured") + self.assertIn("多个待确认工作项", followup["direct_reply_text"]) + self.assertEqual(followup_item.state, "waiting_manual") + + async def test_followup_explicit_work_item_id_can_resume_across_source_system(self) -> None: + async with self.session_factory() as db: + service = AIPMOKernelService(db, tenant_id=1) + first = await service.process_ingress( + source_system="feishu", + source_message_id="feishu-source-3", + message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。", + sender="老板", + channel_ref="oc_phase3_explicit", + source_sender_open_id="ou_boss", + project_hint_id=1, + ) + target_id = int(first["work_item_id"]) + with patch( + "app.services.ai_pmo_kernel_service.SignalSourceDomainWorkflow.run", + AsyncMock( + return_value={ + "status": "completed", + "registry_id": 9, + "typed_actions": [{"operation_code": "task.create", "status": "applied"}], + "material_action_count": 1, + "fallback_used": False, + "raw_result": {}, + } + ), + ): + followup = await service.process_ingress( + source_system="ai_pmo_ui", + source_message_id="resume-ui-1", + message_text=f"继续处理 work_item #{target_id}", + sender="老板", + channel_ref="ai_pmo_ui", + project_hint_id=1, + ) + + self.assertEqual(followup["status"], "answered") + self.assertIn("已继续处理", followup["direct_reply_text"]) + + def test_signal_source_domain_workflow_keeps_notification_only_pending_manual(self) -> None: + normalized = SignalSourceDomainWorkflow.normalize( + { + "status": "partial", + "signal_source": { + "registry_id": 7, + "status": "pending", + "onboarding_status": "pending_manual", + }, + "openclaw": { + "status": "completed", + "result": { + "execution": { + "results": [ + { + "operation_code": "notification.create", + "status": "applied", + "payload": {"title": "待确认"}, + } + ] + } + }, + }, + "worker_executions": [ + { + "worker_role_key": "pm_dispatcher", + "status": "completed", + "used_fallback": True, + } + ], + "pending_workers": [], + } + ) + + self.assertEqual(normalized["status"], "pending_manual") + self.assertEqual(normalized["material_action_count"], 0) + + async def test_transport_mode_statement_is_answered_without_creating_work_item(self) -> None: + async with self.session_factory() as db: + service = AIPMOKernelService(db, tenant_id=1) + result = await service.process_ingress( + source_system="pmflow_manual", + source_message_id="transport-mode-1", + message_text="现在都是以长连接的形式,并不是webhook。", + sender="老板", + channel_ref="pmflow", + project_hint_id=1, + ) + persisted = ( + await db.execute( + select(WorkItem).where( + and_( + WorkItem.tenant_id == 1, + WorkItem.source_message_id == "transport-mode-1", + ) + ) + ) + ).scalar_one_or_none() + + self.assertTrue(result["handled"]) + self.assertEqual(result["status"], "answered") + self.assertTrue(result["direct_reply_required"]) + self.assertIn("长连接模式", result["direct_reply_text"]) + self.assertIsNone(persisted) + + async def test_signal_source_registry_write_re_normalizes_invalid_kind_and_generic_name(self) -> None: + async with self.session_factory() as db: + service = SignalSourceOrchestrationService(db) + result = await service._create_or_reuse_registry( + tenant_id=1, + project_id=1, + signal_id=None, + operator_user_id=None, + requested_via="feishu", + source_payload={ + "source_kind": "long", + "source_name": "给项目ID", + "source_identifier": "long:给项目id", + "description": "每日搜寻最新的医药行业变革动态", + "config": {"keywords": ["医药行业", "变革"]}, + }, + initiating_text="给项目ID: 1 接一个复合信号源:每日搜寻最新的医药行业变革动态。", + dry_run=False, + ) + + self.assertEqual(result["source_kind"], "feishu") + self.assertNotEqual(result["source_name"], "给项目ID") + self.assertTrue(str(result["source_identifier"]).startswith("feishu:")) diff --git a/backend/tests/test_ai_steward_fail_fast.py b/backend/tests/test_ai_steward_fail_fast.py new file mode 100644 index 00000000..b0852ccd --- /dev/null +++ b/backend/tests/test_ai_steward_fail_fast.py @@ -0,0 +1,107 @@ +import asyncio +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from app.services.ai_steward import AIStewardService +from app.models import CognitiveConflict + + +class AIStewardFailFastTest(unittest.IsolatedAsyncioTestCase): + async def test_analyze_project_input_times_out_to_structured_fallback(self) -> None: + service = AIStewardService(db=None) # type: ignore[arg-type] + + with patch.object(service.llm, "generate", AsyncMock(side_effect=asyncio.TimeoutError())): + result = await service._analyze_project_input( + "创建一个 3 周上线的 AI 客服项目,先出可运行 MVP。", + tenant_id=1, + ) + + self.assertEqual(result["primary_role"], "ai_engineer") + self.assertEqual(result["timeline"], "3周") + + async def test_generate_task_chain_draft_times_out_to_default_tasks(self) -> None: + service = AIStewardService(db=None) # type: ignore[arg-type] + + with patch.object(service.llm, "generate", AsyncMock(side_effect=asyncio.TimeoutError())): + tasks = await service._generate_task_chain_draft( + analysis={ + "project_title": "AI 客服项目", + "tech_stack": ["FastAPI", "React"], + "timeline": "3周", + "primary_role": "ai_engineer", + }, + tenant_id=1, + ) + + self.assertGreaterEqual(len(tasks), 2) + self.assertEqual(tasks[0]["title"], "技术方案设计") + + async def test_persist_project_creation_draft_writes_conflict_user_ids_as_list(self) -> None: + added_rows = [] + + class FakeDB: + def add(self, row): + added_rows.append(row) + + async def flush(self): + current = len(added_rows) + for index, row in enumerate(added_rows, start=1): + if getattr(row, "id", None) is None: + row.id = index + + service = AIStewardService(db=FakeDB()) # type: ignore[arg-type] + + with patch( + "app.services.ai_steward.persist_task", + AsyncMock( + return_value=SimpleNamespace( + id=101, + title="任务A", + description="desc", + estimated_hours=8.0, + priority="high", + status="todo", + ) + ), + ): + payload = await service._persist_project_creation_draft( + normalized={ + "project": { + "name": "测试项目", + "title": "测试项目", + "description": "desc", + "assigned_role": "ai_engineer", + "domain_id": 1, + }, + "signal": { + "content": "signal", + "impact_score": 0.8, + "priority": "high", + "strategic_analysis": {}, + }, + "charter": { + "title": "项目宪章", + "content": {"success_criteria": ["ok"]}, + }, + "tasks": [SimpleNamespace(title="任务A", description="desc")], + "task_audit": [], + "risks": [ + { + "type": "schedule_delay", + "description": "任务超时预警", + "threshold_hours": 48.0, + "severity": "medium", + } + ], + "analysis": {}, + }, + input_text="创建测试项目", + creator_id=2, + tenant_id=1, + ) + + risk_rows = [row for row in added_rows if isinstance(row, CognitiveConflict)] + self.assertEqual(len(risk_rows), 1) + self.assertEqual(risk_rows[0].involved_user_ids, []) + self.assertEqual(payload["risks"][0]["description"], "任务超时预警") diff --git a/backend/tests/test_api_router_order.py b/backend/tests/test_api_router_order.py new file mode 100644 index 00000000..575c228e --- /dev/null +++ b/backend/tests/test_api_router_order.py @@ -0,0 +1,22 @@ +from app.api.router import api_router + + +def test_project_signal_route_uses_single_sstc_canonical_endpoint() -> None: + canonical = [] + legacy = [] + for route in api_router.routes: + path = getattr(route, "path", "") + methods = set(getattr(route, "methods", set()) or set()) + if path == "/projects/{project_id}/signals" and "GET" in methods: + endpoint = getattr(route, "endpoint", None) + canonical.append( + ( + getattr(endpoint, "__module__", ""), + getattr(endpoint, "__name__", ""), + ) + ) + if path == "/projects/{project_id}/linked-signals" and "GET" in methods: + legacy.append(path) + + assert canonical == [("app.api.sstc", "list_signals")] + assert legacy == [] diff --git a/backend/tests/test_auth_password_validation.py b/backend/tests/test_auth_password_validation.py new file mode 100644 index 00000000..2b7747cc --- /dev/null +++ b/backend/tests/test_auth_password_validation.py @@ -0,0 +1,40 @@ +from app.services.auth import AuthService +from app.services import auth as auth_module + + +def test_validate_password_rejects_over_72_utf8_bytes_with_friendly_message() -> None: + password = "a" * 73 + + try: + AuthService.hash_password(password) + raise AssertionError("expected ValueError for overlong password") + except ValueError as exc: + message = str(exc) + + assert "72 个 UTF-8 字节" in message + assert "72 bytes" not in message.lower() + + +def test_validate_password_counts_utf8_bytes_not_only_character_length() -> None: + password = "密" * 25 + + try: + AuthService.validate_password(password) + raise AssertionError("expected ValueError for multibyte password overflow") + except ValueError as exc: + message = str(exc) + + assert "72 个 UTF-8 字节" in message + + +def test_hash_password_falls_back_when_bcrypt_backend_misreports_72_bytes(monkeypatch) -> None: + class _BrokenPwdContext: + @staticmethod + def hash(_password: str) -> str: + raise ValueError("password cannot be longer than 72 bytes, truncate manually if necessary") + + monkeypatch.setattr(auth_module, "HAS_PASSLIB", True) + monkeypatch.setattr(auth_module, "pwd_context", _BrokenPwdContext(), raising=False) + hashed = AuthService.hash_password("Passw0rd!") + + assert hashed == AuthService._simple_hash("Passw0rd!") diff --git a/backend/tests/test_feishu_long_connection.py b/backend/tests/test_feishu_long_connection.py new file mode 100644 index 00000000..1f80f2ec --- /dev/null +++ b/backend/tests/test_feishu_long_connection.py @@ -0,0 +1,131 @@ +import unittest +from unittest.mock import patch + +from app.services.feishu_long_connection import FeishuLongConnectionManager + + +class _AliveThread: + def __init__(self) -> None: + self._alive = True + + def is_alive(self) -> bool: + return self._alive + + def join(self, timeout: float | None = None) -> None: + _ = timeout + self._alive = False + + +class _StubFeishuLongConnectionManager(FeishuLongConnectionManager): + def __init__(self, config: dict | None, preflight_exc: Exception | None = None) -> None: + super().__init__(session_factory=None) # type: ignore[arg-type] + self._sdk_available = True + self._stub_config = config + self._preflight_exc = preflight_exc + self.start_calls: list[tuple[str, str]] = [] + + async def _load_active_config(self) -> dict | None: + if self._stub_config is None: + return None + return dict(self._stub_config) + + async def _preflight_auth(self, *, tenant_id: int) -> None: + _ = tenant_id + if self._preflight_exc is not None: + raise self._preflight_exc + + def _start_ws_thread(self, app_id: str, app_secret: str) -> None: + self.start_calls.append((app_id, app_secret)) + self._thread = _AliveThread() + + +class FeishuLongConnectionManagerTest(unittest.IsolatedAsyncioTestCase): + async def asyncTearDown(self) -> None: + manager = getattr(self, "manager", None) + if manager is not None and manager.status().get("running"): + await manager.stop() + + async def test_auth_failure_is_classified_without_starting_thread(self) -> None: + self.manager = _StubFeishuLongConnectionManager( + config={ + "tenant_id": 9, + "event_mode": "long_connection", + "app_id": "cli_xxx", + "app_secret": "secret_xxx", + }, + preflight_exc=ValueError("获取 tenant_access_token 失败(http=401): {'code': 99991663}"), + ) + + await self.manager.start() + + status = self.manager.status() + self.assertEqual(status["tenant_id"], 9) + self.assertEqual(status["last_error_code"], "auth_failed") + self.assertFalse(status["last_error_retryable"]) + self.assertFalse(status["thread_alive"]) + self.assertEqual(self.manager.start_calls, []) + + async def test_network_failure_is_marked_retryable(self) -> None: + self.manager = _StubFeishuLongConnectionManager( + config={ + "tenant_id": 3, + "event_mode": "long_connection", + "app_id": "cli_xxx", + "app_secret": "secret_xxx", + }, + preflight_exc=ValueError("访问飞书 auth 接口失败: timed out"), + ) + + await self.manager.start() + + status = self.manager.status() + self.assertEqual(status["last_error_code"], "network_error") + self.assertTrue(status["last_error_retryable"]) + self.assertEqual(status["restart_attempts"], 1) + self.assertIsNotNone(status["next_retry_at"]) + self.assertEqual(self.manager.start_calls, []) + + async def test_successful_start_records_tenant_and_auth_timestamp(self) -> None: + self.manager = _StubFeishuLongConnectionManager( + config={ + "tenant_id": 12, + "event_mode": "long_connection", + "app_id": "cli_xxx", + "app_secret": "secret_xxx", + } + ) + + await self.manager.start() + + status = self.manager.status() + self.assertEqual(status["tenant_id"], 12) + self.assertTrue(status["thread_alive"]) + self.assertIsNotNone(status["last_auth_at"]) + self.assertEqual(self.manager.start_calls, [("cli_xxx", "secret_xxx")]) + + async def test_retry_strategy_is_1_2_4_and_stops_after_three_attempts(self) -> None: + self.manager = _StubFeishuLongConnectionManager( + config={ + "tenant_id": 7, + "event_mode": "long_connection", + "app_id": "cli_xxx", + "app_secret": "secret_xxx", + } + ) + self.manager._set_error("network", code="network_error", retryable=True) + + with patch("app.services.feishu_long_connection.time.time", return_value=100.0): + self.manager._schedule_retry() + self.assertEqual(self.manager._restart_attempts, 1) + self.assertEqual(self.manager._next_retry_at, 101.0) + self.manager._schedule_retry() + self.assertEqual(self.manager._restart_attempts, 2) + self.assertEqual(self.manager._next_retry_at, 102.0) + self.manager._schedule_retry() + self.assertEqual(self.manager._restart_attempts, 3) + self.assertEqual(self.manager._next_retry_at, 104.0) + # 第 4 次触发应熔断,不再继续重试。 + self.manager._schedule_retry() + self.assertEqual(self.manager._restart_attempts, 3) + self.assertEqual(self.manager._next_retry_at, 0.0) + self.assertFalse(self.manager._last_error_retryable) diff --git a/backend/tests/test_feishu_routing_receipts.py b/backend/tests/test_feishu_routing_receipts.py new file mode 100644 index 00000000..9babf0b6 --- /dev/null +++ b/backend/tests/test_feishu_routing_receipts.py @@ -0,0 +1,177 @@ +import unittest +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from app.services.feishu_integration import FeishuIntegrationService +from app.services.feishu_signal_pipeline import FeishuSignalPipelineService +from app.services.signal_capture import ProactiveSignalCaptureService +from app.services.context_cache import context_cache_service + + +class FeishuRoutingReceiptsTest(unittest.TestCase): + def test_normalize_incoming_text_strips_bot_mentions_and_quote_noise(self) -> None: + text = FeishuSignalPipelineService._normalize_incoming_text( + "@PMFlow\n回复:\n> 上一条消息\n[附带图片]\n麻烦看下统一通知入口" + ) + + self.assertEqual(text, "麻烦看下统一通知入口") + + def test_pending_receipt_prefers_clarification_message(self) -> None: + reply = FeishuIntegrationService._build_execution_receipt_reply( + message_content="@PMFlow 继续处理", + capture_result={ + "status": "captured", + "results": [{"route_status": "queued_for_decision"}], + "clarification_message": "当前群最近同时涉及项目#12、#18,请明确归属项目。", + }, + ) + + self.assertEqual(reply, "当前群最近同时涉及项目#12、#18,请明确归属项目。") + + def test_failed_receipt_uses_controlled_executor_message(self) -> None: + reply = FeishuIntegrationService._build_execution_receipt_reply( + message_content="@PMFlow 创建任务", + capture_result={ + "status": "captured", + "results": [{"route_status": "failed"}], + }, + ) + + self.assertEqual(reply, "未执行:受控执行器拒绝了这次动作,请补充缺失字段或检查项目上下文。") + + def test_followup_confirmation_reply_uses_real_db_ids(self) -> None: + service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type] + + reply = service._build_followup_confirmation_reply( + target_item=SimpleNamespace(id=23, level="signal"), + confirm_result={"signal_id": "91", "project_id": "12"}, + ) + + self.assertEqual(reply, "已继续处理:信号#91 已确认并关联项目#12。") + + def test_provider_error_guard_blocks_aborted_error_text(self) -> None: + self.assertTrue(FeishuIntegrationService._looks_like_provider_error("Request was aborted.")) + + def test_normalize_outgoing_reply_text_replaces_provider_error(self) -> None: + self.assertEqual( + FeishuIntegrationService._normalize_outgoing_reply_text("Request was aborted."), + "系统处理中出现异常,本次未执行。请稍后重试,或补充更明确的目标、对象和动作。", + ) + + def test_pipeline_decorate_capture_result_sanitizes_provider_error_reply(self) -> None: + result = FeishuSignalPipelineService._decorate_capture_result( + { + "status": "answered", + "classification": "question", + "direct_reply_text": "Request was aborted.", + }, + chat_type="group", + normalized_text="@PMFlow 现在状态如何", + route="group.qa_direct", + db_mutations=[], + ) + + self.assertEqual( + result["direct_reply_text"], + "系统处理中出现异常,本次未执行。请稍后重试,或补充更明确的目标、对象和动作。", + ) + + def test_handle_message_receive_skips_reply_for_duplicate_event(self) -> None: + service = FeishuIntegrationService(db=None) # type: ignore[arg-type] + event = { + "message": { + "message_id": "om_duplicate", + "message_type": "text", + "chat_id": "oc_test", + "chat_type": "group", + "content": '{"text":"@PMFlow 帮我继续处理"}', + }, + "chat": { + "chat_id": "oc_test", + "chat_type": "group", + }, + "sender": { + "sender_type": "user", + "sender_id": {"open_id": "ou_test"}, + "sender_name": "林宇航", + }, + "type": "im.message.receive_v1", + } + + with ( + patch.object(service, "_record_ingress_trace", AsyncMock()), + patch.object(service, "_record_message_trace", AsyncMock()), + patch.object(service, "_resolve_sender_display_name", AsyncMock(return_value="林宇航")), + patch.object(service, "_safe_reply_text", AsyncMock()) as reply_mock, + patch( + "app.services.feishu_integration.ProactiveSignalCaptureService.capture_from_feishu", + AsyncMock( + return_value={ + "status": "ignored", + "reason": "duplicate_filtered", + "classification": "duplicate", + "direct_reply_required": True, + "direct_reply_text": "Request was aborted.", + } + ), + ), + ): + asyncio.run(service._handle_message_receive(event)) + + reply_mock.assert_not_awaited() + + def test_sync_feishu_recent_cache_backfills_missing_rag_turn(self) -> None: + service = ProactiveSignalCaptureService(db=None) # type: ignore[arg-type] + + with ( + patch.object(context_cache_service, "get_session_messages", AsyncMock(return_value=[])), + patch.object(context_cache_service, "append_session_messages", AsyncMock()) as append_mock, + ): + asyncio.run( + service._sync_feishu_recent_cache( + tenant_id=1, + group_chat_id="oc_test", + sender="林宇航", + sender_open_id="ou_test", + message="目前医药方向值得关注什么?", + pipeline_result={ + "rag_result": {"answer": "可关注创新药、器械和公共卫生。"}, + "direct_reply_text": "可关注创新药、器械和公共卫生。", + }, + ) + ) + + append_mock.assert_awaited_once() + + def test_sync_feishu_recent_cache_skips_duplicate_turn(self) -> None: + service = ProactiveSignalCaptureService(db=None) # type: ignore[arg-type] + + with ( + patch.object( + context_cache_service, + "get_session_messages", + AsyncMock( + return_value=[ + {"role": "user", "content": "目前医药方向值得关注什么?"}, + {"role": "assistant", "content": "可关注创新药、器械和公共卫生。"}, + ] + ), + ), + patch.object(context_cache_service, "append_session_messages", AsyncMock()) as append_mock, + ): + asyncio.run( + service._sync_feishu_recent_cache( + tenant_id=1, + group_chat_id="oc_test", + sender="林宇航", + sender_open_id="ou_test", + message="目前医药方向值得关注什么?", + pipeline_result={ + "rag_result": {"answer": "可关注创新药、器械和公共卫生。"}, + "direct_reply_text": "可关注创新药、器械和公共卫生。", + }, + ) + ) + + append_mock.assert_not_awaited() diff --git a/backend/tests/test_inbox_queue_view.py b/backend/tests/test_inbox_queue_view.py new file mode 100644 index 00000000..e78bc9cb --- /dev/null +++ b/backend/tests/test_inbox_queue_view.py @@ -0,0 +1,369 @@ +import unittest +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from app.models.feishu_item import FeishuItem +from app.services.feishu_signal_pipeline import FOLLOWUP_STATUS_RE, FeishuSignalPipelineService + + +class InboxQueueViewTest(unittest.TestCase): + def test_has_processed_message_ignores_ingress_trace_only(self) -> None: + service = FeishuSignalPipelineService(db=SimpleNamespace(), tenant_id=1) # type: ignore[arg-type] + + async def fake_execute(stmt): + sql = str(stmt) + if "FROM feishu_items" in sql: + return SimpleNamespace(scalar_one_or_none=lambda: None) + self.assertIn("ai_processing_logs.level IS NULL OR ai_processing_logs.level !=", sql) + self.assertIn( + "ai_processing_logs.routing_decision IS NULL OR ai_processing_logs.routing_decision !=", + sql, + ) + return SimpleNamespace(scalar_one_or_none=lambda: None) + + service.db.execute = AsyncMock(side_effect=fake_execute) # type: ignore[attr-defined] + + exists = asyncio.run(service._has_processed_message("om_ingress_only")) + + self.assertFalse(exists) + + def test_dedupe_queue_items_aggregates_occurrence_count(self) -> None: + service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type] + first = FeishuItem( + tenant_id=1, + raw_message="接入 GitHub 信号源", + level="signal", + card_type="signal", + route_status="pending", + dedupe_key="signal:3:group:external_integration", + occurrence_count=2, + ) + second = FeishuItem( + tenant_id=1, + raw_message="接入 GitHub 信号源", + level="signal", + card_type="signal", + route_status="pending", + dedupe_key="signal:3:group:external_integration", + occurrence_count=1, + ) + + rows = service._dedupe_queue_items([first, second]) + + self.assertEqual(len(rows), 1) + self.assertEqual(int(rows[0].occurrence_count), 3) + + def test_resolve_message_class_falls_back_from_route_status(self) -> None: + pending_item = FeishuItem( + tenant_id=1, + raw_message="同步 GitHub issue", + level="signal", + card_type="signal", + route_status="pending", + ) + auto_done_item = FeishuItem( + tenant_id=1, + raw_message="同步 GitHub issue", + level="signal", + card_type="signal", + route_status="auto_processed", + ) + + self.assertEqual(FeishuSignalPipelineService._resolve_message_class(pending_item), "action_required") + self.assertEqual(FeishuSignalPipelineService._resolve_message_class(auto_done_item), "auto_done") + + def test_resolve_message_class_supports_new_status_machine(self) -> None: + queued_item = FeishuItem( + tenant_id=1, + raw_message="新增一个项目", + level="project", + card_type="new_project_candidate", + route_status="queued_for_decision", + ) + undone_item = FeishuItem( + tenant_id=1, + raw_message="新增一个项目", + level="command", + card_type="project_create", + route_status="undone", + ) + + self.assertEqual(FeishuSignalPipelineService._resolve_message_class(queued_item), "action_required") + self.assertEqual(FeishuSignalPipelineService._resolve_message_class(undone_item), "auto_done") + + def test_extract_project_intent_fields_requires_title_description_and_scope(self) -> None: + service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type] + + fields = service._extract_project_intent_fields("新增一个项目,名称叫飞书协同中台,用于统一多群消息分发,范围是 PMFlow 主数据入库。") + + self.assertTrue(fields["ready"]) + self.assertEqual(fields["title"], "飞书协同中台") + self.assertIn("统一多群消息分发", fields["description"]) + self.assertIn("PMFlow 主数据入库", fields["project_scope"]) + + def test_resolve_intent_gate_recognizes_explicit_project_create(self) -> None: + service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type] + + gate = asyncio.run( + service._resolve_intent_gate( + message="不对,是新增进 PMFlow 的项目列表里面,名称叫飞书协同中台,用于统一多群消息分发,范围是 PMFlow 主数据入库。", + inferred_project_id=None, + ) + ) + + self.assertEqual(gate["intent"], "create_project") + self.assertEqual(gate["canonical_action"], "project.create") + + def test_resolve_intent_gate_uses_context_prefill_for_project_create(self) -> None: + service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type] + service._maybe_prefill_project_fields_from_context = AsyncMock( # type: ignore[method-assign] + return_value={ + "title": "医药十五五方向研究", + "description": "基于最近一轮医药方向问答整理投资与立项建议。", + "project_scope": "围绕创新药、医疗器械、公共卫生与老龄化方向做机会梳理。", + "missing_fields": [], + "ready": True, + } + ) + + gate = asyncio.run( + service._resolve_intent_gate( + message="汇总以上内容给我创建个项目在项目列表", + inferred_project_id=None, + source_chat_id="oc_test", + source_sender_open_id="ou_test", + sender="林宇航", + ) + ) + + self.assertEqual(gate["intent"], "create_project") + self.assertEqual(gate["fields"]["title"], "医药十五五方向研究") + + def test_resolve_intent_gate_uses_embedded_message_context_for_project_create(self) -> None: + service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type] + + gate = asyncio.run( + service._resolve_intent_gate( + message=( + '目前"十五五"规划(2026-2030)还在制定中,但从已有信号来看,医药行业这几个方向值得关注:' + " 创新药会是核心 中医药传承创新 应对老龄化 供应链安全 出海国际化" + " 根据这个汇总一下,给我创建一个项目在项目列表" + ), + inferred_project_id=None, + ) + ) + + self.assertEqual(gate["intent"], "create_project") + self.assertEqual(gate["fields"]["title"], "十五五医药方向研究") + self.assertIn("创新药会是核心", gate["fields"]["description"]) + + def test_resolve_intent_gate_blocks_project_create_when_recent_context_is_failure_text(self) -> None: + service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type] + service._load_recent_chat_turns = AsyncMock( # type: ignore[method-assign] + return_value=[ + { + "question": "目前十五五规划里,医药行业值得关注哪些方向?", + "answer": "我暂时无法生成可靠回答(模型服务异常)。为避免误导,本次不直接给结论;请稍后重试。", + } + ] + ) + service._load_recent_chat_reply_fallback_text = AsyncMock(return_value="") # type: ignore[method-assign] + + gate = asyncio.run( + service._resolve_intent_gate( + message="根据上面汇总一下,给我创建一个项目在项目列表", + inferred_project_id=None, + source_chat_id="oc_test", + source_sender_open_id="ou_test", + sender="林宇航", + ) + ) + + self.assertEqual(gate["intent"], "clarification_required") + self.assertIn("title", gate["fields"]["missing_fields"]) + + def test_followup_continue_accepts_project_reference_message(self) -> None: + self.assertTrue( + FeishuSignalPipelineService._looks_like_continue_followup("你应该继续创建我刚才要求的项目") + ) + self.assertFalse( + FeishuSignalPipelineService._looks_like_continue_followup("继续创建一个项目,名称叫飞书协同中台") + ) + + def test_followup_status_regex_matches_creation_question(self) -> None: + self.assertTrue(bool(FOLLOWUP_STATUS_RE.search("是否已经创建"))) + self.assertTrue(bool(FOLLOWUP_STATUS_RE.search("创建了吗"))) + + def test_select_followup_status_target_prefers_project_create(self) -> None: + source_item = FeishuItem( + tenant_id=1, + raw_message="新增一个Web搜索信号源", + level="command", + card_type="command", + route_status="auto_done", + result_payload={"operation_code": "source.connect"}, + ) + project_item = FeishuItem( + tenant_id=1, + raw_message="给我创建一个项目在项目列表", + level="command", + card_type="project_create", + route_status="auto_done", + result_payload={"operation_code": "project.create"}, + ) + + target = FeishuSignalPipelineService._select_followup_status_target( + message="是否已经创建", + recent_items=[source_item, project_item], + ) + + self.assertIs(target, project_item) + + def test_resolve_project_candidate_fields_blocks_placeholder_project(self) -> None: + service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type] + item = FeishuItem( + tenant_id=1, + raw_message="给我创建一个项目在项目列表", + level="project", + card_type="new_project_candidate", + route_status="queued_for_decision", + result_payload={ + "operation_code": "project.create", + "operation_payload": { + "title": "", + "description": "", + }, + "project_scope": "", + }, + blocking_reason="未执行:当前建项目请求还缺少 项目名称。", + ) + + fields = service._resolve_project_candidate_fields(item=item, suggestion=None) + + self.assertFalse(fields["ready"]) + self.assertIn("title", fields["missing_fields"]) + + def test_create_project_gate_missing_fields_only_returns_clarification(self) -> None: + service = FeishuSignalPipelineService(db=SimpleNamespace(), tenant_id=1) # type: ignore[arg-type] + service._resolve_command_operator_user = AsyncMock(return_value={"id": 1}) # type: ignore[method-assign] + service._can_execute_high_value_action = lambda _operator: True # type: ignore[method-assign] + service._queue_project_intent_item = AsyncMock() # type: ignore[method-assign] + service._write_processing_log = AsyncMock() # type: ignore[method-assign] + service._execute_controlled_operation = AsyncMock( # type: ignore[method-assign] + side_effect=AssertionError("executor should not run") + ) + + result = asyncio.run( + service._handle_create_project_gate( + message="汇总以上内容给我创建个项目在项目列表", + sender="林宇航", + source_chat_id="oc_test", + source_sender_open_id="ou_test", + feishu_message_id="om_test", + captured_at=None, + intent_gate={ + "intent": "clarification_required", + "canonical_action": "project.create", + "fields": { + "title": "", + "description": "汇总以上内容给我创建个项目在项目列表", + "project_scope": "", + "missing_fields": ["title"], + "ready": False, + }, + "clarification_template": "未执行:当前建项目请求还缺少 项目名称。", + }, + process_started_at=0.0, + ) + ) + + self.assertEqual(result["intent"], "clarification_required") + self.assertEqual(result["direct_reply_text"], "未执行:当前建项目请求还缺少 项目名称。") + self.assertEqual(result["results"], []) + service._queue_project_intent_item.assert_not_awaited() + service._execute_controlled_operation.assert_not_awaited() + + def test_create_task_gate_missing_fields_only_returns_clarification(self) -> None: + service = FeishuSignalPipelineService(db=SimpleNamespace(), tenant_id=1) # type: ignore[arg-type] + service._resolve_command_operator_user = AsyncMock(return_value={"id": 1}) # type: ignore[method-assign] + service._can_execute_high_value_action = lambda _operator: True # type: ignore[method-assign] + service._queue_command_intent_item = AsyncMock() # type: ignore[method-assign] + service._write_processing_log = AsyncMock() # type: ignore[method-assign] + service._execute_controlled_operation = AsyncMock( # type: ignore[method-assign] + side_effect=AssertionError("executor should not run") + ) + + result = asyncio.run( + service._handle_create_task_gate( + message="帮我建个任务", + sender="林宇航", + source_chat_id="oc_test", + source_sender_open_id="ou_test", + feishu_message_id="om_test", + captured_at=None, + intent_gate={ + "intent": "clarification_required", + "canonical_action": "task.create", + "fields": { + "project_id": None, + "title": "", + "description": "", + "missing_fields": ["project_id", "title"], + "ready": False, + }, + "clarification_template": "未执行:当前建任务请求还缺少 归属项目、任务标题。", + }, + process_started_at=0.0, + ) + ) + + self.assertEqual(result["intent"], "clarification_required") + self.assertEqual(result["direct_reply_text"], "未执行:当前建任务请求还缺少 归属项目、任务标题。") + self.assertEqual(result["results"], []) + service._queue_command_intent_item.assert_not_awaited() + service._execute_controlled_operation.assert_not_awaited() + + def test_should_hide_pending_item_from_inbox_for_query_and_missing_fields(self) -> None: + lookup_item = FeishuItem( + tenant_id=1, + raw_message="现在有哪些项目", + level="signal", + card_type="signal", + route_status="pending", + ) + clarification_item = FeishuItem( + tenant_id=1, + raw_message="汇总以上内容给我创建个项目在项目列表", + level="project", + card_type="new_project_candidate", + route_status="queued_for_decision", + decision_reason="missing_required_fields", + ) + status_item = FeishuItem( + tenant_id=1, + raw_message="你好 长连接时都已通", + level="signal", + card_type="signal", + route_status="pending", + ) + + self.assertTrue(FeishuSignalPipelineService._should_hide_pending_item_from_inbox(lookup_item)) + self.assertTrue(FeishuSignalPipelineService._should_hide_pending_item_from_inbox(clarification_item)) + self.assertTrue(FeishuSignalPipelineService._should_hide_pending_item_from_inbox(status_item)) + + def test_build_project_preview_prefers_structured_title_and_detail(self) -> None: + title = FeishuSignalPipelineService._build_project_preview_title( + title="", + description="基于最近一轮医药方向问答整理投资与立项建议。", + project_scope="围绕创新药、医疗器械、公共卫生与老龄化方向做机会梳理。", + ) + summary = FeishuSignalPipelineService._build_project_preview_summary( + description="基于最近一轮医药方向问答整理投资与立项建议。", + project_scope="围绕创新药、医疗器械、公共卫生与老龄化方向做机会梳理。", + blocking_reason="未执行:当前建项目请求还缺少 项目名称。", + fallback_text="汇总以上内容给我创建个项目在项目列表", + ) + + self.assertEqual(title, "围绕创新药") + self.assertIn("投资与立项建议", summary) diff --git a/backend/tests/test_llm_structured_extraction.py b/backend/tests/test_llm_structured_extraction.py new file mode 100644 index 00000000..d1e7cf25 --- /dev/null +++ b/backend/tests/test_llm_structured_extraction.py @@ -0,0 +1,207 @@ +import unittest +from unittest.mock import AsyncMock + +from app.services.llm import ChatCompletionResult, LLMService, StructuredExtractionResult + + +class LLMStructuredExtractionTest(unittest.TestCase): + def setUp(self) -> None: + self.service = LLMService() + + def _extract(self, response: dict, *, function_name: str = "openclaw_command_dispatch") -> StructuredExtractionResult: + return self.service._extract_structured_object_from_response( + function_name=function_name, + response=response, + fallback_text="", + provider="glm", + model="GLM-4.7", + ) + + def test_extracts_object_from_plain_content(self) -> None: + result = self._extract( + { + "choices": [ + { + "message": { + "content": '{"summary":"ok","confidence":0.9,"operations":[]}', + } + } + ] + } + ) + + self.assertEqual(result.validation, "ok") + self.assertEqual(result.payload["summary"], "ok") + + def test_extracts_object_from_typed_chunk_content(self) -> None: + result = self._extract( + { + "choices": [ + { + "message": { + "content": [ + {"type": "output_text", "text": '{"summary":"chunk","confidence":0.8,"operations":[]}'} + ] + } + } + ] + } + ) + + self.assertEqual(result.validation, "ok") + self.assertEqual(result.payload["summary"], "chunk") + + def test_extracts_object_from_tool_call_arguments(self) -> None: + result = self._extract( + { + "choices": [ + { + "message": { + "tool_calls": [ + { + "function": { + "arguments": '{"summary":"args","confidence":0.7,"operations":[]}' + } + } + ], + "content": "", + } + } + ] + } + ) + + self.assertEqual(result.source_field, "tool_calls.function.arguments") + self.assertEqual(result.payload["summary"], "args") + + def test_extracts_json_from_reasoning_content_with_prefix_text(self) -> None: + result = self._extract( + { + "choices": [ + { + "message": { + "content": "", + "reasoning_content": "先分析上下文,再给结果:\n{\"summary\":\"from_reasoning\",\"confidence\":0.66,\"operations\":[]}", + } + } + ] + } + ) + + self.assertEqual(result.source_field, "message.reasoning_content") + self.assertEqual(result.payload["summary"], "from_reasoning") + + def test_returns_json_not_found_when_reasoning_has_only_natural_language(self) -> None: + result = self._extract( + { + "choices": [ + { + "message": { + "content": "", + "reasoning_content": "我认为还需要更多上下文。", + } + } + ] + } + ) + + self.assertEqual(result.error_code, "structured_json_not_found") + self.assertEqual(result.source_field, "message.reasoning_content") + + def test_unwraps_nested_json_field_once(self) -> None: + result = self._extract( + { + "choices": [ + { + "message": { + "content": '{"json":"{\\"summary\\":\\"nested\\",\\"confidence\\":0.55,\\"operations\\":[]}"}', + } + } + ] + } + ) + + self.assertEqual(result.validation, "ok") + self.assertEqual(result.payload["summary"], "nested") + + def test_rejects_top_level_array_for_object_only_function(self) -> None: + result = self._extract( + { + "choices": [ + { + "message": { + "content": '[{"summary":"bad"}]', + } + } + ] + } + ) + + self.assertEqual(result.error_code, "structured_schema_invalid") + + +class LLMStructuredCacheTest(unittest.IsolatedAsyncioTestCase): + async def test_generate_structured_object_uses_cache_after_first_hit(self) -> None: + service = LLMService() + calls: list[str] = [] + cache: dict[str, str] = {} + + class _FakeRedis: + async def get(self, key: str): + return cache.get(key) + + async def setex(self, key: str, _ttl: int, value: str): + cache[key] = value + + service._get_redis = AsyncMock(return_value=_FakeRedis()) + service._resolve_engine_for_call = AsyncMock( + return_value={ + "provider": "glm", + "model": "GLM-4.7", + "api_key": "fake", + "base_url": "http://unit.test", + } + ) + service._resolve_active_prompt_profile = AsyncMock(return_value={"role_name": "", "system_prompt": ""}) + service._log_ai_call = AsyncMock() + + async def _fake_chat_completion_raw(**_: object) -> ChatCompletionResult: + calls.append("hit") + return ChatCompletionResult( + text='{"summary":"cached","confidence":0.9,"operations":[]}', + raw_response={ + "choices": [ + { + "message": { + "content": '{"summary":"cached","confidence":0.9,"operations":[]}', + } + } + ] + }, + ) + + service._chat_completion_raw = AsyncMock(side_effect=_fake_chat_completion_raw) + + first = await service.generate_structured_object( + "planner prompt", + db=object(), + tenant_id=1, + function_name="openclaw_command_dispatch", + response_mode="fast", + ) + second = await service.generate_structured_object( + "planner prompt", + db=object(), + tenant_id=1, + function_name="openclaw_command_dispatch", + response_mode="fast", + ) + + self.assertEqual(first.payload["summary"], "cached") + self.assertEqual(second.payload["summary"], "cached") + self.assertTrue(second.from_cache) + self.assertEqual(len(calls), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_openclaw_commander_service.py b/backend/tests/test_openclaw_commander_service.py new file mode 100644 index 00000000..15de8509 --- /dev/null +++ b/backend/tests/test_openclaw_commander_service.py @@ -0,0 +1,445 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from app.models import Project +from app.services.openclaw_commander_service import ( + OpenClawCommanderService, + OperationExecutorService, +) +from app.services.llm import StructuredExtractionResult + + +class _DuplicatePersistentExecutor(OperationExecutorService): + def __init__(self) -> None: + super().__init__(db=None) # type: ignore[arg-type] + + async def _claim_persistent_idempotency(self, **_: object): + return None, { + "status": "skipped", + "message": "duplicate_persistent", + "entity_type": "notification", + "entity_id": "21", + "trace_id": "openclaw-dup-1", + } + + async def _execute_single_operation(self, **_: object): + raise AssertionError("duplicate notification should not execute") + + +class _SignalSourceFallbackCommander(OpenClawCommanderService): + def __init__(self, *, charter_ready: bool) -> None: + super().__init__(db=None) # type: ignore[arg-type] + self.charter_ready = charter_ready + + async def _load_project(self, **_: object): + return SimpleNamespace(id=3, owner_id=7, title="项目A", status="active") + + async def _load_latest_aligned_charter(self, **_: object): + if not self.charter_ready: + return None + return SimpleNamespace(id=2, status="aligned") + + async def _resolve_signal_source_notification_recipient(self, **_: object): + return 7 + + +class _ProjectFallbackCommander(OpenClawCommanderService): + def __init__(self) -> None: + super().__init__(db=None) # type: ignore[arg-type] + + async def _load_project_directory(self, **_: object): + return [{"project_id": 8, "title": "财务冲账协同", "status": "active"}] + + +class _MemoryDB: + def __init__(self) -> None: + self.added: list[object] = [] + + def add(self, obj: object) -> None: + self.added.append(obj) + + async def flush(self) -> None: + for obj in self.added: + if isinstance(obj, Project) and getattr(obj, "id", None) in {None, 0}: + obj.id = 8 + + async def commit(self) -> None: + return None + + async def rollback(self) -> None: + return None + + +class _ProjectExecutor(OperationExecutorService): + def __init__(self, *, existing_project: object | None = None) -> None: + self.memory_db = _MemoryDB() + super().__init__(db=self.memory_db) # type: ignore[arg-type] + self.existing_project = existing_project + + async def _load_project_by_title(self, **_: object): + return self.existing_project + + async def _load_active_user(self, *, tenant_id: int, user_id: int): + _ = tenant_id + return SimpleNamespace(id=user_id, status="active") + + +class OpenClawCommanderServiceTest(unittest.TestCase): + def setUp(self) -> None: + self.executor = OperationExecutorService(db=None) # type: ignore[arg-type] + + def test_plan_operations_accepts_tool_call_json(self) -> None: + service = OpenClawCommanderService(db=None) # type: ignore[arg-type] + service._commander_operation_catalog = lambda: [ # type: ignore[method-assign] + { + "operation_code": "task.assign", + "description": "assign", + "input_schema": {"properties": {"task_id": {}, "assignee_id": {}}}, + } + ] + + with patch( + "app.services.openclaw_commander_service.llm_service.generate_structured_object", + AsyncMock( + return_value=StructuredExtractionResult( + payload={ + "summary": "先补负责人,再发通知", + "confidence": 0.82, + "operations": [ + { + "operation_code": "task.assign", + "payload": {"task_id": 12, "assignee_id": 5}, + "reason": "技能更匹配", + } + ], + }, + source_field="tool_calls.function.arguments", + provider="glm", + model="GLM-4.7", + validation="ok", + ) + ), + ): + plan, planner_status, diagnostics = self.async_run( + service._plan_operations( + tenant_id=1, + operator_user=SimpleNamespace(id=9, name="Boss", role="BOSS"), + project=SimpleNamespace(id=3, title="项目A", name="项目A", description="desc", status="active", assigned_role=""), + tasks=[], + task_summary={}, + members=[], + instruction="把任务 12 分给张三", + dry_run=False, + max_operations=4, + response_mode="fast", + ) + ) + + self.assertEqual(planner_status, "planned") + self.assertEqual(diagnostics["source_field"], "tool_calls.function.arguments") + self.assertEqual(plan["operations"][0]["payload"]["task_id"], 12) + + def test_plan_operations_returns_schema_invalid_diagnostics_on_bad_payload(self) -> None: + service = OpenClawCommanderService(db=None) # type: ignore[arg-type] + service._commander_operation_catalog = lambda: [] # type: ignore[method-assign] + service._build_fallback_plan = AsyncMock(return_value={"summary": "等待补充上下文。", "confidence": 0.0, "operations": []}) # type: ignore[method-assign] + + with patch( + "app.services.openclaw_commander_service.llm_service.generate_structured_object", + AsyncMock( + return_value=StructuredExtractionResult( + payload=None, + source_field="message.content", + provider="glm", + model="GLM-4.7", + error_code="structured_schema_invalid", + validation="invalid", + ) + ), + ): + plan, planner_status, diagnostics = self.async_run( + service._plan_operations( + tenant_id=1, + operator_user=SimpleNamespace(id=9, name="Boss", role="BOSS"), + project=SimpleNamespace(id=3, title="项目A", name="项目A", description="desc", status="active", assigned_role=""), + tasks=[], + task_summary={}, + members=[], + instruction="把任务 12 分给张三", + dry_run=False, + max_operations=4, + response_mode="fast", + ) + ) + + self.assertEqual(planner_status, "fallback") + self.assertEqual(diagnostics["error_code"], "structured_schema_invalid") + self.assertEqual(plan["operations"], []) + + def test_validate_payload_coerces_integer_and_rejects_extra_fields(self) -> None: + normalized = self.executor._validate_and_normalize_payload( + operation_code="task.assign", + payload={"task_id": "12", "assignee_id": "8"}, + ) + + self.assertEqual(normalized, {"task_id": 12, "assignee_id": 8}) + + with self.assertRaisesRegex(ValueError, "unsupported fields"): + self.executor._validate_and_normalize_payload( + operation_code="task.assign", + payload={"task_id": 12, "assignee_id": 8, "foo": "bar"}, + ) + + def test_validate_task_create_payload_supports_array_and_numeric_fields(self) -> None: + normalized = self.executor._validate_and_normalize_payload( + operation_code="task.create", + payload={ + "project_id": "7", + "title": "补充告警兜底", + "required_skill_tag_ids": ["1", 2, "3"], + "estimated_hours": "4.5", + "priority": "P0", + }, + ) + + self.assertEqual(normalized["project_id"], 7) + self.assertEqual(normalized["required_skill_tag_ids"], [1, 2, 3]) + self.assertEqual(normalized["estimated_hours"], 4.5) + self.assertEqual(normalized["priority"], "P0") + + def test_validate_project_create_payload_supports_optional_owner_and_description(self) -> None: + normalized = self.executor._validate_and_normalize_payload( + operation_code="project.create", + payload={ + "title": "财务冲账协同", + "description": "用于协调财务冲账任务。", + "owner_id": "7", + }, + ) + + self.assertEqual( + normalized, + { + "title": "财务冲账协同", + "description": "用于协调财务冲账任务。", + "owner_id": 7, + }, + ) + + def test_workload_update_payload_requires_max_weekly_hours(self) -> None: + normalized = self.executor._validate_and_normalize_payload( + operation_code="personnel.workload_update", + payload={"user_id": "5", "max_weekly_hours": "32"}, + ) + + self.assertEqual(normalized, {"user_id": 5, "max_weekly_hours": 32.0}) + + with self.assertRaisesRegex(ValueError, "unsupported fields"): + self.executor._validate_and_normalize_payload( + operation_code="personnel.workload_update", + payload={"user_id": 5, "workload": 70}, + ) + + def test_notification_idempotency_key_uses_trimmed_content_hash(self) -> None: + base_payload = { + "user_id": 3, + "title": "请处理阻塞", + "content": " 任务 A 已重新分配给你 ", + } + same_after_trim = { + "user_id": 3, + "title": "请处理阻塞", + "content": "任务 A 已重新分配给你", + } + changed_content = { + "user_id": 3, + "title": "请处理阻塞", + "content": "任务 B 已重新分配给你", + } + + key_a = self.executor._build_idempotency_key( + operation_code="notification.create", + payload=base_payload, + tenant_id=1, + project_id=9, + ) + key_b = self.executor._build_idempotency_key( + operation_code="notification.create", + payload=same_after_trim, + tenant_id=1, + project_id=9, + ) + key_c = self.executor._build_idempotency_key( + operation_code="notification.create", + payload=changed_content, + tenant_id=1, + project_id=9, + ) + key_other_project = self.executor._build_idempotency_key( + operation_code="notification.create", + payload=same_after_trim, + tenant_id=1, + project_id=10, + ) + + self.assertEqual(key_a, key_b) + self.assertNotEqual(key_a, key_c) + self.assertNotEqual(key_a, key_other_project) + + def test_execute_operations_skips_persistent_duplicate_notification(self) -> None: + executor = _DuplicatePersistentExecutor() + + result = self.async_run( + executor.execute_operations( + tenant_id=1, + operator_user=SimpleNamespace(id=9), + project=SimpleNamespace(id=4), + operations=[ + { + "operation_code": "notification.create", + "payload": { + "user_id": 3, + "title": "请处理阻塞", + "content": "任务 A 已重新分配给你", + }, + "reason": "重复回放", + } + ], + dry_run=False, + trace_id="openclaw-1", + ) + ) + + self.assertEqual(result["requested_count"], 1) + self.assertEqual(result["skipped_count"], 1) + self.assertEqual(result["results"][0]["message"], "duplicate_persistent") + self.assertEqual(result["results"][0]["entity_id"], "21") + + def test_normalize_task_priority_maps_p_levels(self) -> None: + self.assertEqual(self.executor._normalize_task_priority("P0"), "high") + self.assertEqual(self.executor._normalize_task_priority("P1"), "medium") + self.assertEqual(self.executor._normalize_task_priority("P3"), "low") + + def test_signal_source_fallback_without_charter_creates_notification_only(self) -> None: + commander = _SignalSourceFallbackCommander(charter_ready=False) + + plan = self.async_run( + commander._build_fallback_plan( + tenant_id=1, + project_id=3, + instruction="新增一个 GitHub 信号源,监控核心仓库 issue。", + max_operations=4, + error="llm offline", + ) + ) + + self.assertEqual(plan["summary"], "OpenClaw 规划失败,已回退为信号源接入最小闭环。") + self.assertEqual(len(plan["operations"]), 1) + self.assertEqual(plan["operations"][0]["operation_code"], "notification.create") + self.assertEqual(plan["operations"][0]["payload"]["title"], "GitHub信号源接入待确认") + + def test_signal_source_fallback_with_charter_creates_task_and_notification(self) -> None: + commander = _SignalSourceFallbackCommander(charter_ready=True) + + plan = self.async_run( + commander._build_fallback_plan( + tenant_id=1, + project_id=3, + instruction="新增一个 GitHub 信号源,监控核心仓库 issue。", + max_operations=4, + error="llm offline", + ) + ) + + self.assertEqual([item["operation_code"] for item in plan["operations"]], ["task.create", "notification.create"]) + self.assertEqual(plan["operations"][0]["payload"]["project_id"], 3) + self.assertEqual(plan["operations"][0]["payload"]["title"], "接入GitHub信号源") + + def test_project_create_fallback_builds_project_create_operation(self) -> None: + commander = _ProjectFallbackCommander() + + plan = self.async_run( + commander._build_fallback_plan( + tenant_id=1, + project_id=None, + instruction="帮我创建一个项目,财务冲账协同", + max_operations=4, + error="structured_http_error", + project_directory=None, + ) + ) + + self.assertEqual(plan["operations"][0]["operation_code"], "project.create") + self.assertEqual(plan["operations"][0]["payload"]["title"], "财务冲账协同") + + def test_project_status_fallback_resolves_project_id_from_directory(self) -> None: + commander = _ProjectFallbackCommander() + + plan = self.async_run( + commander._build_fallback_plan( + tenant_id=1, + project_id=None, + instruction="把财务冲账协同项目归档", + max_operations=4, + error="structured_http_error", + project_directory=None, + ) + ) + + self.assertEqual(plan["operations"][0]["operation_code"], "project.archive") + self.assertEqual(plan["operations"][0]["payload"]["project_id"], 8) + + def test_execute_project_create_creates_active_project(self) -> None: + executor = _ProjectExecutor() + + with patch( + "app.services.openclaw_commander_service.log_operation", + AsyncMock(), + ), patch( + "app.services.openclaw_commander_service.context_cache_service.invalidate_system_context", + AsyncMock(), + ): + result = self.async_run( + executor._execute_project_create( + tenant_id=1, + operator_user=SimpleNamespace(id=9, role="BOSS"), + payload={"title": "财务冲账协同", "description": "用于财务冲账。"}, + reason="explicit_project_create", + trace_id="openclaw-1", + ) + ) + + created_project = next(obj for obj in executor.memory_db.added if isinstance(obj, Project)) + self.assertEqual(result["status"], "applied") + self.assertEqual(created_project.title, "财务冲账协同") + self.assertEqual(created_project.status, "active") + self.assertEqual(result["result"]["project_id"], 8) + + def test_execute_project_create_skips_existing_project(self) -> None: + executor = _ProjectExecutor( + existing_project=SimpleNamespace(id=5, title="财务冲账协同", name="财务冲账协同", status="active") + ) + + result = self.async_run( + executor._execute_project_create( + tenant_id=1, + operator_user=SimpleNamespace(id=9, role="BOSS"), + payload={"title": "财务冲账协同"}, + reason="explicit_project_create", + trace_id="openclaw-1", + ) + ) + + self.assertEqual(result["status"], "skipped") + self.assertEqual(result["entity_id"], "5") + self.assertEqual(result["result"]["status"], "active") + + def async_run(self, awaitable): + import asyncio + + return asyncio.run(awaitable) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_signal_source_orchestration_service.py b/backend/tests/test_signal_source_orchestration_service.py new file mode 100644 index 00000000..266118f3 --- /dev/null +++ b/backend/tests/test_signal_source_orchestration_service.py @@ -0,0 +1,422 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from app.models.signal_source_registry import SignalSourceRegistry +from app.services.llm import StructuredExtractionResult +from app.services.signal_source_orchestration_service import SignalSourceOrchestrationService + + +class _StubSignalSourceOrchestrationService(SignalSourceOrchestrationService): + def __init__(self) -> None: + super().__init__(db=None) # type: ignore[arg-type] + + async def _load_project(self, **_: object): + return SimpleNamespace(id=3, title="项目A", name="项目A", description="desc", owner_id=1) + + async def _load_signal(self, **_: object): + return SimpleNamespace( + id=9, + title="新增 GitHub 信号源", + summary="监控关键仓库 issue/PR", + signal_type="新需求", + recommended_action="enter_only", + content="新增 GitHub 信号源并自动拆任务", + ) + + async def _plan_onboarding(self, **_: object): + return ( + { + "should_onboard": True, + "summary": "接入 GitHub 仓库信号源并下发 AI worker。", + "source": { + "source_kind": "github", + "source_name": "核心仓库", + "source_identifier": "github:core-repo", + "description": "跟踪 issue、pr、release", + }, + "worker_roles": ["pm_dispatcher", "tech_architect"], + "openclaw_instruction": "为 GitHub 信号源创建接入任务并通知负责人。", + }, + "planned", + { + "used_fallback": False, + "error_code": None, + "source_field": "message.content", + "provider": "glm", + "model": "GLM-4.7", + }, + ) + + async def _create_or_reuse_registry(self, **_: object): + row = SignalSourceRegistry( + tenant_id=1, + project_id=3, + signal_id=9, + source_kind="github", + source_name="核心仓库", + source_identifier="github:core-repo", + description="跟踪 issue、pr、release", + requested_via="pmflow", + status="active", + onboarding_status="pending", + ) + row.id = 11 # type: ignore[assignment] + return { + "status": "created", + "registry_id": 11, + "row": row, + } + + async def _resolve_operator_user(self, **_: object): + return SimpleNamespace(id=1, role="BOSS") + + async def _dispatch_openclaw(self, **_: object): + return { + "status": "completed", + "result": { + "trace_id": "openclaw-123", + "execution": { + "results": [ + { + "operation_code": "task.create", + "status": "applied", + "entity_id": "21", + "payload": {"title": "接入 GitHub 监听"}, + } + ] + }, + }, + } + + async def _execute_worker_roles(self, **_: object): + return [ + { + "worker_role_key": "pm_dispatcher", + "worker_role_name": "产品经理(任务分派)", + "status": "completed", + "summary": "已输出接入拆解。", + }, + { + "worker_role_key": "tech_architect", + "worker_role_name": "技术架构师", + "status": "completed", + "summary": "已输出接入约束。", + }, + ] + + +class SignalSourceOrchestrationServiceTest(unittest.TestCase): + def async_run(self, awaitable): + import asyncio + + return asyncio.run(awaitable) + + def test_detect_signal_source_request(self) -> None: + self.assertTrue( + SignalSourceOrchestrationService.looks_like_signal_source_request( + "请新增一个 GitHub 信号源,监控核心仓库 issue。" + ) + ) + self.assertFalse( + SignalSourceOrchestrationService.looks_like_signal_source_request( + "把任务 12 分给张三。" + ) + ) + + def test_orchestrate_from_signal_returns_completed_payload(self) -> None: + service = _StubSignalSourceOrchestrationService() + + result = self.async_run( + service.orchestrate_from_signal( + tenant_id=1, + project_id=3, + signal_id=9, + initiating_text="新增一个 GitHub 信号源,监控核心仓库 issue。", + requested_via="pmflow", + operator_user_id=1, + dry_run=True, + ) + ) + + self.assertEqual(result["status"], "planned") + self.assertEqual(result["signal_source"]["registry_id"], 11) + self.assertEqual(result["signal_source"]["source_kind"], "github") + self.assertEqual(len(result["worker_executions"]), 2) + self.assertEqual(result["openclaw"]["status"], "completed") + + def test_orchestrate_from_signal_skips_non_source_request(self) -> None: + service = _StubSignalSourceOrchestrationService() + + result = self.async_run( + service.orchestrate_from_signal( + tenant_id=1, + project_id=3, + signal_id=9, + initiating_text="把任务 12 调成 P0。", + requested_via="pmflow", + operator_user_id=1, + dry_run=True, + ) + ) + + self.assertEqual(result["status"], "skipped") + self.assertEqual(result["reason"], "not_signal_source_request") + + def test_normalize_source_payload_uses_request_hash_for_generic_source_name(self) -> None: + service = _StubSignalSourceOrchestrationService() + + normalized = service._normalize_source_payload( + source_payload={}, + fallback_text="请新增一个 GitHub 信号源,监控核心仓库 issue 和 PR。", + requested_via="pmflow", + ) + + self.assertEqual(normalized["source_kind"], "github") + self.assertEqual(normalized["source_name"], "GitHub") + self.assertTrue(normalized["source_identifier"].startswith("github:req-")) + + def test_build_source_identifier_prefers_explicit_repo_locator(self) -> None: + identifier = SignalSourceOrchestrationService._build_source_identifier( + source_kind="github", + source_name="GitHub", + text="新增 GitHub 信号源,仓库是 openai/codex。", + source_payload={"repo": "OpenAI/Codex"}, + ) + + self.assertEqual(identifier, "github:openai/codex") + + def test_map_orchestration_status_updates_signal_status_label(self) -> None: + self.assertEqual( + SignalSourceOrchestrationService._map_orchestration_status_to_signal_status("completed"), + "已编排", + ) + self.assertEqual( + SignalSourceOrchestrationService._map_orchestration_status_to_signal_status("pending_manual"), + "待确认接入", + ) + self.assertEqual( + SignalSourceOrchestrationService._map_orchestration_status_to_signal_status("partial"), + "部分编排", + ) + self.assertEqual( + SignalSourceOrchestrationService._map_orchestration_status_to_signal_status("failed"), + "编排失败", + ) + + def test_resolve_registry_activation_requires_real_config_and_non_fallback_result(self) -> None: + registry_status, onboarding_status = SignalSourceOrchestrationService._resolve_registry_activation( + source_payload={ + "source_kind": "pmflow", + "source_name": "Web搜索", + "source_identifier": "pmflow:req-abc123", + "description": "新增一个Web搜索信号源,关键词:医药行业。", + "config": {}, + }, + openclaw_result={ + "status": "completed", + "result": { + "execution": { + "results": [ + { + "operation_code": "task.create", + "status": "applied", + "payload": {"title": "接入 GitHub 监听"}, + } + ] + } + }, + }, + worker_results=[ + { + "worker_role_key": "pm_dispatcher", + "status": "completed", + "used_fallback": True, + } + ], + ) + + self.assertEqual(registry_status, "pending") + self.assertEqual(onboarding_status, "pending_manual") + + def test_resolve_registry_activation_allows_explicit_repo_with_real_worker_output(self) -> None: + registry_status, onboarding_status = SignalSourceOrchestrationService._resolve_registry_activation( + source_payload={ + "source_kind": "github", + "source_name": "核心仓库", + "source_identifier": "github:openai/codex", + "description": "监控 openai/codex issue 和 PR", + "config": {}, + "repo": "openai/codex", + }, + openclaw_result={ + "status": "completed", + "result": { + "execution": { + "results": [ + { + "operation_code": "task.create", + "status": "applied", + "payload": {"title": "接入 GitHub 监听"}, + } + ] + } + }, + }, + worker_results=[ + { + "worker_role_key": "tech_architect", + "status": "completed", + "used_fallback": False, + } + ], + ) + + self.assertEqual(registry_status, "active") + self.assertEqual(onboarding_status, "completed") + + def test_plan_onboarding_accepts_reasoning_only_json(self) -> None: + service = SignalSourceOrchestrationService(db=None) # type: ignore[arg-type] + + with patch( + "app.services.signal_source_orchestration_service.llm_service.generate_structured_object", + AsyncMock( + return_value=StructuredExtractionResult( + payload={ + "should_onboard": True, + "summary": "接入 GitHub 仓库信号源。", + "source": { + "source_kind": "github", + "source_name": "核心仓库", + "source_identifier": "github:openai/codex", + "description": "监控 issue 与 PR", + }, + "worker_roles": ["pm_dispatcher"], + "openclaw_instruction": "创建接入任务。", + }, + source_field="message.reasoning_content", + provider="glm", + model="GLM-4.7", + validation="ok", + ) + ), + ): + plan, planner_status, diagnostics = self.async_run( + service._plan_onboarding( + tenant_id=1, + project=SimpleNamespace(id=3, title="项目A", name="项目A", description="desc"), + signal=SimpleNamespace(id=9, title="新增 GitHub 信号源", summary="监控 issue", signal_type="新需求", recommended_action="enter_only"), + initiating_text="新增一个 GitHub 信号源,监控核心仓库 issue。", + requested_via="pmflow", + response_mode="fast", + ) + ) + + self.assertEqual(planner_status, "planned") + self.assertTrue(plan["should_onboard"]) + self.assertEqual(diagnostics["source_field"], "message.reasoning_content") + + def test_plan_onboarding_uses_tool_call_payload_when_content_is_empty(self) -> None: + service = SignalSourceOrchestrationService(db=None) # type: ignore[arg-type] + + with patch( + "app.services.signal_source_orchestration_service.llm_service.generate_structured_object", + AsyncMock( + return_value=StructuredExtractionResult( + payload={ + "should_onboard": True, + "summary": "接入 GitHub 仓库信号源。", + "source": { + "source_kind": "github", + "source_name": "核心仓库", + "source_identifier": "github:openai/codex", + "description": "监控 issue 与 PR", + }, + "worker_roles": ["pm_dispatcher"], + "openclaw_instruction": "创建接入任务。", + }, + source_field="tool_calls.function.arguments", + provider="glm", + model="GLM-4.7", + validation="ok", + ) + ), + ): + _, planner_status, diagnostics = self.async_run( + service._plan_onboarding( + tenant_id=1, + project=SimpleNamespace(id=3, title="项目A", name="项目A", description="desc"), + signal=SimpleNamespace(id=9, title="新增 GitHub 信号源", summary="监控 issue", signal_type="新需求", recommended_action="enter_only"), + initiating_text="新增一个 GitHub 信号源,监控核心仓库 issue。", + requested_via="pmflow", + response_mode="fast", + ) + ) + + self.assertEqual(planner_status, "planned") + self.assertEqual(diagnostics["source_field"], "tool_calls.function.arguments") + + def test_plan_onboarding_returns_schema_invalid_diagnostics(self) -> None: + service = SignalSourceOrchestrationService(db=None) # type: ignore[arg-type] + + with patch( + "app.services.signal_source_orchestration_service.llm_service.generate_structured_object", + AsyncMock( + return_value=StructuredExtractionResult( + payload=None, + source_field="message.content", + provider="glm", + model="GLM-4.7", + error_code="structured_schema_invalid", + validation="invalid", + ) + ), + ): + plan, planner_status, diagnostics = self.async_run( + service._plan_onboarding( + tenant_id=1, + project=SimpleNamespace(id=3, title="项目A", name="项目A", description="desc"), + signal=SimpleNamespace(id=9, title="新增 GitHub 信号源", summary="监控 issue", signal_type="新需求", recommended_action="enter_only"), + initiating_text="新增一个 GitHub 信号源,监控核心仓库 issue。", + requested_via="pmflow", + response_mode="fast", + ) + ) + + self.assertEqual(planner_status, "fallback") + self.assertEqual(diagnostics["error_code"], "structured_schema_invalid") + self.assertTrue(plan["should_onboard"]) + + def test_plan_onboarding_returns_json_not_found_diagnostics(self) -> None: + service = SignalSourceOrchestrationService(db=None) # type: ignore[arg-type] + + with patch( + "app.services.signal_source_orchestration_service.llm_service.generate_structured_object", + AsyncMock( + return_value=StructuredExtractionResult( + payload=None, + source_field="response.reasoning_content", + provider="glm", + model="GLM-4.7", + error_code="structured_json_not_found", + validation="invalid", + ) + ), + ): + _, planner_status, diagnostics = self.async_run( + service._plan_onboarding( + tenant_id=1, + project=SimpleNamespace(id=3, title="项目A", name="项目A", description="desc"), + signal=SimpleNamespace(id=9, title="新增 GitHub 信号源", summary="监控 issue", signal_type="新需求", recommended_action="enter_only"), + initiating_text="新增一个 GitHub 信号源,监控核心仓库 issue。", + requested_via="pmflow", + response_mode="fast", + ) + ) + + self.assertEqual(planner_status, "fallback") + self.assertEqual(diagnostics["error_code"], "structured_json_not_found") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_sstc_signal_state_machine.py b/backend/tests/test_sstc_signal_state_machine.py new file mode 100644 index 00000000..a0090368 --- /dev/null +++ b/backend/tests/test_sstc_signal_state_machine.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import unittest +from datetime import datetime +from types import SimpleNamespace + +from fastapi import HTTPException + +from app.api.sstc import ( + _build_signal_created_at_range, + _collect_locked_signal_conflicts, + _extract_source_attachments, + _match_signal_filters, + _normalize_signal_status_code, +) +from app.services.feishu_integration import FeishuIntegrationService + + +class SignalStateMachineHelpersTest(unittest.TestCase): + def test_normalize_signal_status_code_prefers_linked_state(self) -> None: + status_code = _normalize_signal_status_code( + linked_state="linked", + signal_status="待处理", + processed=False, + ) + self.assertEqual(status_code, "linked") + + def test_normalize_signal_status_code_uses_closed_for_processed(self) -> None: + status_code = _normalize_signal_status_code( + linked_state=None, + signal_status="", + processed=True, + ) + self.assertEqual(status_code, "closed") + + def test_collect_locked_signal_conflicts(self) -> None: + charters = [ + SimpleNamespace( + id=101, + title="宪章A", + content={"signal_ids": [1, 2]}, + signal_id=None, + ), + SimpleNamespace( + id=102, + title="宪章B", + content={"signal_ids": [3]}, + signal_id=5, + ), + ] + conflict_ids, conflict_map = _collect_locked_signal_conflicts( + charters=charters, # type: ignore[arg-type] + candidate_signal_ids=[2, 5, 9], + ) + self.assertEqual(conflict_ids, [2, 5]) + self.assertEqual(conflict_map[2][0]["charter_id"], 101) + self.assertEqual(conflict_map[5][0]["charter_id"], 102) + + def test_build_signal_created_at_range_rejects_invalid_order(self) -> None: + with self.assertRaises(HTTPException): + _build_signal_created_at_range( + created_from="2026-03-11", + created_to="2026-03-10", + ) + + def test_match_signal_filters_keyword_and_status(self) -> None: + row = { + "id": "88", + "title": "新增飞书信号源", + "content": "支持附件同步", + "proposer_name": "老板", + "charter_name": "信号治理宪章", + "source": "Feishu", + "priority": "P1", + "status_code": "pending", + } + matched = _match_signal_filters( + row=row, + status_filter="pending", + keyword="附件", + source="Feishu", + priority="p1", + created_from_utc=datetime(2026, 3, 1, 0, 0, 0), + created_to_utc=datetime(2026, 3, 20, 0, 0, 0), + created_at_dt=datetime(2026, 3, 10, 12, 0, 0), + ) + self.assertTrue(matched) + + def test_extract_source_attachments_normalizes_and_deduplicates(self) -> None: + attachments = _extract_source_attachments( + { + "attachments": [ + {"attachment_type": "image", "image_key": "img_a", "file_name": "截图A"}, + {"attachment_type": "img", "image_key": "img_a", "file_name": "截图A重复"}, + {"attachment_type": "file", "file_key": "file_b", "file_name": "需求文档.pdf"}, + ] + } + ) + self.assertEqual(len(attachments), 2) + self.assertEqual(attachments[0]["attachment_type"], "image") + self.assertEqual(attachments[1]["file_name"], "需求文档.pdf") + + +class FeishuMessagePayloadParserTest(unittest.TestCase): + def setUp(self) -> None: + self.service = FeishuIntegrationService(db=None) # type: ignore[arg-type] + + def test_extract_message_payload_for_image_message(self) -> None: + payload = self.service._extract_message_payload( + { + "message": { + "message_type": "image", + "content": '{"image_key":"img_001"}', + } + } + ) + self.assertEqual(payload["message_type"], "image") + self.assertEqual(len(payload["attachments"]), 1) + self.assertIn("附件消息", str(payload["text"] or "")) + + def test_extract_message_payload_for_file_message(self) -> None: + payload = self.service._extract_message_payload( + { + "message": { + "message_type": "file", + "content": '{"file_key":"file_001","file_name":"立项材料.docx","file_size":1024}', + } + } + ) + self.assertEqual(len(payload["attachments"]), 1) + attachment = payload["attachments"][0] + self.assertEqual(attachment["attachment_type"], "file") + self.assertEqual(attachment["file_name"], "立项材料.docx") + + def test_extract_message_payload_for_post_with_text_and_image(self) -> None: + payload = self.service._extract_message_payload( + { + "message": { + "message_type": "post", + "content": { + "post": { + "zh_cn": { + "content": [ + [ + {"tag": "text", "text": "请评估这张图的风险点"}, + {"tag": "img", "image_key": "img_post_1"}, + ] + ] + } + } + }, + } + } + ) + self.assertEqual(payload["message_type"], "post") + self.assertEqual(len(payload["attachments"]), 1) + self.assertIn("请评估这张图的风险点", str(payload["text"] or "")) + + +if __name__ == "__main__": + unittest.main() + diff --git a/backend/tests/test_task_persistence.py b/backend/tests/test_task_persistence.py new file mode 100644 index 00000000..54eaf05c --- /dev/null +++ b/backend/tests/test_task_persistence.py @@ -0,0 +1,80 @@ +import unittest + +from app.models import SkillTag +from app.services.task_persistence import ( + build_task_create_draft, + resolve_skill_tag_ids_by_names, +) + + +class _ScalarRows: + def __init__(self, rows: list[object]) -> None: + self._rows = rows + + def scalars(self): + return self + + def all(self): + return self._rows + + +class _FakeDB: + def __init__(self, rows: list[object]) -> None: + self._rows = rows + + async def execute(self, *_args, **_kwargs): + return _ScalarRows(self._rows) + + +class TaskPersistenceTest(unittest.IsolatedAsyncioTestCase): + def test_build_task_create_draft_normalizes_fields(self) -> None: + draft = build_task_create_draft( + title=" 补齐统一通知入口 ", + required_skill_tag_ids=["1", 2, "2"], + priority="P0", + status="pending", + estimated_hours="4.5", + due_date="2026-03-07T10:00:00Z", + ) + + self.assertEqual(draft.title, "补齐统一通知入口") + self.assertEqual(draft.required_skill_tag_ids, [1, 2]) + self.assertEqual(draft.priority, "high") + self.assertEqual(draft.status, "todo") + self.assertEqual(draft.estimated_hours, 4.5) + self.assertIsNotNone(draft.due_date) + self.assertIsNone(draft.due_date.tzinfo) + + async def test_resolve_skill_tag_ids_by_names_preserves_unknowns_for_audit(self) -> None: + db = _FakeDB( + [ + SkillTag(id=11, tenant_id=1, name="系统设计", slug="system-design", category="skill"), + SkillTag(id=12, tenant_id=1, name="开发", slug="development", category="skill"), + ] + ) + + resolution = await resolve_skill_tag_ids_by_names( + db, + tenant_id=1, + skill_names=["系统设计", "开发", "未知技能"], + ) + + self.assertEqual(resolution.matched_ids, [11, 12]) + self.assertEqual(resolution.unmatched_names, ["未知技能"]) + self.assertEqual( + resolution.matched_items, + [ + { + "input_name": "系统设计", + "skill_tag_id": 11, + "skill_tag_name": "系统设计", + "skill_tag_slug": "system-design", + }, + { + "input_name": "开发", + "skill_tag_id": 12, + "skill_tag_name": "开发", + "skill_tag_slug": "development", + }, + ], + ) diff --git a/devops/tunnel/setup.sh b/devops/tunnel/setup.sh index 1d72c56c..545746ce 100755 --- a/devops/tunnel/setup.sh +++ b/devops/tunnel/setup.sh @@ -14,11 +14,12 @@ if ! command -v cloudflared &> /dev/null; then fi # Ask for configuration -read -p "Enter your domain name (e.g., dev.pmflow.com): " DOMAIN_NAME -read -p "Enter a name for your tunnel (e.g., pmflow-dev): " TUNNEL_NAME +read -p "Enter your Frontend domain (e.g., pmflow.com): " FRONTEND_DOMAIN +read -p "Enter your Backend domain (e.g., api.pmflow.com): " BACKEND_DOMAIN +read -p "Enter a name for your tunnel (e.g., pmflow-tunnel): " TUNNEL_NAME -if [ -z "$DOMAIN_NAME" ] || [ -z "$TUNNEL_NAME" ]; then - echo "❌ Domain name and tunnel name are required." +if [ -z "$FRONTEND_DOMAIN" ] || [ -z "$BACKEND_DOMAIN" ] || [ -z "$TUNNEL_NAME" ]; then + echo "❌ Frontend domain, Backend domain, and Tunnel name are required." exit 1 fi @@ -38,8 +39,9 @@ else fi echo "Routing DNS..." -# Route DNS -cloudflared tunnel route dns "$TUNNEL_NAME" "$DOMAIN_NAME" +# Route DNS for both domains +cloudflared tunnel route dns "$TUNNEL_NAME" "$FRONTEND_DOMAIN" +cloudflared tunnel route dns "$TUNNEL_NAME" "$BACKEND_DOMAIN" # Get Tunnel ID TUNNEL_ID=$(cloudflared tunnel list | grep "$TUNNEL_NAME" | awk '{print $1}') @@ -51,13 +53,16 @@ fi echo "Generating config file..." # Create config.yml +mkdir -p devops/tunnel cat > devops/tunnel/config.yml <> "$ENV_FILE" fi else - echo "VITE_APP_DOMAIN=$DOMAIN_NAME" >> "$ENV_FILE" + sed -i "s|VITE_APP_DOMAIN=.*|VITE_APP_DOMAIN=$FRONTEND_DOMAIN|" "$ENV_FILE" + if grep -q "VITE_API_BASE_URL=" "$ENV_FILE"; then + sed -i "s|VITE_API_BASE_URL=.*|VITE_API_BASE_URL=https://$BACKEND_DOMAIN|" "$ENV_FILE" + else + echo "VITE_API_BASE_URL=https://$BACKEND_DOMAIN" >> "$ENV_FILE" + fi fi -echo "✅ Updated $ENV_FILE with VITE_APP_DOMAIN=$DOMAIN_NAME" +if ! grep -q "VITE_APP_DOMAIN=" "$ENV_FILE"; then + echo "VITE_APP_DOMAIN=$FRONTEND_DOMAIN" >> "$ENV_FILE" +fi + +echo "✅ Updated $ENV_FILE with:" +echo " VITE_APP_DOMAIN=$FRONTEND_DOMAIN" +echo " VITE_API_BASE_URL=https://$BACKEND_DOMAIN" echo "🎉 Setup complete! You can now run the tunnel with:" -echo " cloudflared tunnel run --config devops/tunnel/config.yml $TUNNEL_NAME" +echo " ./devops/tunnel/start.sh" + diff --git a/devops/tunnel/start-quick.sh b/devops/tunnel/start-quick.sh index 40920bb5..ef2177ae 100755 --- a/devops/tunnel/start-quick.sh +++ b/devops/tunnel/start-quick.sh @@ -10,5 +10,58 @@ echo "⚠️ For a fixed custom domain, you must PURCHASE a domain and configur # Kill existing tunnels pkill -f "cloudflared tunnel" >/dev/null 2>&1 || true -# Start tunnel pointing to frontend -cloudflared tunnel --url http://localhost:5174 +echo "🌐 Starting Frontend Tunnel (port 5174)..." +cloudflared tunnel --url http://localhost:5174 > logs/tunnel-frontend.log 2>&1 & +PID_FRONT=$! + +echo "🌐 Starting Backend Tunnel (port 8000)..." +cloudflared tunnel --url http://localhost:8000 > logs/tunnel-backend.log 2>&1 & +PID_BACK=$! + +echo "⏳ Waiting for tunnels to initialize..." +sleep 5 + +URL_FRONT=$(grep -o 'https://.*\.trycloudflare.com' logs/tunnel-frontend.log | head -1) +URL_BACK=$(grep -o 'https://.*\.trycloudflare.com' logs/tunnel-backend.log | head -1) + +check_url() { + local url="$1" + local max_retry="$2" + local retry=1 + while [ "$retry" -le "$max_retry" ]; do + if curl -kfsS --max-time 8 "$url" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + retry=$((retry + 1)) + done + return 1 +} + +if [ -n "$URL_FRONT" ] && [ -n "$URL_BACK" ]; then + FRONT_OK=false + BACK_OK=false + if check_url "${URL_FRONT}/login" 25 || check_url "${URL_FRONT}" 25; then + FRONT_OK=true + fi + if check_url "${URL_BACK}/healthz" 25; then + BACK_OK=true + fi + + echo "" + if [ "$FRONT_OK" = true ] && [ "$BACK_OK" = true ]; then + echo "✅ Tunnels Started and reachable!" + else + echo "⚠️ Tunnels started, but health check not fully ready yet. Retry in 10-30s." + fi + echo "----------------------------------------" + echo "Frontend: $URL_FRONT" + echo "Backend: $URL_BACK" + echo "----------------------------------------" + echo "ℹ️ Frontend tunnel can call backend via /api proxy; no need to set VITE_API_BASE_URL." + echo "----------------------------------------" +else + echo "❌ Failed to get tunnel URLs. Check logs/tunnel-*.log" +fi + +wait $PID_FRONT $PID_BACK diff --git a/devops/tunnel/start.sh b/devops/tunnel/start.sh index 6c5f95ef..51eb4cbc 100755 --- a/devops/tunnel/start.sh +++ b/devops/tunnel/start.sh @@ -9,23 +9,34 @@ echo "🚀 Checking Cloudflare Tunnel configuration..." # Check if config.yml exists if [ ! -f "$CONFIG_FILE" ]; then - echo "⚠️ Configuration not found. Starting setup..." - if [ -f "$SETUP_SCRIPT" ]; then - # Run setup script and check its exit code - if ! ./$SETUP_SCRIPT; then - echo "❌ Setup failed or was cancelled." + echo "⚠️ Named Tunnel Configuration not found." + echo " If you have a domain, you can set up a stable tunnel." + echo " If not, you can use Quick Tunnels (random URLs)." + echo "" + read -p "Do you have a domain and want to set up a named tunnel? (y/N): " SETUP_CHOICE + + if [[ "$SETUP_CHOICE" =~ ^[Yy]$ ]]; then + if [ -f "$SETUP_SCRIPT" ]; then + # Run setup script and check its exit code + if ! bash "$SETUP_SCRIPT"; then + echo "❌ Setup failed or was cancelled." + exit 1 + fi + else + echo "❌ Setup script not found at $SETUP_SCRIPT" exit 1 fi else - echo "❌ Setup script not found at $SETUP_SCRIPT" - exit 1 + echo "🔄 Falling back to Quick Tunnels..." + bash devops/tunnel/start-quick.sh + exit 0 fi fi # Re-check config after potential setup if [ -f "$CONFIG_FILE" ]; then echo "✅ Configuration found." - echo "🌐 Starting tunnel..." + echo "🌐 Starting tunnel for Frontend and Backend..." # Run the tunnel using the config file cloudflared tunnel run --config "$CONFIG_FILE" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..b50dfde0 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,119 @@ +services: + db: + image: ankane/pgvector:latest + container_name: pmflow-db + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-pmflow} + POSTGRES_USER: ${POSTGRES_USER:-pmflow} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-pmflow_pass} + ports: + - "${DB_PORT:-5435}:5432" + volumes: + - pg_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-pmflow} -d ${POSTGRES_DB:-pmflow}"] + interval: 5s + timeout: 5s + retries: 20 + + redis: + image: redis:7-alpine + container_name: pmflow-redis + restart: unless-stopped + command: + [ + "redis-server", + "--appendonly", + "yes", + "--requirepass", + "${REDIS_PASSWORD:-pmflow_redis_pass}", + "--maxmemory", + "512mb", + "--maxmemory-policy", + "allkeys-lru" + ] + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD-SHELL", "redis-cli -a ${REDIS_PASSWORD:-pmflow_redis_pass} ping | grep PONG"] + interval: 5s + timeout: 3s + retries: 20 + + backend: + build: + context: . + dockerfile: backend/Dockerfile + container_name: pmflow-backend + restart: unless-stopped + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + environment: + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-pmflow}:${POSTGRES_PASSWORD:-pmflow_pass}@db:5432/${POSTGRES_DB:-pmflow} + REDIS_URL: redis://:${REDIS_PASSWORD:-pmflow_redis_pass}@redis:6379/0 + CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:5174,http://127.0.0.1:5174} + LLM_PROVIDER: ${LLM_PROVIDER:-openai} + LLM_FALLBACK_PROVIDER: ${LLM_FALLBACK_PROVIDER:-ollama} + OPENAI_BASE_URL: ${OPENAI_BASE_URL:-http://openclaw:18789/v1} + OPENCLAW_ROUTING_ENABLED: ${OPENCLAW_ROUTING_ENABLED:-true} + OPENCLAW_ROUTING_RULES_PATH: ${OPENCLAW_ROUTING_RULES_PATH:-/app/backend/.openclaw/pmflow-routing-rules.json} + LLM_MIXED_ROUTING_ENABLED: ${LLM_MIXED_ROUTING_ENABLED:-false} + LLM_LOW_TO_HIGH_ESCALATION_ENABLED: ${LLM_LOW_TO_HIGH_ESCALATION_ENABLED:-true} + LLM_ROUTING_LOW_PROVIDER: ${LLM_ROUTING_LOW_PROVIDER:-openai} + LLM_ROUTING_LOW_MODEL: ${LLM_ROUTING_LOW_MODEL:-zai/glm-4-flash} + LLM_ROUTING_HIGH_PROVIDER: ${LLM_ROUTING_HIGH_PROVIDER:-openai} + LLM_ROUTING_HIGH_MODEL: ${LLM_ROUTING_HIGH_MODEL:-minimax/MiniMax-M2.5} + LLM_ROUTING_SAFETY_PROVIDER: ${LLM_ROUTING_SAFETY_PROVIDER:-openai} + LLM_ROUTING_SAFETY_MODEL: ${LLM_ROUTING_SAFETY_MODEL:-deepseek/deepseek-chat} + LLM_DIRECT_EMERGENCY_ENABLED: ${LLM_DIRECT_EMERGENCY_ENABLED:-false} + LLM_REQUEST_TIMEOUT: ${LLM_REQUEST_TIMEOUT:-90} + AUTO_CREATE_TABLES: "true" + GLM_API_KEY: ${GLM_API_KEY:-} + GLM_BASE_URL: ${GLM_BASE_URL:-https://open.bigmodel.cn/api/paas/v4} + GLM_MODEL: ${GLM_MODEL:-glm-4-flash} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-} + OLLAMA_HOST: ${OLLAMA_HOST:-http://host.docker.internal:11434} + OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434} + OLLAMA_MODEL: ${OLLAMA_MODEL:-llama3:latest} + PYTHONUNBUFFERED: "1" + command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload", "--reload-dir", "app"] + volumes: + - ./backend:/app/backend + - ./logs:/app/logs + ports: + - "${BACKEND_PORT:-8000}:8000" + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost:8000/healthz >/dev/null || exit 1"] + interval: 10s + timeout: 5s + retries: 15 + + frontend: + image: node:20-alpine + container_name: pmflow-frontend + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + working_dir: /app + environment: + CHOKIDAR_USEPOLLING: "true" + VITE_DEV_API_PROXY_TARGET: "http://backend:8000" + command: ["/bin/sh", "-c", "npm ci && npm run dev -- --host 0.0.0.0 --port 5174"] + volumes: + - ./frontend:/app + - frontend_node_modules:/app/node_modules + ports: + - "${FRONTEND_PORT:-5174}:5174" + +volumes: + pg_data: + redis_data: + frontend_node_modules: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..2520e8eb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,109 @@ +services: + db: + image: ankane/pgvector:latest + container_name: pmflow-db + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-pmflow} + POSTGRES_USER: ${POSTGRES_USER:-pmflow} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-pmflow_pass} + ports: + - "${DB_PORT:-5435}:5432" + volumes: + - pg_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-pmflow} -d ${POSTGRES_DB:-pmflow}"] + interval: 5s + timeout: 5s + retries: 20 + + redis: + image: redis:7-alpine + container_name: pmflow-redis + restart: unless-stopped + command: + [ + "redis-server", + "--appendonly", + "yes", + "--requirepass", + "${REDIS_PASSWORD:-pmflow_redis_pass}", + "--maxmemory", + "512mb", + "--maxmemory-policy", + "allkeys-lru" + ] + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD-SHELL", "redis-cli -a ${REDIS_PASSWORD:-pmflow_redis_pass} ping | grep PONG"] + interval: 5s + timeout: 3s + retries: 20 + + backend: + build: + context: . + dockerfile: backend/Dockerfile + container_name: pmflow-backend + restart: unless-stopped + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + environment: + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-pmflow}:${POSTGRES_PASSWORD:-pmflow_pass}@db:5432/${POSTGRES_DB:-pmflow} + REDIS_URL: redis://:${REDIS_PASSWORD:-pmflow_redis_pass}@redis:6379/0 + CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:5174,http://127.0.0.1:5174} + LLM_PROVIDER: ${LLM_PROVIDER:-openai} + LLM_FALLBACK_PROVIDER: ${LLM_FALLBACK_PROVIDER:-ollama} + OPENAI_BASE_URL: ${OPENAI_BASE_URL:-http://openclaw:18789/v1} + OPENCLAW_ROUTING_ENABLED: ${OPENCLAW_ROUTING_ENABLED:-true} + OPENCLAW_ROUTING_RULES_PATH: ${OPENCLAW_ROUTING_RULES_PATH:-/app/backend/.openclaw/pmflow-routing-rules.json} + LLM_MIXED_ROUTING_ENABLED: ${LLM_MIXED_ROUTING_ENABLED:-false} + LLM_LOW_TO_HIGH_ESCALATION_ENABLED: ${LLM_LOW_TO_HIGH_ESCALATION_ENABLED:-true} + LLM_ROUTING_LOW_PROVIDER: ${LLM_ROUTING_LOW_PROVIDER:-openai} + LLM_ROUTING_LOW_MODEL: ${LLM_ROUTING_LOW_MODEL:-zai/glm-4-flash} + LLM_ROUTING_HIGH_PROVIDER: ${LLM_ROUTING_HIGH_PROVIDER:-openai} + LLM_ROUTING_HIGH_MODEL: ${LLM_ROUTING_HIGH_MODEL:-minimax/MiniMax-M2.5} + LLM_ROUTING_SAFETY_PROVIDER: ${LLM_ROUTING_SAFETY_PROVIDER:-openai} + LLM_ROUTING_SAFETY_MODEL: ${LLM_ROUTING_SAFETY_MODEL:-deepseek/deepseek-chat} + LLM_DIRECT_EMERGENCY_ENABLED: ${LLM_DIRECT_EMERGENCY_ENABLED:-false} + LLM_REQUEST_TIMEOUT: ${LLM_REQUEST_TIMEOUT:-90} + AUTO_CREATE_TABLES: "true" + GLM_API_KEY: ${GLM_API_KEY:-} + GLM_BASE_URL: ${GLM_BASE_URL:-https://open.bigmodel.cn/api/paas/v4} + GLM_MODEL: ${GLM_MODEL:-glm-4-flash} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-} + OLLAMA_HOST: ${OLLAMA_HOST:-http://host.docker.internal:11434} + OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434} + OLLAMA_MODEL: ${OLLAMA_MODEL:-llama3:latest} + ports: + - "${BACKEND_PORT:-8000}:8000" + volumes: + - ./logs:/app/logs + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost:8000/healthz >/dev/null || exit 1"] + interval: 10s + timeout: 5s + retries: 15 + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: pmflow-frontend + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + ports: + - "${FRONTEND_PORT:-5174}:80" + +volumes: + pg_data: + redis_data: diff --git a/docs/FEISHU_AI_FLOW_CURRENT.md b/docs/FEISHU_AI_FLOW_CURRENT.md new file mode 100644 index 00000000..5b9722bb --- /dev/null +++ b/docs/FEISHU_AI_FLOW_CURRENT.md @@ -0,0 +1,246 @@ +# PMFlow 当前飞书接 AI 流程说明 + +更新时间:2026-03-10 + +## 1. 结论(当前接入方式) + +- 当前生产链路是 **长连接(long_connection)**,不是 webhook。 +- 真实入口链路:`FeishuLongConnectionManager -> FeishuIntegrationService -> ProactiveSignalCaptureService -> AIPMOKernelService`。 +- 回消息统一走 **Outbox(feishu_reply_outbox)**,支持重试与补偿发送。 +- `FeishuSignalPipelineService` 仍在代码中,但当前主链路未直接调用,主要用于历史/评测链路。 + +## 2. 端到端时序 + +```mermaid +sequenceDiagram + participant U as Feishu User + participant F as Feishu Open Platform + participant LC as FeishuLongConnectionManager + participant FI as FeishuIntegrationService + participant SC as ProactiveSignalCaptureService + participant K as AIPMOKernelService + participant DB as DB + participant OB as FeishuReplyOutbox + participant API as Feishu HTTP API + + U->>F: 群消息 / @机器人 + F-->>LC: long connection event (im.message.receive_v1) + LC->>FI: handle_message_event(event) + FI->>SC: capture_from_feishu(...) + SC->>K: process_feishu_message(...) + K->>DB: ingest_events/work_items/decision/plan/reply_receipts/signal + K-->>SC: capture_result (status/classification/direct_reply_text) + SC-->>FI: pipeline_result + FI->>OB: enqueue reply (pending) + OB->>API: send_message_to_group + API-->>OB: sent / retrying / failed +``` + +## 3. 详细流程 + +### 3.1 配置与启动 + +1. 前端配置页保存 `app_id/app_secret` 到 `POST /feishu/config`。 +2. 后端写入 `integrations(type=feishu)`,并调用长连接管理器 `restart()`。 +3. 应用启动时(`main.py` lifespan): +- 初始化并启动 `FeishuLongConnectionManager`。 +- 启动 `feishu_outbox_drain_loop`,持续补偿发送 outbox。 + +补充:长连接重试策略来自环境变量: +- `FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS`(默认 1) +- `FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS`(最大 3) + +### 3.2 飞书事件接收(长连接) + +1. 管理器线程内创建 `lark_oapi ws.Client`。 +2. 仅业务事件 `message.receive_v1 / im.message.receive_v1` 进入主队列。 +3. Worker 从队列取事件,调用 `FeishuIntegrationService.handle_message_event()`。 + +### 3.3 消息解析与预处理 + +在 `FeishuIntegrationService._handle_message_receive`: + +1. 忽略机器人/应用发送者(`sender_type in {app, bot}`)。 +2. 解析消息内容(支持 `text/post/image/file/media`),附件会归一化。 +3. 记录 ingress trace 到 `ai_processing_logs`。 +4. 无文本但被 @ 时,会回一条“请补充文字需求”的提示。 + +### 3.4 AI PMO 决策主链路 + +`ProactiveSignalCaptureService.capture_from_feishu` 会调用 `AIPMOKernelService.process_feishu_message`: + +1. 清洗文本,先过滤空消息/纯噪音。 +2. 生成 `source_message_id`,并写 `ingest_events` 做幂等去重。 +3. 创建 `work_items`(状态机起点 `received`)。 +4. 构建 world model(项目、预算、产能、技能、章程、任务、风险)。 +5. Intent 识别: +- `query.read_only` +- `project.create` +- `signal.create` +- `signal_source.onboard` +- `followup.confirm / followup.continue` +- `ignore` +6. 三脑决策与仿真:战略、战术、执行前仿真 + policy gate。 +7. 根据策略: +- 可自动执行:进入 typed executor。 +- 不可自动执行:进入 `waiting_manual`,并推送站内待办通知。 +8. 生成唯一回执(`reply_receipts`),避免同消息同 phase 重复回复。 + +### 3.5 Typed Executor 分支 + +- `query.read_only`:调用 `AdaptiveRAGService.answer_question`。 +- `project.create`:调用 `AIStewardService.create_project_from_natural_language`。 +- `signal.create`:直接创建 `signals`(可带 `project_id`)。 +- `signal_source.onboard`:创建信号后,走 `SignalSourceDomainWorkflow`(底层编排 `SignalSourceOrchestrationService` / OpenClaw)。 +- `followup.*`:尝试恢复最近待确认工作项继续执行。 + +### 3.6 回复生成与发送 + +`FeishuIntegrationService` 会按 scene 路由回复: + +- `execute`:优先固定执行回执模板(避免“教程式”回复)。 +- `qa`: + - 若 `direct_reply_required=true`,直接回 `direct_reply_text`; + - 否则仅在明确 `@PMFlow` 时触发人设化回复。 +- `error`:固定错误模板。 + +发送路径: + +1. 先入 `feishu_reply_outbox(status=pending)`。 +2. 异步立即派发一次;失败则指数退避重试。 +3. 后台 drain loop 持续扫 `pending/retrying` 做补偿发送。 + +## 4. 可靠性与幂等设计 + +关键幂等点: + +- `ingest_events`:`(tenant_id, channel, event_key)` 唯一。 +- `work_items`:`(tenant_id, source_system, source_message_id)` 唯一。 +- `reply_receipts`:`(tenant_id, source_system, source_message_id, receipt_phase)` 唯一。 + +关键可靠性点: + +- 长连接断线自动重连(最多 3 次,指数退避)。 +- Outbox 发送失败自动重试 + 后台补偿。 +- 全链路 `ai_processing_logs` 留痕,便于回放排障。 + +## 5. 关键数据表(当前链路涉及) + +- `integrations`:飞书配置。 +- `ingest_events`:入站事件幂等账本。 +- `work_items`:AI PMO 工作项与状态机。 +- `decision_records` / `plan_bundles`:决策与计划快照。 +- `reply_receipts`:回执唯一性控制。 +- `feishu_reply_outbox`:发送队列与重试。 +- `signals` / `project_signal_links`:落库信号与项目关联。 +- `ai_processing_logs`:处理过程日志。 + +## 6. 运维排查入口 + +- `GET /feishu/config`:是否配置、当前模式、长连接状态。 +- `POST /feishu/test-connection`:token + 长连接线程健康检查。 +- `GET /feishu/long-connection/status`:运行态/错误码/重试窗口。 +- `POST /feishu/long-connection/restart`:手动重启长连接。 + +常见失败定位: + +- `auth_failed`:`app_id/app_secret` 或应用权限问题。 +- `network_error`:后端到飞书网络/TLS 问题。 +- `sdk_missing`:缺少 `lark-oapi` 依赖。 +- outbox `failed`:下游发送失败,查看 `last_error`。 + +## 7. 现状说明(避免误判) + +- `FeishuSignalPipelineService` 与 `feishu_items` 仍存在,但当前主入口未直接调用该服务。 +- 现在线上主流程以 `AIPMOKernelService` 为统一决策中枢。 +- 如果后续要彻底收敛,可考虑把历史队列链路标记为 deprecated 并逐步下线。 + +### 7.1 这套历史链路现在“还有什么用” + +当前仍有 4 类明确用途: + +1. 回归评测与回放:`backend/evals/feishu_replay_eval.py` 仍直接驱动 `FeishuSignalPipelineService`,用于离线评估路由/回执一致性。 +2. 单元测试基座:多处测试直接依赖其工具函数与判定逻辑(例如 `backend/tests/test_inbox_queue_view.py`、`backend/tests/test_feishu_routing_receipts.py`)。 +3. 历史数据兼容:`ai_logs`、`project_knowledge_graph` 仍会读取 `feishu_items` 做项目过滤和增量图谱事件。 +4. 数据迁移与幂等治理:Alembic 与启动自检里仍有 `feishu_items` 去重/唯一索引修复逻辑,保障旧数据不炸库。 + +一句话:它对“新消息在线处理”不是主链路,但对“测试、历史数据、迁移兼容”仍有实际价值。 + +## 8. 阶段与 Agent(服务)映射 + +> 你问的“走到哪用哪个 agent”,按当前代码可对应为下表。 + +| 阶段 | Agent/服务 | 作用 | +| --- | --- | --- | +| 事件接入 | `FeishuLongConnectionManager` | 与飞书建立长连接,接收 `im.message.receive_v1` 事件并投递队列 | +| 渠道适配 | `FeishuIntegrationService` | 解析飞书消息、提取文本/附件、场景分流、触发回复 | +| 消息捕获 | `ProactiveSignalCaptureService` | 预清洗与噪音过滤,统一进入 AI PMO 内核 | +| 意图解析 | `IntentResolver`(内核组件) | 把文本收口为 `query/project.create/signal.create/...` | +| 组合决策 | `DecisionEngine` + `StrategicBrain` + `TacticalBrain` + `SimulationEngine` + `ExecutionPolicyBrain` | 做战略/战术/仿真/自动执行边界判定 | +| 执行落地 | `TypedExecutor` | 按 intent 进行 `RAG问答/立项/信号入库/信号源接入` | +| 结果复核 | `AIPMOReviewService` | 校验执行后状态与风险,生成 review summary | +| 回复渲染 | `ReplyRenderer` | 统一生成 `action/pending/error` 回执文本 | +| 回复幂等 | `ReplyReceipt`(账本) | 保证同消息同 phase 只发一次业务回执 | +| 消息投递 | `FeishuReplyOutbox` + dispatch worker | 发送到飞书并做失败重试/补偿 | +| 知识沉淀 | `AdaptiveRAGService.ingest_feishu_message_async` | 异步把飞书内容纳入检索知识域 | + +### 8.1 具体 Agent 名称(字符串级) + +下面是你要的“具体名称”。 + +1. 角色 Agent(`role_key`) +- `pm_dispatcher`(默认 active,角色名:产品经理(任务分派)) +- `tech_architect`(角色名:技术架构师) +- `delivery_coach`(角色名:交付推进官) + +2. 主链路实际会命中的 LLM Agent 名(`function_name`) +- `feishu_humanized_reply`:飞书被 @ 且需要人设化回复时使用。 +- `signal_source_onboarding_plan`:识别到“新增信号源接入”时的规划器。 +- `signal_source_ai_worker_execution`:信号源接入子 worker 执行器。 +- `openclaw_command_dispatch`:OpenClaw 指挥层把自然语言转操作计划。 +- `rag_intent_classification`:RAG 问答意图分类(query/mixed/statement)。 +- `rag_db_project_semantic_retrieval`:RAG 的项目语义检索快路由。 + +3. 历史链路 Agent 名(`FeishuSignalPipelineService`,非当前主入口) +- `feishu_level_classification` +- `feishu_signal_extraction` +- `feishu_project_prefill` +- `feishu_task_orchestration` +- `feishu_project_signal_classification` +- `feishu_new_project_brief` + +说明:上面第 3 组名称仍在评测/历史链路中使用,但实时主入口不直接走这条链路。 + +### 8.2 `glm` / `minimax` 在哪一步、做什么 + +> 依据:`backend/.openclaw/pmflow-routing-rules.json`(当前默认 `OPENCLAW_ROUTING_ENABLED=true`,`LLM_MIXED_ROUTING_ENABLED=false`)。 + +#### A. 当前飞书主链路(实时) + +| 步骤 | function_name | 默认命中模型 | 具体作用 | +| --- | --- | --- | --- | +| 飞书 @ 人设回复 | `feishu_humanized_reply` | `zai/glm-4-flash` | 把执行结果转成短中文回复,控制语气与可执行建议 | +| RAG 意图判定 | `rag_intent_classification` | `zai/glm-4-flash` | 把用户输入判定为 question / mixed / statement | +| RAG 项目语义检索 | `rag_db_project_semantic_retrieval` | `minimax/MiniMax-M2.5` | 从项目索引中做语义命中与回答草案 | +| RAG 最终答案生成(normal/deep) | `adaptive_rag_answer_normal` / `adaptive_rag_answer_deep` | `minimax/MiniMax-M2.5` | 生成最终回答(结论+证据+不确定项) | + +#### B. 主链路中的“信号源接入子流程”(命中时) + +| 步骤 | function_name | 默认命中模型 | 具体作用 | +| --- | --- | --- | --- | +| 接入规划器 | `signal_source_onboarding_plan` | **active 引擎**(未在规则中显式绑定) | 判断是否应接入、产出 source 元数据与 worker 角色 | +| OpenClaw 指挥计划 | `openclaw_command_dispatch` | **active 引擎**(未在规则中显式绑定) | 把自然语言转 typed operations | +| 子 worker 执行 | `signal_source_ai_worker_execution` | **active 引擎**(未在规则中显式绑定) | 各角色生成 deliverables/risks/next_actions | + +说明:这 3 个函数名不在当前 OpenClaw 规则文件中,因此在默认配置下走 active 引擎;若 active 仍是默认 OpenClaw low 模型,则通常表现为 `glm`。 + +#### C. 历史/评测链路(`FeishuSignalPipelineService`) + +| 步骤 | function_name | 默认命中模型 | 具体作用 | +| --- | --- | --- | --- | +| 层级判断 | `feishu_level_classification` | `zai/glm-4-flash` | 判定 project-level 还是 signal-level | +| 信号抽取 | `feishu_signal_extraction` | `zai/glm-4-flash` | 抽取标题、类型、优先级、置信度 | +| 项目预填充 | `feishu_project_prefill` | `minimax/MiniMax-M2.5` | 生成建议项目名/描述/owner/timeline | +| 任务编排 | `feishu_task_orchestration` | `minimax/MiniMax-M2.5` | 生成任务拆解与分配建议 | +| 项目信号分类 | `feishu_project_signal_classification` | `minimax/MiniMax-M2.5` | 对新项目/已有项目/非需求做高层分类 | +| 新项目摘要 | `feishu_new_project_brief` | `minimax/MiniMax-M2.5` | 生成项目简报(brief) | diff --git a/docs/PMFLOW_THREE_ENGINE_UNIFIED_PLAN.md b/docs/PMFLOW_THREE_ENGINE_UNIFIED_PLAN.md new file mode 100644 index 00000000..157694b1 --- /dev/null +++ b/docs/PMFLOW_THREE_ENGINE_UNIFIED_PLAN.md @@ -0,0 +1,141 @@ +# PMFlow 三引擎统一方案(v2) + +## 1. 目标 +- 用 3 个引擎达到“质量/成本/稳定性”平衡。 +- 飞书与内置 AI 管家使用同一智能链路,避免 IQ 不一致。 +- 所有系统操作(不止创建需求)由 AI 覆盖,但写库/副作用必须可控可审计。 +- 消除多控制面冲突(PMFlow 配一套、OpenClaw 配一套、规则文件又一套)。 + +## 2. 先把名词说清楚 +当前不是“3 个 agent”,而是: +- 2 个 agent + - PMFlow Orchestrator Agent:流程编排与执行(唯一执行入口)。 + - OpenClaw Reasoning Agent:模型网关与推理。 +- 3 个引擎(模型层) + - Engine-L(Low):低成本/低延迟,处理执行态与轻分类。 + - Engine-H(High):高推理,处理复杂问答/规划。 + - Engine-S(Safety):兜底引擎,处理故障与降级。 + +## 3. 单一控制面原则(必须) +只保留一个“模型真相源(Source of Truth)”: +- 推荐:OpenClaw 作为唯一模型控制面(模型、fallback、配额、可用性)。 +- PMFlow 只做函数路由和执行状态机,不再直连多个云厂商。 + +> 结论:PMFlow 不再同时保存 GLM/OpenAI/DeepSeek 直连 key 参与主路由; +> PMFlow 统一走 OpenClaw 网关。 + +## 4. 三引擎职责分配(推荐) +- Engine-L:`zai/glm-4-flash` + - 场景:执行态分类、信号抽取、简短回执、轻量意图识别。 +- Engine-H:`minimax/MiniMax-M2.5` + - 场景:复杂问答、多约束规划、任务编排、项目方案生成。 +- Engine-S:`deepseek/deepseek-chat`(或你可用的稳定备份模型) + - 场景:H 不可用/超时/限流后的稳定兜底。 + +## 5. 路由矩阵(按 function_name) +### 5.1 走 Engine-L +- `feishu_level_classification` +- `feishu_signal_extraction` +- `rag_intent_classification` +- `classify_intent` +- `feishu_humanized_reply`(仅咨询态;执行态优先模板) + +### 5.2 走 Engine-H +- `adaptive_rag_answer` +- `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` + +### 5.3 走 Engine-S(故障兜底) +- 触发条件:`Engine-L/H` 超时、5xx、限流、空响应、无效结构化输出。 +- 行为:单次兜底重试 + 记录 `routing_decision=fallback_safety_engine`。 + +## 6. “低阶答不上来 -> 高阶接管”硬规则 +已建议启用: +- 对低阶函数结果做“不可答”检测(如“信息不足/无法回答/不知道”)。 +- 命中后自动升级到 High-tier 再请求 1 次。 +- 若 High-tier 也失败,最后走 Safety-tier。 + +优先级链: +`Engine-L -> Engine-H -> Engine-S -> 固定降级模板` + +## 7. 飞书与内置 AI 管家一致性 +两入口统一走同一条链路: +- `RECEIVED -> CLASSIFIED -> ROUTED -> COMMITTED -> REPLIED` +- 执行态:禁自由文本,固定模板。 +- 咨询态:允许文本回答,但同样走统一路由与审计。 + +这样飞书和内置管家不会再出现“一个聪明一个笨”的模型漂移。 + +## 8. 全功能 AI 操作覆盖(不止建需求) +建立 `Operation Catalog`,每个操作都必须有: +- `operation_code` +- `input_schema`(Pydantic strict) +- `rbac_policy` +- `idempotency_key` +- `audit_fields` +- `side_effect_guard` + +覆盖范围: +- 项目:create/update/archive/reopen +- 任务:create/update/assign/prioritize/status_transition/delete +- 人员:role/workload/availability +- 知识库:doc/chunk/link/reindex +- 通知:create/ack/close +- 文件:白名单路径写入(禁止任意路径) + +注意:LLM 只产出结构化意图;真正写库必须由 Tool 层执行。 + +## 9. 推荐配置模板 + +### 9.1 PMFlow `.env`(统一走 OpenClaw) +```env +LLM_PROVIDER=openai +OPENAI_BASE_URL=http://openclaw:18789/v1 +OPENAI_API_KEY= + +OPENCLAW_ROUTING_ENABLED=true +OPENCLAW_ROUTING_RULES_PATH=/app/backend/.openclaw/pmflow-routing-rules.json + +LLM_MIXED_ROUTING_ENABLED=false +LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true +``` + +### 9.2 OpenClaw Agent(main) +- Primary model:`zai/glm-4-flash`(Engine-L) +- Fallbacks:`minimax/MiniMax-M2.5,deepseek/deepseek-chat` + +> 如果你要严格“低->高->安全”,可在 OpenClaw 路由中按函数直接指定模型,fallback 只做同层灾备。 + +### 9.3 PMFlow 路由规则(关键) +- 同一文件统一管理:`backend/.openclaw/pmflow-routing-rules.json` +- 生产发布必须过 lint + replay gate。 + +## 10. 安全边界(必须守) +- 执行态不允许自由文本动作建议驱动写库。 +- 所有写操作必须经过 schema 校验;失败直接模板降级,不猜测。 +- 事务与副作用解耦:先 commit,再 outbox 回复。 +- 每条请求全链路 trace:可追溯“模型选择 -> 操作执行 -> 回执结果”。 + +## 11. 落地步骤(按优先级) +1. 统一控制面:PMFlow 改为只调用 OpenClaw 网关。 +2. 清理直连模型 key 的主路径,仅保留应急开关。 +3. 完整补齐 function_name 路由矩阵(L/H/S)。 +4. 启用低阶失败自动升级高阶(已具备,可保持开启)。 +5. 扩展 Operation Catalog 覆盖全系统操作。 +6. 回放测试扩展到“多操作写库场景”。 + +## 12. 验收标准 +- 飞书与内置管家:同问题同输入,模型决策一致(允许渠道文案差异,不允许能力差异)。 +- 执行态回复无“教程/curl”污染。 +- 回放集通过率 100%,且 `DB + route_status + reply_text` 一致。 +- 任一引擎故障时,系统可自动降级不中断主流程。 diff --git a/docs/docker-server-deploy.md b/docs/docker-server-deploy.md new file mode 100644 index 00000000..365f6c9c --- /dev/null +++ b/docs/docker-server-deploy.md @@ -0,0 +1,47 @@ +# PMFlow Docker Server Deploy Guide + +## 1. Prerequisites + +- Remote host has Docker Engine and Docker Compose plugin. +- Remote host can be accessed by SSH. +- This machine can run `rsync` and `ssh`. +- `docker-compose.yml` and `.env.example` exist in project root. + +## 2. Local one-line deploy + +```bash +bash scripts/deploy_remote_docker.sh +``` + +Examples: + +```bash +bash scripts/deploy_remote_docker.sh 192.168.1.100 root /opt/pmflow +SSH_KEY=~/.ssh/id_rsa bash scripts/deploy_remote_docker.sh 8.8.8.8 ubuntu /srv/pmflow +``` + +## 3. What the script does + +1. Create remote target directory. +2. Sync local project with `rsync --delete` (excludes `.git`, `venv`, `node_modules`, `logs`). +3. Create `.env` from `.env.example` when missing. +4. Run `docker compose build && docker compose up -d`. + +## 4. Daily operations (local) + +```bash +bash scripts/docker_stack.sh start +bash scripts/docker_stack.sh health +bash scripts/docker_stack.sh status +bash scripts/docker_stack.sh logs backend +bash scripts/docker_stack.sh restart +bash scripts/docker_stack.sh stop +``` + +## 5. Remote troubleshoot + +```bash +ssh @ "cd /opt/pmflow && docker compose ps" +ssh @ "cd /opt/pmflow && docker compose logs --tail=200 backend" +ssh @ "cd /opt/pmflow && docker compose down && docker compose up -d" +``` diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 00000000..af9e0ea4 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +.env.local +.env.development diff --git a/frontend/index.html b/frontend/index.html index e7263a07..7e084b14 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,6 +3,8 @@ + + PMFlow diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 0392eeec..02629542 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -7,8 +7,12 @@ server { try_files $uri $uri/ /index.html; } - location /api { - proxy_pass http://backend:8000; + location = /api { + return 301 /api/; + } + + location /api/ { + proxy_pass http://backend:8000/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 00000000..24375575 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 00000000..3f67d55a --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,7 @@ + + + + diff --git a/frontend/src/api/aiLogs.ts b/frontend/src/api/aiLogs.ts new file mode 100644 index 00000000..8dceaa40 --- /dev/null +++ b/frontend/src/api/aiLogs.ts @@ -0,0 +1,89 @@ +import { api } from "@/lib/api"; + +type ApiEnvelope = { code: number; message: string; data: T }; + +function unwrap(raw: ApiEnvelope | T): T { + if (raw && typeof raw === "object" && "data" in (raw as Record)) { + return (raw as ApiEnvelope).data; + } + return raw as T; +} + +export type AIRoutingDecision = + | "auto_create_project" + | "auto_enter_signal" + | "pending_low_confidence" + | "pending_cold_start" + | "pending_uncertain_project" + | "duplicate_filtered" + | "failed"; + +export type AIProcessingStatus = "success" | "failed" | "processing"; + +export interface AIProcessingLog { + id: string; + feishu_message_id?: string | null; + feishu_item_id?: string | null; + raw_message?: string | null; + ai_raw_output?: string | null; + ai_raw_output_json?: unknown; + project_score?: number | null; + signal_confidence?: number | null; + level?: "project" | "signal" | null; + routing_decision?: AIRoutingDecision | null; + routing_reason?: string | null; + reasoning_bullets?: string[]; + processing_status: AIProcessingStatus; + error_message?: string | null; + duration_ms?: number | null; + created_at?: string | null; +} + +export interface AIProcessingLogListPayload { + items: AIProcessingLog[]; + total: number; + limit: number; + offset: number; + has_more: boolean; +} + +export interface AIProcessingLogStats { + date: string; + received_count: number; + success_count: number; + failed_count: number; + duplicate_filtered_count: number; + auto_entered_count: number; + pending_count: number; + processing_count: number; + avg_signal_confidence: number; + avg_project_score: number; +} + +export const aiLogsApi = { + async list(projectId: string, limit = 20, offset = 0): Promise { + const { data } = await api.get>("/ai-logs", { + params: { project_id: Number(projectId), limit, offset }, + }); + return unwrap(data); + }, + + async getStats(projectId: string, date = "today"): Promise { + const { data } = await api.get>("/ai-logs/stats", { + params: { project_id: Number(projectId), date }, + }); + return unwrap(data); + }, + + async getLatest(projectId: string): Promise { + const { data } = await api.get>("/ai-logs/latest", { + params: { project_id: Number(projectId) }, + }); + return unwrap(data); + }, + + async getDetail(logId: string): Promise { + const { data } = await api.get>(`/ai-logs/${logId}`); + return unwrap(data); + }, +}; diff --git a/frontend/src/api/aiPmo.ts b/frontend/src/api/aiPmo.ts new file mode 100644 index 00000000..d2a96a7f --- /dev/null +++ b/frontend/src/api/aiPmo.ts @@ -0,0 +1,168 @@ +import { api } from "@/lib/api"; + +type ApiEnvelope = { code: number; message: string; data: T }; + +function unwrap(raw: ApiEnvelope | T): T { + if (raw && typeof raw === "object" && "data" in (raw as Record)) { + return (raw as ApiEnvelope).data; + } + return raw as T; +} + +export interface AIPMOWorkItem { + id: number; + source_system: string; + source_message_id: string; + state: string; + intent?: string | null; + decision_level?: string | null; + project_id?: number | null; + project_name?: string | null; + signal_id?: number | null; + reply_template_key?: string | null; + title: string; + summary: string; + ai_plan?: string | null; + blocking_reason?: string | null; + next_step_text?: string | null; + stage_text?: string | null; + priority: string; + priority_rank: number; + target_url?: string | null; + review_summary?: Record | null; + execution_result?: Record | null; + created_at?: string | null; + updated_at?: string | null; +} + +export interface AIPMOWorkItemListPayload { + items: AIPMOWorkItem[]; +} + +export interface AIPMODecisionRecordDetail { + id: number; + decision_level?: string | null; + decision_object_type?: string | null; + decision_object_id?: number | null; + rationale?: string | null; + risk_score?: number | null; + budget_impact?: Record | null; + resource_impact?: Record | null; + simulation_result?: Record | null; + policy_gate_hit?: boolean; + policy_gate_code?: string | null; + approval_requirement?: Record | null; + execution_result?: Record | null; + final_outcome?: string | null; +} + +export interface AIPMOPlanBundleDetail { + id: number; + plan_type?: string | null; + summary?: string | null; + payload?: Record | null; +} + +export interface AIPMOReplyReceiptDetail { + id: number; + receipt_phase?: string | null; + template_key?: string | null; + payload?: Record | null; + status?: string | null; +} + +export interface AIPMOWorkItemDetail { + id: number; + source_system: string; + source_message_id: string; + state: string; + intent?: string | null; + decision_level?: string | null; + project_id?: number | null; + signal_id?: number | null; + context_snapshot?: Record | null; + action_bundle?: Record | null; + execution_result?: Record | null; + review_summary?: Record | null; + reply_template_key?: string | null; + decision_records: AIPMODecisionRecordDetail[]; + plan_bundles: AIPMOPlanBundleDetail[]; + reply_receipts: AIPMOReplyReceiptDetail[]; +} + +export interface AIPMOResumeResult { + handled?: boolean; + status?: string; + followup_reply?: string; + target_work_item_id?: number | null; + target_state?: string | null; + target_intent?: string | null; + project_id?: number | null; + signal_id?: number | null; + auto_tasks?: Array>; +} + +export interface AIPMOCadenceSnapshot { + id: number; + scope_type: string; + scope_ref: string; + summary?: Record | null; + payload?: Record | null; + created_at?: string | null; +} + +export interface AIPMOProgramView { + summary: { + program_count: number; + active_project_count: number; + pending_manual_total: number; + blocked_task_total: number; + }; + programs: Array<{ + program_key: string; + program_name: string; + project_count: number; + project_ids: number[]; + blocked_task_count: number; + pending_manual_count: number; + budget_usage_ratio_max: number; + }>; +} + +export const aiPmoApi = { + async listWorkItems(params?: { limit?: number; state?: string; projectId?: number }): Promise { + const { data } = await api.get>("/ai-pmo/work-items", { + params: { + limit: params?.limit ?? 20, + state: params?.state, + project_id: params?.projectId, + }, + }); + return unwrap(data); + }, + + async getWorkItem(workItemId: number): Promise { + const { data } = await api.get>(`/ai-pmo/work-items/${workItemId}`); + return unwrap(data); + }, + + async resumeWorkItem(workItemId: number, body?: { project_id?: number | null }): Promise { + const { data } = await api.post>(`/ai-pmo/work-items/${workItemId}/resume`, body || {}); + return unwrap(data); + }, + + async getLatestCadence(params: { cadenceType: string; projectId?: number }): Promise { + const { data } = await api.get>("/ai-pmo/cadence/latest", { + params: { + cadence_type: params.cadenceType, + project_id: params.projectId, + }, + }); + return unwrap(data); + }, + + async getProgramView(): Promise { + const { data } = await api.get>("/ai-pmo/portfolio/program-view"); + return unwrap(data); + }, +}; diff --git a/frontend/src/api/aiSteward.ts b/frontend/src/api/aiSteward.ts new file mode 100644 index 00000000..c18b72c5 --- /dev/null +++ b/frontend/src/api/aiSteward.ts @@ -0,0 +1,74 @@ +import { apiRequest } from "./base"; + +type ApiEnvelope = { code: number; message: string; data: T }; + +function unwrap(raw: ApiEnvelope | T): T { + if (raw && typeof raw === "object" && "code" in (raw as ApiEnvelope) && "data" in (raw as ApiEnvelope)) { + return (raw as ApiEnvelope).data; + } + return raw as T; +} + +export interface OpenClawCommandDispatchRequest { + project_id: number; + instruction: string; + dry_run?: boolean; + max_operations?: number; +} + +export interface OpenClawCommandExecutionItem { + index: number; + operation_code: string; + payload: Record; + idempotency_key?: string; + status: "planned" | "applied" | "skipped" | "failed"; + message: string; + entity_type?: string | null; + entity_id?: string | null; + trace_id?: string | null; +} + +export interface OpenClawCommandDispatchResponse { + trace_id: string; + planner_status: string; + project: { + id: string; + title: string; + status: string; + }; + prompt_role: { + role_key: string; + role_name: string; + }; + instruction: string; + dry_run: boolean; + summary: string; + confidence: number; + task_summary: { + total: number; + active: number; + done: number; + blocked: number; + sampled: number; + }; + member_count: number; + planned_operations: number; + execution: { + dry_run: boolean; + requested_count: number; + applied_count: number; + skipped_count: number; + failed_count: number; + results: OpenClawCommandExecutionItem[]; + }; +} + +export const aiStewardApi = { + async dispatchCommand(body: OpenClawCommandDispatchRequest): Promise { + const raw = await apiRequest>("/ai-steward/command/dispatch", { + method: "POST", + body: JSON.stringify(body), + }); + return unwrap(raw); + }, +}; diff --git a/frontend/src/api/base.ts b/frontend/src/api/base.ts index b426e824..ea89c76d 100644 --- a/frontend/src/api/base.ts +++ b/frontend/src/api/base.ts @@ -3,12 +3,31 @@ * 基础 API 客户端 */ -// 与 src/lib/api.ts 对齐:开发环境默认走 /api(由 Vite 代理), -// 仅在显式配置 VITE_API_URL / VITE_API_BASE_URL 时直连后端。 -const API_BASE = - import.meta.env.VITE_API_BASE_URL || - import.meta.env.VITE_API_URL || - (import.meta.env.DEV ? '/api' : 'http://127.0.0.1:8000') +import { getAuthToken } from '@/lib/api' + +function normalizeBaseURL(value?: string): string { + return String(value || '').trim().replace(/\/+$/, '') +} + +function resolveApiBase(): string { + const explicitBaseURL = normalizeBaseURL(import.meta.env.VITE_API_BASE_URL) + if (explicitBaseURL) { + return explicitBaseURL + } + + // VITE_API_URL 仅用于本地开发直连;生产构建统一走同源 /api。 + if (import.meta.env.DEV) { + const explicitApiURL = normalizeBaseURL(import.meta.env.VITE_API_URL) + if (explicitApiURL) { + return explicitApiURL + } + } + + // 与 nginx / Vite 配置对齐,默认依赖同源 /api 反代。 + return '/api' +} + +const API_BASE = resolveApiBase() export interface ApiRequestOptions { method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' @@ -18,11 +37,13 @@ export interface ApiRequestOptions { export async function apiRequest(endpoint: string, options: ApiRequestOptions = { method: 'GET' }): Promise { const url = `${API_BASE}${endpoint}` + const token = getAuthToken() const config: RequestInit = { method: options.method, headers: { 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), ...options.headers, }, } diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index e80bf81e..518126aa 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -11,3 +11,5 @@ export * from './integration' export * from './projects' export * from './modelCapability' export * from './budget' +export * from './recipes' +export * from './aiSteward' diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts new file mode 100644 index 00000000..53476433 --- /dev/null +++ b/frontend/src/api/notifications.ts @@ -0,0 +1,62 @@ +import { api } from "@/lib/api"; + +type ApiEnvelope = { code: number; message: string; data: T }; + +function unwrap(raw: ApiEnvelope | T): T { + if (raw && typeof raw === "object" && "data" in (raw as Record)) { + return (raw as ApiEnvelope).data; + } + return raw as T; +} + +export interface NotificationItem { + id: number; + title: string; + content?: string | null; + category: string; + source?: string | null; + entity_type?: string | null; + entity_id?: number | null; + target_url?: string | null; + kind?: string | null; + priority?: string | null; + status?: string | null; + blocking_reason?: string | null; + recommended_action?: string | null; + dedupe_key?: string | null; + occurrence_count?: number; + payload?: Record | null; + project_id?: number | null; + project_name?: string | null; + is_read: boolean; + read_at?: string | null; + created_at?: string | null; + updated_at?: string | null; +} + +export interface NotificationListPayload { + items: NotificationItem[]; + unread_count: number; +} + +export const notificationsApi = { + async list(params?: { unreadOnly?: boolean; limit?: number }): Promise { + const { data } = await api.get>("/notifications", { + params: { + unread_only: params?.unreadOnly ?? false, + limit: params?.limit ?? 20, + }, + }); + return unwrap(data); + }, + + async markRead(notificationId: number): Promise { + const { data } = await api.post>(`/notifications/${notificationId}/read`); + return unwrap(data); + }, + + async markAllRead(): Promise<{ updated: number }> { + const { data } = await api.post>("/notifications/read-all"); + return unwrap(data); + }, +}; diff --git a/frontend/src/api/projects.ts b/frontend/src/api/projects.ts index 8b1026ed..8c1007d6 100644 --- a/frontend/src/api/projects.ts +++ b/frontend/src/api/projects.ts @@ -32,21 +32,6 @@ export interface Project { updated_at: string } -/** - * 项目信号关联数据结构 - */ -export interface ProjectSignal { - id: string - source: string - content: string - impact_score: number - priority: string - processed: boolean - created_at: string - link_type: string - relevance_score: number -} - /** * 项目信号关联链接数据结构 */ @@ -208,13 +193,6 @@ export const projectsApi = { return apiRequest(`/projects/${projectId}`) }, - /** - * 获取项目关联的所有信号 - */ - async getSignals(projectId: string): Promise { - return apiRequest(`/projects/${projectId}/signals`) - }, - /** * 关联信号到项目 */ diff --git a/frontend/src/api/promptProfiles.ts b/frontend/src/api/promptProfiles.ts new file mode 100644 index 00000000..a9b06474 --- /dev/null +++ b/frontend/src/api/promptProfiles.ts @@ -0,0 +1,55 @@ +import { api } from "@/lib/api"; + +type ApiEnvelope = { code: number; message: string; data: T }; + +function unwrap(raw: ApiEnvelope | T): T { + if (raw && typeof raw === "object" && "data" in (raw as Record)) { + return (raw as ApiEnvelope).data; + } + return raw as T; +} + +export interface PromptRoleProfile { + role_key: string; + role_name: string; + role_description?: string; + system_prompt: string; + enabled: boolean; + updated_at?: string; +} + +export interface PromptProfilesPayload { + version: number; + active_role_key: string; + roles: PromptRoleProfile[]; + updated_at?: string; + tips?: { + active_role_key?: string; + system_prompt?: string; + role_key?: string; + }; +} + +export interface PromptProfilesUpdateRequest { + active_role_key?: string; + roles: PromptRoleProfile[]; +} + +export const promptProfilesApi = { + async get(): Promise { + const { data } = await api.get>("/ai-steward/prompt-profiles"); + return unwrap(data); + }, + + async update(body: PromptProfilesUpdateRequest): Promise { + const { data } = await api.put>("/ai-steward/prompt-profiles", body); + return unwrap(data); + }, + + async getActive(): Promise<{ active_role_key: string; role: PromptRoleProfile | null }> { + const { data } = await api.get>( + "/ai-steward/prompt-profiles/active", + ); + return unwrap(data); + }, +}; diff --git a/frontend/src/api/recipes.ts b/frontend/src/api/recipes.ts new file mode 100644 index 00000000..232e4f7a --- /dev/null +++ b/frontend/src/api/recipes.ts @@ -0,0 +1,176 @@ +/** + * 食谱 API + */ + +import { apiRequest } from './base' + +// ============================================================================ +// 类型定义 +// ============================================================================ + +export interface Recipe { + id: number + name: string + description?: string + image_url?: string + // 营养信息 + calories?: number + protein?: number + fat?: number + carbs?: number + fiber?: number + // AI 识别相关 + ai_recognition_confidence?: number + ai_analysis_result?: any + // 分类 + category?: string + cuisine?: string + // 状态 + status: 'draft' | 'published' | 'archived' + // 用户标注 + user_notes?: string + is_favorite: boolean + // 时间戳 + created_at: string + updated_at: string +} + +export interface RecipeCreate { + name: string + description?: string + image_url?: string + category?: string + cuisine?: string + user_notes?: string +} + +export interface RecipeUpdate { + name?: string + description?: string + image_url?: string + category?: string + cuisine?: string + status?: 'draft' | 'published' | 'archived' + user_notes?: string + is_favorite?: boolean +} + +export interface RecipeListParams { + skip?: number + limit?: number + category?: string + cuisine?: string + status?: string + is_favorite?: boolean +} + +export interface AnalyzeCaloriesRequest { + recipe: RecipeCreate + food_description: string +} + +export interface AnalyzeCaloriesResponse { + success: boolean + data?: Recipe + message?: string +} + +export interface UploadImageResponse { + image_url: string +} + +// ============================================================================ +// API 函数 +// ============================================================================ + +/** + * 获取食谱列表 + */ +export async function getRecipes(params?: RecipeListParams): Promise { + const queryParams = new URLSearchParams() + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + queryParams.append(key, String(value)) + } + }) + } + + const queryString = queryParams.toString() + const endpoint = `/recipes/${queryString ? `?${queryString}` : ''}` + + return apiRequest(endpoint) +} + +/** + * 获取单个食谱 + */ +export async function getRecipe(id: number): Promise { + return apiRequest(`/recipes/${id}`) +} + +/** + * 创建食谱 + */ +export async function createRecipe(recipe: RecipeCreate): Promise { + return apiRequest('/recipes/', { + method: 'POST', + body: JSON.stringify(recipe), + }) +} + +/** + * AI 热量分析 + */ +export async function analyzeCalories( + recipe: RecipeCreate, + foodDescription: string +): Promise { + const params = new URLSearchParams({ food_description: foodDescription }) + return apiRequest(`/recipes/analyze?${params.toString()}`, { + method: 'POST', + body: JSON.stringify(recipe), + }) +} + +/** + * 更新食谱 + */ +export async function updateRecipe(id: number, recipe: RecipeUpdate): Promise { + return apiRequest(`/recipes/${id}`, { + method: 'PUT', + body: JSON.stringify(recipe), + }) +} + +/** + * 删除食谱 + */ +export async function deleteRecipe(id: number): Promise<{ message: string }> { + return apiRequest<{ message: string }>(`/recipes/${id}`, { + method: 'DELETE', + }) +} + +/** + * 收藏/取消收藏食谱 + */ +export async function toggleFavorite(id: number): Promise { + return apiRequest(`/recipes/${id}/favorite`, { + method: 'POST', + }) +} + +/** + * 上传图片(示例实现) + */ +export async function uploadRecipeImage( + imageBase64: string, + filename?: string +): Promise { + const params = filename ? new URLSearchParams({ filename }) : new URLSearchParams() + return apiRequest(`/recipes/upload-image?${params.toString()}`, { + method: 'POST', + body: JSON.stringify({ image_base64: imageBase64 }), + }) +} diff --git a/frontend/src/api/sstc.ts b/frontend/src/api/sstc.ts index 375036ca..786100a9 100644 --- a/frontend/src/api/sstc.ts +++ b/frontend/src/api/sstc.ts @@ -31,17 +31,48 @@ export interface SSTCStageNode { risk_count: number; } +export interface SignalAttachment { + id: string; + attachment_id: string; + attachment_type: "image" | "file" | "media" | "document"; + file_name: string; + mime_type?: string | null; + file_size?: number | null; + file_key?: string | null; + image_key?: string | null; + media_id?: string | null; + occurred_at?: string | null; + source?: string | null; + download_url?: string | null; +} + export interface SSTCSignal { id: string; title?: string; source: string; content: string; + project_creator_name?: string; + proposer_name?: string; + proposer_open_id?: string | null; impact_score?: number; priority?: string; status?: string; + status_code?: "pending" | "linked" | "closed"; + can_create_charter?: boolean; + charter_lock_reason?: string | null; signal_type?: string; processed?: boolean; created_at?: string | null; + charter_name?: string | null; + attachments?: SignalAttachment[]; +} + +export interface SSTCSignalListPayload { + items: SSTCSignal[]; + total: number; + limit: number; + offset: number; + has_more: boolean; } export interface SSTCCharter { @@ -105,6 +136,33 @@ export interface SSTCReview { created_at?: string | null; } +export interface SSTCGovernanceSnapshot { + project_id: number; + charter_id?: number | null; + intervention_required: boolean; + blockers: string[]; + warnings: string[]; + confidence: { + score?: number | null; + risk: boolean; + }; + model_binding: { + bound: boolean; + compatibility_score?: number | null; + model_version_id?: number | null; + model_name?: string | null; + provider?: string | null; + }; + budget: { + allowed: boolean; + reason?: string; + usage_percent?: number; + budget_checked?: boolean; + budget_id?: number; + status?: string; + }; +} + export interface SSTCChain { project: { id: string; title: string; description?: string }; signals: SSTCSignal[]; @@ -179,6 +237,7 @@ export interface CharterCreateRequest { title: string; content?: Record; signal_ids: number[]; + skip_ai_prefill?: boolean; } export interface CharterUpdateRequest { @@ -187,6 +246,11 @@ export interface CharterUpdateRequest { force_new_version?: boolean; } +export interface CharterAIPrefillRequest { + force?: boolean; + raw_text?: string; +} + export interface TaskCreateRequest { title: string; description?: string; @@ -209,6 +273,39 @@ export interface ReviewCreateRequest { content?: Record; } +export interface WorkloadOptimizeRequest { + mode?: "unassigned" | "rebalance"; + max_moves?: number; +} + +export interface WorkloadOptimizeResponse { + updated: number; + affected_users?: number; + proposals?: number; + mode?: "unassigned" | "rebalance"; + message?: string; +} + +export interface AIRoutingConfig { + cold_start_enabled: boolean; + signal_auto_enter_threshold: number; + project_auto_create_threshold: number; + project_uncertain_threshold: number; + tips: { + cold_start_enabled: string; + signal_auto_enter_threshold: string; + project_auto_create_threshold: string; + project_uncertain_threshold: string; + }; +} + +export interface AIRoutingConfigUpdateRequest { + cold_start_enabled?: boolean; + signal_auto_enter_threshold?: number; + project_auto_create_threshold?: number; + project_uncertain_threshold?: number; +} + export const sstcApi = { async getBoardOverview(projectId: string): Promise { const { data } = await api.get>( @@ -238,10 +335,81 @@ export const sstcApi = { return unwrap(data); }, - async listSignals(projectId: string, status?: string): Promise { - const { data } = await api.get>( + async listSignals( + projectId: string, + params?: { + status?: string; + keyword?: string; + source?: string; + priority?: string; + created_from?: string; + created_to?: string; + limit?: number; + offset?: number; + } + ): Promise { + const query: Record = {}; + if (params?.status) query.status = params.status; + if (params?.keyword) query.keyword = params.keyword; + if (params?.source) query.source = params.source; + if (params?.priority) query.priority = params.priority; + if (params?.created_from) query.created_from = params.created_from; + if (params?.created_to) query.created_to = params.created_to; + if (typeof params?.limit === "number") query.limit = params.limit; + if (typeof params?.offset === "number") query.offset = params.offset; + const { data } = await api.get>( `/projects/${projectId}/signals`, - { params: status ? { status } : undefined } + { params: Object.keys(query).length > 0 ? query : undefined } + ); + const payload = unwrap(data); + if (Array.isArray(payload)) { + return { + items: payload, + total: payload.length, + limit: payload.length || params?.limit || 10, + offset: params?.offset || 0, + has_more: false, + }; + } + return payload; + }, + + async downloadSignalAttachment( + projectId: string, + signalId: string, + attachmentId: string + ): Promise { + const { data } = await api.get( + `/projects/${projectId}/signals/${signalId}/attachments/${attachmentId}/download`, + { responseType: "blob" } + ); + return data; + }, + + async uploadSignalAttachment( + projectId: string, + signalId: string, + file: File, + onProgress?: (percent: number) => void + ): Promise { + const formData = new FormData(); + formData.append("file", file); + const { data } = await api.post>( + `/projects/${projectId}/signals/${signalId}/attachments`, + formData, + { + headers: { "Content-Type": "multipart/form-data" }, + onUploadProgress: (event) => { + if (!onProgress) return; + const total = Number(event.total || 0); + if (total <= 0) { + onProgress(0); + return; + } + const loaded = Number(event.loaded || 0); + onProgress(Math.max(0, Math.min(100, Math.round((loaded / total) * 100)))); + }, + } ); return unwrap(data); }, @@ -253,6 +421,31 @@ export const sstcApi = { return unwrap(data); }, + async deleteSignal(projectId: string, signalId: string): Promise<{ message: string }> { + const { data } = await api.delete | { message: string }>( + `/projects/${projectId}/signals/${signalId}` + ); + return unwrap(data); + }, + + async getAIRoutingConfig(projectId: string): Promise { + const { data } = await api.get>( + `/projects/${projectId}/ai-routing-config` + ); + return unwrap(data); + }, + + async updateAIRoutingConfig( + projectId: string, + body: AIRoutingConfigUpdateRequest + ): Promise { + const { data } = await api.put>( + `/projects/${projectId}/ai-routing-config`, + body + ); + return unwrap(data); + }, + async listCharters(projectId: string): Promise { const { data } = await api.get>( `/projects/${projectId}/charters` @@ -274,6 +467,7 @@ export const sstcApi = { ): Promise< SSTCCharter & { signals: SSTCSignal[]; + source_signal?: SSTCSignal | null; tasks: SSTCTask[]; alignment_records: Array<{ user_id: string; @@ -287,6 +481,7 @@ export const sstcApi = { ApiEnvelope< SSTCCharter & { signals: SSTCSignal[]; + source_signal?: SSTCSignal | null; tasks: SSTCTask[]; alignment_records: Array<{ user_id: string; @@ -308,6 +503,17 @@ export const sstcApi = { return unwrap(data); }, + async aiPrefillCharter( + projectId: string, + charterId: string, + body?: CharterAIPrefillRequest + ) { + const { data } = await api.post< + ApiEnvelope + >(`/projects/${projectId}/charters/${charterId}/ai-prefill`, body || {}); + return unwrap(data); + }, + async startAlignment(projectId: string, charterId: string, note?: string) { const { data } = await api.post>( `/projects/${projectId}/charters/${charterId}/alignment/start`, @@ -400,9 +606,10 @@ export const sstcApi = { return unwrap(data); }, - async optimizeWorkload(projectId: string) { - const { data } = await api.post>( - `/projects/${projectId}/tasks/optimize-workload` + async optimizeWorkload(projectId: string, body?: WorkloadOptimizeRequest): Promise { + const { data } = await api.post>( + `/projects/${projectId}/tasks/optimize-workload`, + body || {} ); return unwrap(data); }, @@ -437,6 +644,23 @@ export const sstcApi = { return unwrap(data); }, + async patchReleaseVerificationItem( + projectId: string, + releaseNodeId: string, + itemId: string, + payload: { status: "pending" | "done"; note?: string } + ) { + const { data } = await api.patch< + ApiEnvelope<{ + release_id: string; + item_id: string; + verification_progress: { done: number; total: number }; + verification_done: boolean; + }> + >(`/projects/${projectId}/releases/${releaseNodeId}/verification/items/${itemId}`, payload); + return unwrap(data); + }, + async completeReleaseVerification(projectId: string, releaseNodeId: string) { const { data } = await api.post>( `/projects/${projectId}/releases/${releaseNodeId}/verification/complete` @@ -459,6 +683,40 @@ export const sstcApi = { return unwrap(data); }, + async patchReviewContent( + projectId: string, + reviewNodeId: string, + content: Record + ): Promise<{ id: string; status: string; content: Record }> { + const { data } = await api.patch< + ApiEnvelope<{ id: string; status: string; content: Record }> + >(`/projects/${projectId}/reviews/${reviewNodeId}/content`, { content }); + return unwrap(data); + }, + + async convertReviewImprovementToTask( + projectId: string, + reviewNodeId: string, + payload: { + improvement_index: number; + charter_id?: number; + title?: string; + description?: string; + priority?: "high" | "medium" | "low"; + estimated_hours?: number; + due_date?: string; + } + ) { + const { data } = await api.post< + ApiEnvelope<{ + task: { id: string; title: string; status: string; priority: string; charter_id: string }; + review_id: string; + improvement_index: number; + }> + >(`/projects/${projectId}/reviews/${reviewNodeId}/improvements/convert-task`, payload); + return unwrap(data); + }, + async archiveReview(projectId: string, reviewNodeId: string, archive = true, note?: string) { const { data } = await api.post>( `/projects/${projectId}/reviews/${reviewNodeId}/archive`, @@ -466,4 +724,12 @@ export const sstcApi = { ); return unwrap(data); }, + + async getGovernance(projectId: string, params?: { charter_id?: number }): Promise { + const { data } = await api.get>( + `/projects/${projectId}/sstc/governance`, + { params } + ); + return unwrap(data); + }, }; diff --git a/frontend/src/components/SkillRadarChart.tsx b/frontend/src/components/SkillRadarChart.tsx index 3c6c9e89..3e038cbe 100644 --- a/frontend/src/components/SkillRadarChart.tsx +++ b/frontend/src/components/SkillRadarChart.tsx @@ -28,6 +28,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { ChevronDown } from "lucide-react"; +import { formatBeijingDate, parseBackendDate } from "@/lib/datetime"; import { cn } from "@/lib/utils"; const LEVEL_LABELS: Record = { @@ -50,16 +51,13 @@ function compositeScore(b: UserSkillBinding): number { function formatLastUsed(iso: string | null | undefined): string { if (!iso) return "暂无"; try { - const d = new Date(iso); + const d = parseBackendDate(iso); + if (!d) return "暂无"; const now = new Date(); const diff = now.getTime() - d.getTime(); if (diff < 86400000) return "今日"; if (diff < 604800000) return `${Math.floor(diff / 86400000)} 天前`; - return d.toLocaleDateString("zh-CN", { - year: "numeric", - month: "short", - day: "numeric", - }); + return formatBeijingDate(iso); } catch { return "暂无"; } @@ -96,7 +94,7 @@ export default function SkillRadarChart({ return m; }, [tags]); - const radarData = useMemo(() => { + const filteredBindings = useMemo(() => { if (!bindings?.length) return []; const tagById = tagMap; let filtered = bindings; @@ -106,13 +104,51 @@ export default function SkillRadarChart({ return tag?.category === category; }); } - return filtered.map((b) => ({ + return filtered; + }, [bindings, tagMap, category]); + + const radarData = useMemo(() => { + return filteredBindings.map((b) => ({ subject: b.skill_name, score: Math.min(100, Math.max(0, compositeScore(b))), fullMark: 100, binding: b, })); - }, [bindings, tagMap, category]); + }, [filteredBindings]); + + const selectedCategoryLabel = category === "all" + ? "全部分类" + : SKILL_CATEGORIES.find((c) => c.value === category)?.label ?? category; + + const summary = useMemo(() => { + if (filteredBindings.length === 0) { + return { + averageScore: 0, + activeRecently: 0, + topSkillName: "暂无", + topSkillScore: 0, + }; + } + + const now = Date.now(); + const scores = filteredBindings.map((binding) => ({ + binding, + score: Math.min(100, Math.max(0, compositeScore(binding))), + })); + const totalScore = scores.reduce((sum, item) => sum + item.score, 0); + const topSkill = scores.reduce((best, item) => (item.score > best.score ? item : best), scores[0]); + const activeRecently = filteredBindings.filter((binding) => { + const parsed = parseBackendDate(binding.last_used_at); + return !!parsed && now - parsed.getTime() <= 30 * 86400000; + }).length; + + return { + averageScore: Math.round(totalScore / filteredBindings.length), + activeRecently, + topSkillName: topSkill.binding.skill_name, + topSkillScore: topSkill.score, + }; + }, [filteredBindings]); const handleSkillClick = (skillTagId: string) => { navigate(`/users/${userId}/skill-tasks/${skillTagId}`); @@ -194,43 +230,84 @@ export default function SkillRadarChart({ 暂无技能数据 ) : ( -
- - - - - - - } - cursor={{ stroke: "hsl(var(--primary))", strokeWidth: 1 }} - /> - - +
+
+
+

当前视图

+
+
+

{radarData.length}

+

{selectedCategoryLabel}

+
+
+ 平均 {summary.averageScore} +
+
+
+ +
+
+

近 30 天活跃

+

{summary.activeRecently}

+
+
+

最高分技能

+

{summary.topSkillName}

+

综合评分 {summary.topSkillScore}

+
+
+ +
+

交互提示

+

+ 点击雷达轴标签可直接进入该技能的任务历史页,当前筛选会同步影响摘要指标。 +

+
+
+ +
+
+ + + + + + + } + cursor={{ stroke: "hsl(var(--primary))", strokeWidth: 1 }} + /> + + +
+
)} diff --git a/frontend/src/components/ai-steward/FloatingQAChat.tsx b/frontend/src/components/ai-steward/FloatingQAChat.tsx new file mode 100644 index 00000000..908c768f --- /dev/null +++ b/frontend/src/components/ai-steward/FloatingQAChat.tsx @@ -0,0 +1,999 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { AlertCircle, Loader2, MessageSquare, Sparkles, ThumbsDown, ThumbsUp, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { Switch } from "@/components/ui/switch"; +import { apiBaseURL, getAuthToken } from "@/lib/api"; +import { formatBeijingDate, formatBeijingTime, parseBackendDate } from "@/lib/datetime"; + +type FeedbackRating = "positive" | "negative"; +type Helpfulness = "fully" | "partially" | "could_be_better"; +type ThinkingMode = "normal" | "deep"; +type ActionTaken = "signal_created" | "signal_deduped" | "signal_failed"; +type MessageSource = { source: string; label: string; score: number; metadata?: Record }; + +type SessionSummary = { + id: number; + title: string; + message_count: number; + last_message_at?: string | null; + rating_summary?: { + positive: number; + negative: number; + unrated: number; + corrected: number; + }; + last_asked_via?: "pmflow" | "feishu"; +}; + +type SessionMessage = { + id: number; + question: string; + answer: string; + sources: MessageSource[]; + response_time_ms?: number; + turn_index: number; + created_at?: string | null; + feedback?: { + id: number; + rating: FeedbackRating; + helpfulness?: Helpfulness; + error_types?: string[]; + correct_answer?: string; + } | null; + thinking_mode?: ThinkingMode; + action_taken?: ActionTaken | null; + signal_id?: number | null; + reply_required?: boolean; + reply_text?: string | null; + ack_latency_ms?: number | null; + trace_id?: string | null; + processing_stage?: string | null; +}; + +type SessionDetail = { + session: { + id: number; + title: string; + message_count: number; + started_at?: string | null; + last_message_at?: string | null; + }; + messages: SessionMessage[]; +}; + +type DrawerMode = "chat" | "history" | "session"; +type LoadSessionDetailOptions = { + silent?: boolean; + preserveLocal?: boolean; +}; + +const ERROR_OPTIONS = ["答案错误", "来源错误", "不完整", "答非所问", "其他"]; +const LAST_SEEN_KEY = "pmflow_qa_chat_last_seen"; +const THINKING_MODE_KEY = "pmflow_qa_chat_thinking_mode"; +const QUICK_QUESTIONS = ["你是谁", "系统架构是什么", "最近有哪些项目", "当前阻塞项有哪些"]; + +function normalizeRequestError(error: unknown, fallback = "请求失败,请稍后重试。"): string { + const raw = error instanceof Error ? error.message : String(error || ""); + if (/failed to fetch|networkerror|load failed|fetch failed/i.test(raw)) { + return "无法连接 AI 管家服务,请检查前端 /api 代理与后端服务状态。"; + } + return raw.trim() || fallback; +} + +function normalizeSources(value: unknown): MessageSource[] { + if (!Array.isArray(value)) return []; + return value + .filter((item): item is Record => Boolean(item) && typeof item === "object") + .map((item) => ({ + source: String(item.source || "unknown"), + label: String(item.label || item.source || "unknown"), + score: Number(item.score || 0), + metadata: item.metadata && typeof item.metadata === "object" ? (item.metadata as Record) : undefined, + })); +} + +function looksLikeRequirementWrite(text: string): boolean { + const clean = String(text || "").trim(); + if (!clean) return false; + return /(新增|增加|添加|记录|入库|同步|补充|实现).*(信号|需求|任务|功能|接口|页面|按钮|卡路里)|给.*(项目|系统).*(新增|增加|添加)/.test(clean); +} + +function getActionBadge(actionTaken?: ActionTaken | null): { label: string; variant: "secondary" | "destructive" } | null { + if (actionTaken === "signal_created") { + return { label: "已创建", variant: "secondary" }; + } + if (actionTaken === "signal_deduped") { + return { label: "已复用", variant: "secondary" }; + } + if (actionTaken === "signal_failed") { + return { label: "入库失败", variant: "destructive" }; + } + return null; +} + +function getStageBadge(message: Pick): string | null { + if (message.processing_stage === "ack_only") { + return "已回复"; + } + if (message.reply_required) { + return "已回执"; + } + return null; +} + +async function fetchApi(path: string, init?: RequestInit): Promise { + const token = getAuthToken(); + if (!token) return null; + try { + const res = await fetch(`${apiBaseURL}${path}`, { + ...init, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + ...(init?.headers || {}), + }, + }); + if (!res.ok) return null; + const payload = await res.json().catch(() => ({})); + return (payload?.data ?? payload) as T; + } catch (error) { + console.error(`[FloatingQAChat] request failed: ${path}`, error); + return null; + } +} + +async function askRAG( + question: string, + sessionId?: number | null, + thinkingMode: ThinkingMode = "normal", + newSession = false, +): Promise<{ + answer: string; + session_id?: number | null; + thinking_mode?: ThinkingMode; + sources?: MessageSource[]; + response_time_ms?: number | null; + action_taken?: ActionTaken | null; + signal_id?: number | null; + reply_required?: boolean; + reply_text?: string | null; + ack_latency_ms?: number | null; + trace_id?: string | null; + processing_stage?: string | null; +}> { + const token = getAuthToken(); + if (!token) { + throw new Error("登录状态已失效,请重新登录后提问。"); + } + + const payloadObj: { + question: string; + asked_via: "pmflow"; + thinking_mode: ThinkingMode; + new_session: boolean; + action_mode: "auto"; + sync_requirement: boolean; + session_id?: number; + } = { + question, + asked_via: "pmflow", + thinking_mode: thinkingMode, + new_session: Boolean(newSession), + action_mode: "auto", + sync_requirement: true, + }; + if (typeof sessionId === "number" && Number.isFinite(sessionId) && sessionId > 0) { + payloadObj.session_id = Math.trunc(sessionId); + } + const payload = JSON.stringify(payloadObj); + const paths = ["/rag/ask", "/ai-steward/rag/ask"]; + let lastError = "提问失败,请稍后重试。"; + + for (const path of paths) { + let res: Response; + try { + res = await fetch(`${apiBaseURL}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: payload, + }); + } catch (error) { + throw new Error(normalizeRequestError(error, "提问失败,请稍后重试。")); + } + if (res.ok) { + const body = await res.json().catch(() => ({})); + const data = (body?.data ?? body) as { + answer?: string; + session_id?: number | null; + thinking_mode?: string; + sources?: unknown; + response_time_ms?: number | null; + action_taken?: ActionTaken | null; + signal_id?: number | null; + reply_required?: boolean; + reply_text?: string | null; + ack_latency_ms?: number | null; + trace_id?: string | null; + processing_stage?: string | null; + }; + if (!data || !String(data.answer || "").trim()) { + throw new Error("服务返回为空,未生成有效回答。"); + } + const resolvedThinkingMode: ThinkingMode = String(data.thinking_mode || thinkingMode).toLowerCase() === "deep" ? "deep" : "normal"; + return { + answer: String(data.answer), + session_id: data.session_id ?? null, + thinking_mode: resolvedThinkingMode, + sources: normalizeSources(data.sources), + response_time_ms: data.response_time_ms ?? null, + action_taken: data.action_taken ?? null, + signal_id: data.signal_id ?? null, + reply_required: Boolean(data.reply_required), + reply_text: data.reply_text ?? null, + ack_latency_ms: data.ack_latency_ms ?? null, + trace_id: data.trace_id ?? null, + processing_stage: data.processing_stage ?? null, + }; + } + + const body = await res.json().catch(() => ({})); + const detail = String(body?.detail || body?.message || "").trim(); + lastError = detail || `请求失败(${res.status})`; + if (res.status !== 404) break; + } + + throw new Error(lastError); +} + +function groupSessions(items: SessionSummary[]) { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today); + yesterday.setDate(today.getDate() - 1); + const thirtyDaysAgo = new Date(today); + thirtyDaysAgo.setDate(today.getDate() - 30); + + const groups = { + today: [] as SessionSummary[], + yesterday: [] as SessionSummary[], + recent: [] as SessionSummary[], + older: [] as SessionSummary[], + }; + + items.forEach((item) => { + const dt = parseBackendDate(item.last_message_at || ""); + if (!dt) { + groups.recent.push(item); + return; + } + const day = new Date(dt.getFullYear(), dt.getMonth(), dt.getDate()); + if (day.getTime() === today.getTime()) { + groups.today.push(item); + return; + } + if (day.getTime() === yesterday.getTime()) { + groups.yesterday.push(item); + return; + } + if (day.getTime() < thirtyDaysAgo.getTime()) { + groups.older.push(item); + return; + } + groups.recent.push(item); + }); + + return groups; +} + +export function FloatingQAChat() { + const [open, setOpen] = useState(false); + const [mode, setMode] = useState("chat"); + const [sessions, setSessions] = useState([]); + const [historyPage, setHistoryPage] = useState(1); + const [historyTotalPages, setHistoryTotalPages] = useState(0); + const [historyLoading, setHistoryLoading] = useState(false); + const [activeSessionId, setActiveSessionId] = useState(null); + const [sessionDetail, setSessionDetail] = useState(null); + const [localMessages, setLocalMessages] = useState([]); + const [historyApiAvailable, setHistoryApiAvailable] = useState(true); + const [detailLoading, setDetailLoading] = useState(false); + const [question, setQuestion] = useState(""); + const [thinkingMode, setThinkingMode] = useState(() => { + const stored = String(localStorage.getItem(THINKING_MODE_KEY) || "").toLowerCase(); + return stored === "deep" ? "deep" : "normal"; + }); + const [draftNewSession, setDraftNewSession] = useState(false); + const [requestThinkingMode, setRequestThinkingMode] = useState("normal"); + const [asking, setAsking] = useState(false); + const [askError, setAskError] = useState(""); + const [feedbackMode, setFeedbackMode] = useState>({}); + const [helpfulness, setHelpfulness] = useState>({}); + const [errorTypes, setErrorTypes] = useState>({}); + const [correctAnswer, setCorrectAnswer] = useState>({}); + const [submittingFeedbackId, setSubmittingFeedbackId] = useState(null); + const [showOlder, setShowOlder] = useState(false); + const [unread, setUnread] = useState(false); + const [renameEditing, setRenameEditing] = useState(false); + const [renameTitle, setRenameTitle] = useState(""); + const latestSessionAtRef = useRef(null); + const localMessageIdRef = useRef(-1); + const activeSessionIdRef = useRef(null); + const draftNewSessionRef = useRef(false); + const messageViewportRef = useRef(null); + const messageEndRef = useRef(null); + const forceStickBottomRef = useRef(false); + + const loadSessions = async (page = 1, append = false) => { + setHistoryLoading(true); + try { + const data = await fetchApi<{ + items: SessionSummary[]; + page: number; + total_pages: number; + }>(`/rag/qa-sessions?page=${page}&page_size=20`); + if (!data) { + setHistoryApiAvailable(false); + return; + } + setHistoryApiAvailable(true); + + setHistoryPage(Number(data.page || page)); + setHistoryTotalPages(Number(data.total_pages || 0)); + setSessions((prev) => { + if (!append) return Array.isArray(data.items) ? data.items : []; + const existing = new Map(prev.map((x) => [x.id, x])); + (data.items || []).forEach((item) => existing.set(item.id, item)); + return Array.from(existing.values()).sort((a, b) => { + const at = parseBackendDate(a.last_message_at || "")?.getTime() || 0; + const bt = parseBackendDate(b.last_message_at || "")?.getTime() || 0; + return bt - at; + }); + }); + + const newest = (data.items || [])[0]; + if (newest?.last_message_at) { + latestSessionAtRef.current = newest.last_message_at; + } + if (newest?.last_message_at && newest.last_asked_via === "feishu") { + const seenAt = localStorage.getItem(LAST_SEEN_KEY); + if (!seenAt || (parseBackendDate(newest.last_message_at)?.getTime() || 0) > (parseBackendDate(seenAt)?.getTime() || 0)) { + setUnread(true); + } + } + + if (!activeSessionIdRef.current && !draftNewSessionRef.current && Array.isArray(data.items) && data.items.length > 0) { + setActiveSessionId(data.items[0].id); + } + } finally { + setHistoryLoading(false); + } + }; + + const loadSessionDetail = async (sessionId: number, options?: LoadSessionDetailOptions) => { + const silent = Boolean(options?.silent); + const preserveLocal = Boolean(options?.preserveLocal); + if (!silent) { + setDetailLoading(true); + } + try { + const data = await fetchApi(`/rag/qa-sessions/${sessionId}`); + if (!data) { + setHistoryApiAvailable(false); + return; + } + setHistoryApiAvailable(true); + setSessionDetail(data); + if (!preserveLocal) { + setLocalMessages([]); + } + setRenameTitle(String(data.session?.title || "")); + } finally { + if (!silent) { + setDetailLoading(false); + } + } + }; + + const refreshActiveSession = async (sessionId?: number | null, options?: LoadSessionDetailOptions) => { + const targetId = sessionId ?? activeSessionId; + if (!targetId) return; + await loadSessionDetail(targetId, options); + }; + + const startNewChat = () => { + forceStickBottomRef.current = true; + setMode("chat"); + draftNewSessionRef.current = true; + setDraftNewSession(true); + setActiveSessionId(null); + setSessionDetail(null); + setLocalMessages([]); + setAskError(""); + setQuestion(""); + setRenameEditing(false); + setRenameTitle(""); + }; + + const askQuestion = async () => { + const clean = question.trim(); + if (!clean) return; + const currentSessionId = draftNewSession ? null : activeSessionIdRef.current; + const currentThinkingMode: ThinkingMode = thinkingMode; + forceStickBottomRef.current = true; + setAskError(""); + setRequestThinkingMode(currentThinkingMode); + setAsking(true); + try { + const data = await askRAG(clean, currentSessionId, currentThinkingMode, draftNewSession); + setQuestion(""); + setSessionDetail(null); + draftNewSessionRef.current = false; + setDraftNewSession(false); + const localId = localMessageIdRef.current; + localMessageIdRef.current -= 1; + setLocalMessages((prev) => [ + ...prev, + { + id: localId, + question: clean, + answer: String(data.reply_text || data.answer || ""), + sources: data.sources?.length ? data.sources : [{ source: "rag", label: "AI 管家", score: 1 }], + response_time_ms: data.response_time_ms ?? undefined, + turn_index: prev.length + 1, + created_at: new Date().toISOString(), + feedback: null, + thinking_mode: data.thinking_mode || currentThinkingMode, + action_taken: data.action_taken ?? null, + signal_id: data.signal_id ?? null, + reply_required: Boolean(data.reply_required), + reply_text: data.reply_text ?? null, + ack_latency_ms: data.ack_latency_ms ?? null, + trace_id: data.trace_id ?? null, + processing_stage: data.processing_stage ?? null, + }, + ]); + const returnedSessionId = Number(data.session_id || 0) || null; + if (historyApiAvailable) { + await loadSessions(1, false); + if (returnedSessionId) { + setActiveSessionId(returnedSessionId); + await refreshActiveSession(returnedSessionId, { silent: true, preserveLocal: true }); + } else if (currentSessionId) { + setActiveSessionId(currentSessionId); + await refreshActiveSession(currentSessionId, { silent: true, preserveLocal: true }); + } else if (latestSessionAtRef.current) { + const fallback = await fetchApi<{ items: SessionSummary[] }>("/rag/qa-sessions?page=1&page_size=1"); + const target = fallback?.items?.[0]?.id || null; + if (target) { + setActiveSessionId(target); + await refreshActiveSession(target, { silent: true, preserveLocal: true }); + } + } + } + setMode("chat"); + } catch (error) { + const message = normalizeRequestError(error, "提问失败,请稍后重试。"); + setAskError(message); + } finally { + setAsking(false); + } + }; + + const submitFeedback = async (messageId: number, rating: FeedbackRating) => { + setSubmittingFeedbackId(messageId); + try { + await fetchApi(`/rag/qa-records/${messageId}/feedback`, { + method: "POST", + body: JSON.stringify({ + rating, + helpfulness: rating === "positive" ? helpfulness[messageId] || "fully" : undefined, + error_types: rating === "negative" ? errorTypes[messageId] || [] : [], + correct_answer: rating === "negative" ? correctAnswer[messageId] || undefined : undefined, + }), + }); + setFeedbackMode((prev) => ({ ...prev, [messageId]: null })); + await Promise.all([loadSessions(1, false), refreshActiveSession()]); + } finally { + setSubmittingFeedbackId(null); + } + }; + + const handleRename = async () => { + if (!activeSessionId) return; + const clean = renameTitle.trim(); + if (!clean) return; + const data = await fetchApi<{ title: string }>(`/rag/qa-sessions/${activeSessionId}/title`, { + method: "PATCH", + body: JSON.stringify({ title: clean }), + }); + if (!data) return; + setRenameEditing(false); + await Promise.all([loadSessions(1, false), refreshActiveSession(activeSessionId)]); + }; + + const grouped = useMemo(() => groupSessions(sessions), [sessions]); + const requestLooksLikeWrite = useMemo(() => looksLikeRequirementWrite(question), [question]); + const displayMessages = useMemo(() => { + if (mode === "session") { + return sessionDetail?.messages || []; + } + if (localMessages.length > 0) { + return localMessages; + } + if ( + sessionDetail?.messages && + sessionDetail.messages.length > 0 && + sessionDetail.session && + activeSessionId && + Number(sessionDetail.session.id) === Number(activeSessionId) + ) { + return sessionDetail.messages; + } + return []; + }, [mode, sessionDetail, localMessages, activeSessionId]); + + useEffect(() => { + activeSessionIdRef.current = activeSessionId; + }, [activeSessionId]); + + useEffect(() => { + draftNewSessionRef.current = draftNewSession; + }, [draftNewSession]); + + useEffect(() => { + localStorage.setItem(THINKING_MODE_KEY, thinkingMode); + }, [thinkingMode]); + + useEffect(() => { + if (!open) return; + loadSessions(1, false); + const timer = window.setInterval(() => { + if (historyApiAvailable) { + loadSessions(1, false); + } + }, 15000); + return () => window.clearInterval(timer); + }, [open, historyApiAvailable]); + + useEffect(() => { + if (!historyApiAvailable || !activeSessionId || !open) return; + const inChatMode = mode === "chat"; + refreshActiveSession( + activeSessionId, + inChatMode ? { silent: true, preserveLocal: true } : undefined, + ); + }, [activeSessionId, open, historyApiAvailable, mode]); + + useEffect(() => { + if (!open) return; + if (latestSessionAtRef.current) { + localStorage.setItem(LAST_SEEN_KEY, latestSessionAtRef.current); + } + setUnread(false); + }, [open]); + + useEffect(() => { + if (!open || mode === "history") return; + const viewport = messageViewportRef.current; + if (!viewport) return; + if (forceStickBottomRef.current) { + const behavior: ScrollBehavior = asking ? "auto" : "smooth"; + viewport.scrollTo({ top: viewport.scrollHeight, behavior }); + messageEndRef.current?.scrollIntoView({ behavior, block: "end" }); + if (!asking) { + forceStickBottomRef.current = false; + } + return; + } + const nearBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 160; + if (nearBottom || asking) { + messageEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); + } + }, [open, mode, displayMessages.length, asking]); + + return ( + <> + + +
+
+
+
+ + AI 管家 +
+
+ + + +
+
+ + {mode === "history" && ( +
+
+ +
+
+ {(["today", "yesterday", "recent"] as const).map((groupKey) => { + const titleMap: Record = { + today: "今天", + yesterday: "昨天", + recent: "近 30 天", + }; + const list = grouped[groupKey]; + if (!list.length) return null; + return ( +
+

{titleMap[groupKey]}

+
+ {list.map((item) => ( + + ))} +
+
+ ); + })} + +
+ + {showOlder && grouped.older.length > 0 && ( +
+ {grouped.older.map((item) => ( + + ))} +
+ )} +
+ + {historyPage < historyTotalPages && ( + + )} +
+
+ )} + + {(mode === "chat" || mode === "session") && ( + <> + {mode === "session" && ( +
+ +
+ )} + +
+ {mode === "session" && sessionDetail?.session && ( +
+ {renameEditing ? ( +
+ setRenameTitle(e.target.value)} /> +
+ + +
+
+ ) : ( + + )} +
+ )} + + {detailLoading ? ( +
+ 加载会话中... +
+ ) : ( +
+ {displayMessages.map((msg) => { + const modeForItem = feedbackMode[msg.id] || null; + const selectedErrors = errorTypes[msg.id] || []; + const isLocalMessage = msg.id < 0; + const actionBadge = getActionBadge(msg.action_taken); + const stageBadge = getStageBadge(msg); + return ( +
+
+
+ {msg.question} +
+
+ +
+
+
{msg.answer}
+
+ + 来源:{msg.sources?.length ? msg.sources.map((s) => s.label).join(" / ") : "无"} + + {msg.response_time_ms ? 耗时 {(msg.response_time_ms / 1000).toFixed(1)}s : null} + {msg.ack_latency_ms ? ACK {(msg.ack_latency_ms / 1000).toFixed(1)}s : null} +
+
+ {actionBadge ? {actionBadge.label} : null} + {msg.signal_id ? signal #{msg.signal_id} : null} + {stageBadge ? {stageBadge} : null} + {msg.thinking_mode === "deep" ? "深入思考" : "标准回答"} +
+ {msg.trace_id ? ( +
trace: {msg.trace_id}
+ ) : null} + + {isLocalMessage ? null : msg.feedback ? ( +
+ + {msg.feedback.rating === "positive" ? "已评价" : "已标记问题"} + +
+ ) : ( +
+
+ + +
+ + {modeForItem === "positive" && ( +
+
这条回答对你有多大帮助?
+
+ {[ + ["fully", "完全解决"], + ["partially", "部分有用"], + ["could_be_better", "可以更好"], + ].map(([value, label]) => ( + + ))} +
+
+ + +
+
+ )} + + {modeForItem === "negative" && ( +
+
哪里有问题?(可多选)
+
+ {ERROR_OPTIONS.map((option) => { + const checked = selectedErrors.includes(option); + return ( + + ); + })} +
+