From 2860f11cec29bca3a5bc71e72daf3824b8ced2f1 Mon Sep 17 00:00:00 2001 From: Yuuuu <1569752928@qq.com> Date: Thu, 26 Mar 2026 17:36:31 +0800 Subject: [PATCH] feat: auto materialize project signal sources --- CLAUDE.md | 13 + backend/.openclaw/pmflow-routing-rules.json | 4 +- .../app/api/ai_steward_api/routes_capture.py | 9 +- backend/app/api/ai_steward_api/schemas.py | 2 +- backend/app/api/ai_steward_api/shared.py | 3 +- backend/app/config/ai_prompt_registry.py | 12 + backend/app/config/ai_prompts.py | 2 + .../app/config/ai_prompts_catalog/__init__.py | 3 +- .../app/config/ai_prompts_catalog/part_02.py | 51 ++++ .../mixin_creation_normalize_persist.py | 16 +- .../mixin_draft_generation.py | 28 +-- .../app/services/ai_steward_service/shared.py | 3 +- .../services/auditor_core/mixin_ai_score.py | 8 +- .../mixin_capture_legacy.py | 7 +- .../mixin_new_project.py | 50 ++-- .../signal/signal_capture_service/shared.py | 2 +- .../mixin_registry_execute.py | 225 +++++++++++++++++- ...t_ai_steward_project_from_text_contract.py | 78 ++++++ .../test_feishu_new_project_signal_sources.py | 141 +++++++++++ ...gnal_source_requirement_materialization.py | 104 ++++++++ frontend/src/api/aiSteward.ts | 65 +++++ frontend/src/api/base.ts | 35 ++- .../src/pages/ai-steward/AIStewardPage.tsx | 61 +++-- .../ai-steward/sections/CreateProjectTab.tsx | 117 ++++++++- frontend/src/pages/ai-steward/types.ts | 15 +- .../pages/project-board/ProjectBoardPage.tsx | 4 +- .../components/DeleteProjectDialog.tsx | 4 +- 27 files changed, 941 insertions(+), 121 deletions(-) create mode 100644 backend/tests/test_ai_steward_project_from_text_contract.py create mode 100644 backend/tests/test_feishu_new_project_signal_sources.py create mode 100644 backend/tests/test_signal_source_requirement_materialization.py diff --git a/CLAUDE.md b/CLAUDE.md index 561fead9..f37e9ba7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,6 +94,8 @@ backend/ - 新增:`backend/app/api/audit.py`(审计查询接口:`GET /audit/operations`) - 新增:`backend/app/services/audit_query_service/service.py`(operation_logs 过滤与序列化) - 新增:`backend/app/services/signal_source_repair_service.py`(脏信号源回填服务) +- 更新:`backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py`(需求点归一化/去重与 `materialize_requirement_sources` 落库 helper,`raw_context` 记录来源与原始需求点) +- 更新:`backend/app/services/signal/signal_capture_service/mixin_new_project.py`、`mixin_capture_legacy.py`(飞书 `new_project` 自动立项收口,项目创建与信号源落库失败即回滚,避免半成功状态) - 新增:`backend/alembic/versions/20260311_shift_all_timestamps_to_beijing.py`(全库历史时间 +8h,数据库默认时区切到 Asia/Shanghai) - 新增:`backend/alembic/versions/20260312_add_signal_planner_fields.py`(signals 增加 planner 状态字段与索引) - 新增:`backend/app/services/signal/signal_planner/signal_strategic_analysis_service.py`(Signal Planner 写回收口) @@ -146,6 +148,9 @@ backend/ - 新增:`backend/tests/test_feedback_center_service.py`(反馈中心服务回归:tenant/project 过滤、review 修订、映射与原因候选) - 新增:`backend/tests/test_feedback_center_api.py`(反馈中心 API 回归:路由、权限与三类来源回填) - 新增:`backend/tests/test_daily_briefing_service.py`(每日简报回归:北京时间 naive 时间窗、任务更新采集与空响应边界) +- 新增:`backend/tests/test_ai_steward_project_from_text_contract.py`(AI Steward `project-from-text` 契约回归:`code/message/data` 包络与 `signal_sources` 字段) +- 新增:`backend/tests/test_signal_source_requirement_materialization.py`(需求点 -> 信号源注册项归一/去重/原始上下文回归) +- 新增:`backend/tests/test_feishu_new_project_signal_sources.py`(飞书 `new_project` 自动立项回归:摘要失败显式报错、信号源随项目同步落库) - 更新:`backend/app/services/feedback_center/service.py`(summary 输出新增 metrics 学习指标、决策洞察 `insights`,并支持 `window_days` / `source_group` 的趋势分析作用域) - 更新:`backend/app/schemas/feedback_center.py`(summary 增加 metrics / trend / insights 结构,并回传 `source_group`) - 新增:`backend/app/services/signal_planner_service.py`(Signal Planner 异步分析服务) @@ -154,6 +159,7 @@ backend/ - 新增:`backend/app/services/model_context_bundle_service.py`(双模型上下文组包/能力汇总/消费验证服务) - 新增:`backend/app/api/model_routing.py`(模型路由配置接口) - 新增:`backend/app/api/task_briefs.py`(任务包中心接口) +- 更新:`backend/app/api/ai_steward_api/routes_capture.py`、`schemas.py`、`shared.py`(`POST /ai-steward/project-from-text` 统一 `code/message/data` 包络,返回 `signal_sources`) - 更新:`backend/app/api/knowledge_graph.py`(新增 `capability-summary` 与 `verify-context-consumption` 接口) - 更新:`backend/app/services/llm.py`(按 function_name 注入 DDL 接口化硬约束,默认直连 GLM 严格失败;本地 Ollama 仅允许知识库问答链路按开关启用) - 更新:`backend/app/api/llm_engines.py`(新增 `PUT /ai-steward/llm-engines/settings`,维护本地知识库问答开关) @@ -360,12 +366,15 @@ frontend/ - 新增:`frontend/src/pages/ai-steward/types.ts`、`frontend/src/pages/ai-steward/constants.ts`(AI Steward 类型与常量集中管理) - 新增:`frontend/src/pages/ai-steward/components/FeatureOverviewCards.tsx`(AI Steward 首页功能卡片组件) - 新增:`frontend/src/pages/ai-steward/sections/OverviewTab.tsx`、`CreateProjectTab.tsx`、`SignalsTab.tsx`、`ConflictsTab.tsx`、`BlockersTab.tsx`、`BriefingTab.tsx`、`EngineTab.tsx`、`FeedbackCenterTab.tsx`(AI Steward 分阶段 UI 拆分) +- 更新:`frontend/src/pages/ai-steward/sections/CreateProjectTab.tsx`(零配置创建结果区内联展示 `signal_sources` 分组,复用现有 AI Steward 页面,不新增独立路由) - 新增:`frontend/src/pages/ai-steward/components/feedback-center/FeedbackList.tsx`、`FeedbackDetail.tsx`、`ReasonPicker.tsx`、`MetricsPanel.tsx`、`TrendPanel.tsx`(反馈中心三栏工作台、指标页与趋势页组件;趋势页支持 `7/30 天` 与 `all/task/qa/signal` 局部筛选,并提供洞察卡、原因筛选与一键钻取) - 新增:`frontend/src/pages/ai-steward/components/feedback-center/TrendPanel.tsx` 的趋势决策版布局(Scope / KPI / Insights / Drilldown / Evidence) - 更新:`frontend/src/pages/ai-steward/sections/FeedbackCenterTab.tsx`(趋势页钻取联动列表筛选,支持 reason_code / source_type 的局部跳转) - 更新:`frontend/src/pages/ai-steward/components/feedback-center/FeedbackList.tsx`(列表增加原因筛选器,支持趋势页高频原因钻取) - 新增:`frontend/src/api/feedbackCenter.ts`(统一反馈中心 API 封装) - 新增:`frontend/src/pages/ai-steward/AIStewardPage.tsx`(AI Steward 页面编排层,主文件聚焦状态与事件) +- 更新:`frontend/src/api/aiSteward.ts`、`frontend/src/api/base.ts`(新增 `createProjectFromText`,并兼容 `{code,message,data}` 响应包络) +- 新增:`frontend/src/lib/api.ts`、`apiError.ts`、`display.ts`、`datetime.ts`、`inbox.ts`、`inboxTextMap.ts`、`formatInboxTime.ts`、`feishuEventMode.ts`、`sessionGuard.ts`、`rbac.ts`、`title.ts`、`utils.ts`、`zhCN.ts`(前端兼容工具与消息台/AI Steward 展示格式化层) - 更新:`frontend/src/pages/ProjectBoardPage.tsx`(降级为轻量入口,转发到 `pages/project-board/ProjectBoardPage`) - 新增:`frontend/src/pages/project-board/helpers.ts`(Project Board 信号筛选状态与 PMO 工作项文本归一 helper) - 新增:`frontend/src/pages/project-board/components/CreateCharterDialog.tsx`、`ProjectSettingsDialog.tsx`(Project Board 大体量弹窗组件拆分) @@ -459,6 +468,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - 运行期禁止 DDL:`database.py` 钩子统一拦截 `CREATE/ALTER/DROP/...`,仅迁移路径允许执行 - 运行环境数据库仅允许 PostgreSQL(`postgresql+asyncpg`);禁止回落 SQLite - 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回 +- 项目创建时自动生成的信号源必须落到现有 `SignalSourceRegistry`,前端仅允许在现有 AI Steward 创建结果区内联展示,禁止新增独立信号源页面或独立持久化模型 - 禁止在 API 层写业务逻辑,业务逻辑归属 `services/` - 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层 - 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState` @@ -487,9 +497,11 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ## 9. 新增代码的参考模式 - 新增后端接口:参考 `backend/app/api/sstc.py` 的结构 +- 项目创建接口:参考 `backend/app/api/ai_steward_api/routes_capture.py`(`POST /ai-steward/project-from-text` 统一 `{code,message,data}`,返回 `signal_sources`) - 模型路由配置接口:参考 `backend/app/api/model_routing.py`(`GET/PUT /model-routing/functions/{function_name}`,仅管理员可写并写入 `operation_logs`) - 任务包中心接口:参考 `backend/app/api/task_briefs.py`(`/task-briefs/center/templates/runs/generate/contract-sync`) - KG 上下文闭环接口:参考 `backend/app/api/knowledge_graph.py`(`GET /ai-steward/knowledge-graph/capability-summary`、`POST /ai-steward/knowledge-graph/verify-context-consumption`) +- 需求点落信号源:参考 `backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py`(`materialize_requirement_sources` 负责归一、去重与 `raw_context` 持久化) - 审计查询接口:参考 `backend/app/api/audit.py`(`GET /audit/operations`) - 脏数据修复接口:参考 `backend/app/api/ai_steward.py`(`POST /ai-steward/signal-sources/repair-dirty`) - LLM 引擎设置接口:参考 `backend/app/api/llm_engines.py`(`PUT /ai-steward/llm-engines/settings`,仅管理员可修改本地知识库问答开关) @@ -536,6 +548,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ## 11. 已知 BOUNDARY(不要幻构行为) - 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用 +- 项目创建返回的 `signal_sources` 仅作为 AI Steward 创建结果区的内联展示,现阶段不新增独立页面路由;底层持久化统一复用现有 `SignalSourceRegistry` - 飞书长连接 websocket 在本地/CI 环境默认使用 `certifi` CA bundle 作为信任链;如需自定义 CA,可通过 `FEISHU_LONG_CONNECTION_SSL_CERT_FILE` 或 `SSL_CERT_FILE` 覆盖 - 飞书长连接状态分为两层:`last_received_at/last_received_message_id` 表示 SDK 回调已收到事件,`last_event_at` 表示 worker 完成处理;排查“没吃进去”时必须优先看 `last_received_*` - 飞书长连接会话应复用 `Integration.config` 中已有的 `encrypt_key/verification_token`;配置保存时若未显式修改这两个字段,必须保留现有值,避免 SDK 因缺失事件密钥而拒收消息 diff --git a/backend/.openclaw/pmflow-routing-rules.json b/backend/.openclaw/pmflow-routing-rules.json index bfd7fd56..eff7d153 100644 --- a/backend/.openclaw/pmflow-routing-rules.json +++ b/backend/.openclaw/pmflow-routing-rules.json @@ -63,7 +63,8 @@ "project_draft_analysis_glm47_fallback", "project_draft_signal_glm47_fallback", "project_draft_charter_glm47_fallback", - "project_draft_task_chain_glm47_fallback" + "project_draft_task_chain_glm47_fallback", + "project_requirement_extraction_glm47_fallback" ] }, { @@ -126,6 +127,7 @@ "match_any": [ "adaptive_rag_answer", "classify_intent_complex", + "project_requirement_extraction", "feishu_project_prefill", "feishu_task_orchestration", "generate_task_breakdown", diff --git a/backend/app/api/ai_steward_api/routes_capture.py b/backend/app/api/ai_steward_api/routes_capture.py index 60e0e692..e4ba9266 100644 --- a/backend/app/api/ai_steward_api/routes_capture.py +++ b/backend/app/api/ai_steward_api/routes_capture.py @@ -3,7 +3,7 @@ from __future__ import annotations from .shared import * from .schemas import BrowserCaptureRequest, FeishuCaptureRequest, GitHubCaptureRequest, ProjectFromTextRequest, ProjectFromTextResponse -@router.post("/project-from-text", response_model=ProjectFromTextResponse) +@router.post("/project-from-text", response_model=ApiResponse[ProjectFromTextResponse]) async def create_project_from_text( request: ProjectFromTextRequest, current_user: User = Depends(get_current_user), @@ -34,12 +34,12 @@ async def create_project_from_text( tenant_id=tenant_id ) - return ProjectFromTextResponse(**result) + return success_response(ProjectFromTextResponse(**result).model_dump()) except Exception as e: - raise HTTPException( + return JSONResponse( status_code=500, - detail=f"项目创建失败: {str(e)}" + content=error_response(ErrorCode.INTERNAL_ERROR, f"项目创建失败: {str(e)}"), ) @router.post("/signal/capture-feishu") @@ -231,4 +231,3 @@ async def sync_external_signal( status_code=500, detail=f"信号同步失败: {str(e)}" ) - diff --git a/backend/app/api/ai_steward_api/schemas.py b/backend/app/api/ai_steward_api/schemas.py index 83976d06..228db27c 100644 --- a/backend/app/api/ai_steward_api/schemas.py +++ b/backend/app/api/ai_steward_api/schemas.py @@ -18,6 +18,7 @@ class ProjectFromTextResponse(BaseModel): charter: Dict[str, Any] tasks: List[Dict[str, Any]] risks: List[Dict[str, Any]] + signal_sources: List[Dict[str, Any]] = Field(default_factory=list) analysis: Dict[str, Any] class CognitiveConflictResponse(BaseModel): @@ -93,4 +94,3 @@ class WorkflowTriggerRequest(BaseModel): """工作流触发请求""" event_type: str event_data: Dict[str, Any] - diff --git a/backend/app/api/ai_steward_api/shared.py b/backend/app/api/ai_steward_api/shared.py index aeb4d426..9adde3d7 100644 --- a/backend/app/api/ai_steward_api/shared.py +++ b/backend/app/api/ai_steward_api/shared.py @@ -5,11 +5,12 @@ from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Literal, Optional from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession -from ...core.response import success_response +from ...core.response import ApiResponse, ErrorCode, error_response, success_response from ...core.database import get_db from ...dependencies.auth import get_current_user from ...dependencies.permissions import require_manager_user diff --git a/backend/app/config/ai_prompt_registry.py b/backend/app/config/ai_prompt_registry.py index a67abc79..0cfd6220 100644 --- a/backend/app/config/ai_prompt_registry.py +++ b/backend/app/config/ai_prompt_registry.py @@ -161,6 +161,10 @@ FEISHU_PIPELINE_CALL_SITES = ("backend/app/services/integrations/feishu_signal_p FEISHU_SIGNAL_CLASSIFY_CALL_SITES = ("backend/app/services/signal/signal_capture_service/mixin_analysis_normalize.py",) SIGNAL_SOURCE_CALL_SITES = ("backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py",) DRAFT_GENERATOR_CALL_SITES = ("backend/app/services/signal/draft_generator_service.py",) +PROJECT_REQUIREMENT_EXTRACTION_CALL_SITES = ( + "backend/app/services/ai_steward_service/mixin_draft_generation.py", + "backend/app/services/signal/signal_capture_service/mixin_new_project.py", +) RAG_INTENT_CALL_SITES = ("backend/app/services/adaptive_rag_service/mixin_core_intent.py",) RAG_DB_CALL_SITES = ("backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py",) SIGNAL_PLANNER_CALL_SITES = ("backend/app/services/signal/signal_planner/mixin_prompt.py", "backend/app/services/signal/signal_planner/mixin_analyze.py") @@ -260,6 +264,14 @@ def _part_02_entries() -> tuple[dict[str, Any], ...]: output_contract="输出 JSON 对象,包含项目名、描述、负责人、时间线与置信度。", call_sites=FEISHU_EXTRACT_CALL_SITES, ), + _entry( + "PROMPT_PROJECT_REQUIREMENT_EXTRACTION", + module="part_02", + business_scenario="项目需求抽取与信号源落点", + input_contract=JSON_INPUT, + output_contract="输出 JSON 对象,包含项目基础信息、核心痛点、MVP 功能与 requirement_items。", + call_sites=PROJECT_REQUIREMENT_EXTRACTION_CALL_SITES, + ), _entry( "PROMPT_FEISHU_TASK_ORCHESTRATION", module="part_02", diff --git a/backend/app/config/ai_prompts.py b/backend/app/config/ai_prompts.py index 94925d8e..8dcd144a 100644 --- a/backend/app/config/ai_prompts.py +++ b/backend/app/config/ai_prompts.py @@ -19,6 +19,7 @@ from .ai_prompts_catalog import ( PROMPT_FEISHU_LEVEL_CLASSIFICATION, PROMPT_FEISHU_SIGNAL_EXTRACTION, PROMPT_FEISHU_PROJECT_PREFILL, + PROMPT_PROJECT_REQUIREMENT_EXTRACTION, PROMPT_FEISHU_TASK_ORCHESTRATION, PROMPT_SIGNAL_SOURCE_ONBOARDING, PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION, @@ -162,6 +163,7 @@ __all__ = [ "PROMPT_FEISHU_LEVEL_CLASSIFICATION", "PROMPT_FEISHU_SIGNAL_EXTRACTION", "PROMPT_FEISHU_PROJECT_PREFILL", + "PROMPT_PROJECT_REQUIREMENT_EXTRACTION", "PROMPT_FEISHU_TASK_ORCHESTRATION", "PROMPT_SIGNAL_SOURCE_ONBOARDING", "PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION", diff --git a/backend/app/config/ai_prompts_catalog/__init__.py b/backend/app/config/ai_prompts_catalog/__init__.py index f70819ea..d227b753 100644 --- a/backend/app/config/ai_prompts_catalog/__init__.py +++ b/backend/app/config/ai_prompts_catalog/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations from .part_01 import PROMPT_IMPACT_ANALYSIS, PROMPT_STRATEGIC_ANALYSIS_HEADER, PROMPT_RAG_CONTEXT_HEADER, PROMPT_RAG_CONTEXT_ITEM, PROMPT_CODE_AUDIT, PROMPT_CONTEXT_INSIGHTS, PROMPT_FEISHU_HUMANIZED_REPLY, PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, PROMPT_FEISHU_LEVEL_CLASSIFICATION -from .part_02 import PROMPT_FEISHU_SIGNAL_EXTRACTION, PROMPT_FEISHU_PROJECT_PREFILL, PROMPT_FEISHU_TASK_ORCHESTRATION, PROMPT_SIGNAL_SOURCE_ONBOARDING, PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION, PROMPT_AI_PMO_INTENT_CLASSIFICATION, PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS, PROMPT_DRAFT_GENERATOR, PROMPT_RAG_INTENT_CLASSIFICATION +from .part_02 import PROMPT_FEISHU_SIGNAL_EXTRACTION, PROMPT_FEISHU_PROJECT_PREFILL, PROMPT_PROJECT_REQUIREMENT_EXTRACTION, PROMPT_FEISHU_TASK_ORCHESTRATION, PROMPT_SIGNAL_SOURCE_ONBOARDING, PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION, PROMPT_AI_PMO_INTENT_CLASSIFICATION, PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS, PROMPT_DRAFT_GENERATOR, PROMPT_RAG_INTENT_CLASSIFICATION from .part_03 import PROMPT_RAG_ANSWER, PROMPT_DECISION_QA, PROMPT_RAG_DB_PROJECT_QA, PROMPT_GENERATE_CHARTER, PROMPT_DECOMPOSE_TASKS, PROMPT_RECOMMEND_ASSIGNEES, PROMPT_ALIGNMENT_SCORE, PROMPT_COGNITIVE_CONFLICTS, PROMPT_EVALUATE_MODEL_REQUIREMENT, PROMPT_ANALYZE_CAPABILITY_GAP, PROMPT_ANALYZE_COST_ROI from .part_04 import PROMPT_ANALYZE_COST_OPTIMIZATION, PROMPT_ASSESS_MODEL_UPDATE_IMPACT, PROMPT_EXPERIMENT_SYNC_DECISION, PROMPT_SKILL_SCORE_UPDATE, PROMPT_WORKLOAD_OPTIMIZATION, PROMPT_ANALYZE_FOOD_CALORIES, PROMPT_SIGNAL_PLANNER from .part_scenes_01 import PROMPT_FEISHU_DIRECT_QA @@ -23,6 +23,7 @@ __all__ = [ "PROMPT_FEISHU_LEVEL_CLASSIFICATION", "PROMPT_FEISHU_SIGNAL_EXTRACTION", "PROMPT_FEISHU_PROJECT_PREFILL", + "PROMPT_PROJECT_REQUIREMENT_EXTRACTION", "PROMPT_FEISHU_TASK_ORCHESTRATION", "PROMPT_SIGNAL_SOURCE_ONBOARDING", "PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION", diff --git a/backend/app/config/ai_prompts_catalog/part_02.py b/backend/app/config/ai_prompts_catalog/part_02.py index fac240b7..02ac0be8 100644 --- a/backend/app/config/ai_prompts_catalog/part_02.py +++ b/backend/app/config/ai_prompts_catalog/part_02.py @@ -80,6 +80,56 @@ PROMPT_FEISHU_PROJECT_PREFILL = _compose_prompt( JSON_OUTPUT_GUARDRAIL, ) +PROMPT_PROJECT_REQUIREMENT_EXTRACTION = """ +你是 PMFlow 的项目需求结构化抽取助手。请从输入文本中抽取项目立项信息与需求点。 + +输入来源:{source_label} +来源上下文:{source_context} +原始文本: +{input_text} + +输出 JSON: +{{ + "project_name": "短项目名", + "project_title": "项目标题", + "tech_stack": ["技术栈"], + "target_audience": "目标用户", + "timeline": "时间周期", + "primary_role": "ai_engineer|backend|frontend|devops", + "domain_id": 1, + "key_features": ["核心功能"], + "competitors": ["竞品"], + "success_metrics": ["成功指标"], + "core_pain_points": ["核心痛点"], + "mvp_features": ["MVP 功能"], + "requirement_items": [ + {{ + "section": "核心痛点|MVP 功能", + "title": "短标题", + "description": "具体描述", + "priority": "high|medium|low", + "source_kind": "custom|feishu|pmflow", + "source_name": "可选", + "source_identifier": "可选稳定键" + }} + ] +}} + +规则: +1. requirement_items 至少 2 条,优先覆盖“核心痛点”和“MVP 功能”。 +2. 每条 requirement_items 只表达一个需求点,避免重复。 +3. section 必须保留原始分组语义,优先使用“核心痛点”与“MVP 功能”。 +4. 若输入来自飞书消息,也按相同结构抽取,不要省略 requirement_items。 +5. source_kind 无法判断时,AI Steward 页面使用 custom,飞书消息使用 feishu。 +6. 只返回 JSON,不要 markdown,不要解释。必须可被 json.loads 直接解析。 +""" +PROMPT_PROJECT_REQUIREMENT_EXTRACTION = _compose_prompt( + PMFLOW_PMO_STYLE_BASE, + EXTRACTION_SCOPE_BASE, + PROMPT_PROJECT_REQUIREMENT_EXTRACTION, + JSON_OUTPUT_GUARDRAIL, +) + PROMPT_FEISHU_TASK_ORCHESTRATION = """ 你是 PMFlow 的任务编排与人员分配 AI。 请基于飞书需求信号,为指定项目产出“可执行任务 + 负责人建议”。 @@ -381,6 +431,7 @@ PROMPT_RAG_INTENT_CLASSIFICATION = _compose_prompt( __all__ = [ "PROMPT_FEISHU_SIGNAL_EXTRACTION", "PROMPT_FEISHU_PROJECT_PREFILL", + "PROMPT_PROJECT_REQUIREMENT_EXTRACTION", "PROMPT_FEISHU_TASK_ORCHESTRATION", "PROMPT_SIGNAL_SOURCE_ONBOARDING", "PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION", diff --git a/backend/app/services/ai_steward_service/mixin_creation_normalize_persist.py b/backend/app/services/ai_steward_service/mixin_creation_normalize_persist.py index 26986975..13b963f4 100644 --- a/backend/app/services/ai_steward_service/mixin_creation_normalize_persist.py +++ b/backend/app/services/ai_steward_service/mixin_creation_normalize_persist.py @@ -169,6 +169,19 @@ class AIStewardCreationPersistMixin: ) ) + signal_sources = await SignalSourceOrchestrationService(self.db).materialize_requirement_sources( + tenant_id=int(tenant_id), + project_id=int(project.id), + signal_id=int(signal.id), + operator_user_id=int(creator_id), + requested_via="ai_steward", + origin="project_analysis", + origin_text=input_text, + analysis=normalized["analysis"], + requirement_items=normalized["analysis"].get("requirement_items") if isinstance(normalized["analysis"].get("requirement_items"), list) else [], + default_source_kind="custom", + ) + charter_data = normalized["charter"] charter = TruthNode( node_type="charter", @@ -244,6 +257,7 @@ class AIStewardCreationPersistMixin: signal_id=int(signal.id), charter_id=int(charter.id), persisted_task_ids=persisted_task_ids, + signal_source_count=len(signal_sources), task_audit=normalized["task_audit"], input_text=input_text[:500], ) @@ -268,6 +282,6 @@ class AIStewardCreationPersistMixin: }, "tasks": persisted_tasks, "risks": risks_payload, + "signal_sources": signal_sources, "analysis": normalized["analysis"], } - diff --git a/backend/app/services/ai_steward_service/mixin_draft_generation.py b/backend/app/services/ai_steward_service/mixin_draft_generation.py index 394a95dc..5381f4e1 100644 --- a/backend/app/services/ai_steward_service/mixin_draft_generation.py +++ b/backend/app/services/ai_steward_service/mixin_draft_generation.py @@ -1,34 +1,21 @@ from __future__ import annotations from .shared import * +from ...config.ai_prompts import PROMPT_PROJECT_REQUIREMENT_EXTRACTION class AIStewardDraftGenerationMixin: async def _analyze_project_input(self, input_text: str, tenant_id: int) -> Dict[str, Any]: """使用 AI 分析项目输入,提取关键信息。""" - prompt = f"""分析以下项目需求,提取关键信息并以 JSON 格式返回: - -用户输入:{input_text} - -请提取以下信息: -{{ - "project_name": "项目名称(简短)", - "project_title": "项目标题", - "tech_stack": ["技术栈列表"], - "target_audience": "目标用户", - "timeline": "时间周期", - "primary_role": "主要角色类型(ai_engineer/backend/frontend)", - "domain_id": 1, - "key_features": ["核心功能列表"], - "competitors": ["竞品列表"], - "success_metrics": ["成功指标"] -}} - -只返回 JSON,不要其他内容。必须返回可被 json.loads 直接解析的单个对象,不要 markdown。""" + prompt = PROMPT_PROJECT_REQUIREMENT_EXTRACTION.format( + source_label="项目描述", + source_context="AI Steward 零配置项目创建输入", + input_text=input_text[:2000], + ) analysis = await self._generate_project_creation_object( prompt=prompt, tenant_id=tenant_id, - function_name="project_draft_analysis_glm47_fallback", + function_name="project_requirement_extraction_glm47_fallback", stage_label="AI 管家项目需求分析失败", ) return analysis @@ -197,4 +184,3 @@ class AIStewardDraftGenerationMixin: "severity": "medium", }, ] - diff --git a/backend/app/services/ai_steward_service/shared.py b/backend/app/services/ai_steward_service/shared.py index 64427ae4..f22e31cb 100644 --- a/backend/app/services/ai_steward_service/shared.py +++ b/backend/app/services/ai_steward_service/shared.py @@ -20,6 +20,7 @@ from ...utils.sequence import ensure_project_id_sequence from ...utils.title import compact_title from ..adaptive_rag_service import AdaptiveRAGService from ..llm_service import LLMService +from ..signal.signal_source_orchestration import SignalSourceOrchestrationService from ..task_persistence_core import ( build_task_create_draft, normalize_skill_names, @@ -76,7 +77,7 @@ PROJECT_CREATION_LOW_TO_HIGH_FUNCTION_MAP = { "project_draft_signal_glm47_fallback": "project_from_text_signal", "project_draft_charter_glm47_fallback": "project_from_text_charter", "project_draft_task_chain_glm47_fallback": "project_from_text_task_chain", + "project_requirement_extraction_glm47_fallback": "project_requirement_extraction", } PROJECT_CREATION_SERIAL_DRAFT_STEPS = _env_int("AI_STEWARD_PROJECT_CREATION_SERIAL_DRAFT_STEPS", 1) == 1 - diff --git a/backend/app/services/auditor_core/mixin_ai_score.py b/backend/app/services/auditor_core/mixin_ai_score.py index e558e7dc..a9d9d876 100644 --- a/backend/app/services/auditor_core/mixin_ai_score.py +++ b/backend/app/services/auditor_core/mixin_ai_score.py @@ -10,6 +10,11 @@ class CodeAuditorAIScoreMixin: issues = [] # 构建 AI 审计 prompt + full_content_block = "" + if full_content: + full_content_block = ( + f"完整文件内容:\n```{self._detect_language(file_path)}\n{full_content}\n```" + ) prompt = f"""作为一位资深代码审查员,请审计以下代码变更: 任务需求:{task_name} @@ -21,7 +26,7 @@ class CodeAuditorAIScoreMixin: {file_changes} ``` -{f'完整文件内容:\n```{self._detect_language(file_path)}\n{full_content}\n```' if full_content else ''} +{full_content_block} 请从以下维度进行审查: 1. 功能正确性:代码是否正确实现了需求 @@ -213,4 +218,3 @@ class CodeAuditorAIScoreMixin: for issue in issues: counts[issue.category] = counts.get(issue.category, 0) + 1 return counts - diff --git a/backend/app/services/signal/signal_capture_service/mixin_capture_legacy.py b/backend/app/services/signal/signal_capture_service/mixin_capture_legacy.py index 904b14a6..8f18beb9 100644 --- a/backend/app/services/signal/signal_capture_service/mixin_capture_legacy.py +++ b/backend/app/services/signal/signal_capture_service/mixin_capture_legacy.py @@ -85,8 +85,7 @@ class SignalCaptureLegacyMixin: tenant_id=tenant_id, ) self.db.add(signal) - await self.db.commit() - await self.db.refresh(signal) + await self.db.flush() self._emit_ai_think("signal_created", trace_id=trace_id, signal_id=signal.id) linked_project_ids: List[int] = [] @@ -115,6 +114,9 @@ class SignalCaptureLegacyMixin: ) except Exception as exc: self._emit_ai_think("project_link_failed", trace_id=trace_id, error=str(exc)) + if project_scope == "new_project": + await self.db.rollback() + raise auto_tasks: List[Dict[str, Any]] = [] ai_execution: Dict[str, Any] = { @@ -196,4 +198,3 @@ class SignalCaptureLegacyMixin: "proposer": proposer_name, "ai_trace_id": trace_id, } - diff --git a/backend/app/services/signal/signal_capture_service/mixin_new_project.py b/backend/app/services/signal/signal_capture_service/mixin_new_project.py index 1b567eb0..28801015 100644 --- a/backend/app/services/signal/signal_capture_service/mixin_new_project.py +++ b/backend/app/services/signal/signal_capture_service/mixin_new_project.py @@ -1,6 +1,7 @@ from __future__ import annotations from .shared import * +from ....config.ai_prompts import PROMPT_PROJECT_REQUIREMENT_EXTRACTION class SignalCaptureNewProjectMixin: async def _auto_create_project_from_new_signal( @@ -83,17 +84,30 @@ class SignalCaptureNewProjectMixin: ) self.db.add(link) + signal_sources = await SignalSourceOrchestrationService(self.db).materialize_requirement_sources( + tenant_id=int(tenant_id), + project_id=int(project.id), + signal_id=int(signal_id), + operator_user_id=int(owner_id) if owner_id is not None else None, + requested_via="feishu", + origin="feishu_new_project", + origin_text=message, + analysis=project_brief, + requirement_items=project_brief.get("requirement_items") if isinstance(project_brief.get("requirement_items"), list) else [], + default_source_kind="feishu", + ) + 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, + signal_source_count=len(signal_sources), ) return int(project.id) @@ -106,22 +120,15 @@ class SignalCaptureNewProjectMixin: 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,不要解释,不要乱码。""" + prompt = PROMPT_PROJECT_REQUIREMENT_EXTRACTION.format( + source_label="飞书消息", + source_context=( + f"需求提出人:{sender}\n" + f"提出时间:{requested_at_text}\n" + "来源:飞书群聊新项目立项" + ), + input_text=message[:1000], + ) try: raw = await self.llm.generate( prompt, @@ -133,16 +140,13 @@ class SignalCaptureNewProjectMixin: role = str(parsed.get("primary_role") or "ai_engineer").strip().lower() if role not in {"ai_engineer", "backend", "frontend", "devops"}: role = "ai_engineer" + requirement_items = parsed.get("requirement_items") if isinstance(parsed.get("requirement_items"), list) else [] 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, + "requirement_items": [item for item in requirement_items if isinstance(item, dict)], } 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", - } - + raise RuntimeError(f"feishu_new_project_brief_failed:{exc}") from exc diff --git a/backend/app/services/signal/signal_capture_service/shared.py b/backend/app/services/signal/signal_capture_service/shared.py index 1fc10d97..b0e3ee59 100644 --- a/backend/app/services/signal/signal_capture_service/shared.py +++ b/backend/app/services/signal/signal_capture_service/shared.py @@ -152,7 +152,7 @@ AI_THINK_STAGE_ZH = { "hn_story_captured": "HackerNews 信号已捕获", "hn_monitor_error": "HackerNews 监控异常", "new_project_created_from_signal": "已根据信号自动创建项目", - "draft_new_project_brief_failed": "新项目摘要生成失败,已走兜底", + "draft_new_project_brief_failed": "新项目摘要生成失败", } diff --git a/backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py b/backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py index 314fd684..6b6a157e 100644 --- a/backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py +++ b/backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py @@ -3,6 +3,213 @@ from __future__ import annotations from .shared import * class SignalSourceRegistryExecuteMixin: + @staticmethod + def _normalize_requirement_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_requirement_text(value: Any, *, default: str = "", max_len: int = 120) -> str: + text = re.sub(r"\s+", " ", str(value or "")).strip() + if not text: + return default + return text[:max_len] + + @classmethod + def _expand_requirement_items_from_analysis( + cls, + analysis: dict[str, Any], + *, + default_source_kind: str, + ) -> list[dict[str, Any]]: + raw_items = analysis.get("requirement_items") if isinstance(analysis.get("requirement_items"), list) else [] + items: list[dict[str, Any]] = [] + if raw_items: + for raw_item in raw_items: + if isinstance(raw_item, dict): + items.append(dict(raw_item)) + if items: + return items + + section_sources = ( + ("核心痛点", analysis.get("core_pain_points")), + ("MVP 功能", analysis.get("mvp_features") or analysis.get("key_features")), + ) + for section, source in section_sources: + if not isinstance(source, list): + continue + for raw_value in source: + text = cls._normalize_requirement_text(raw_value, default="", max_len=240) + if not text: + continue + items.append( + { + "section": section, + "title": text, + "description": text, + "priority": "high" if section == "核心痛点" else "medium", + "source_kind": default_source_kind, + } + ) + return items + + @classmethod + def _normalize_requirement_items( + cls, + *, + requirement_items: list[dict[str, Any]] | None, + analysis: dict[str, Any] | None, + default_source_kind: str, + ) -> list[dict[str, Any]]: + items = list(requirement_items or []) + if not items and isinstance(analysis, dict): + items = cls._expand_requirement_items_from_analysis( + analysis, + default_source_kind=default_source_kind, + ) + + normalized: list[dict[str, Any]] = [] + for raw_item in items: + if not isinstance(raw_item, dict): + continue + section = cls._normalize_requirement_text( + raw_item.get("section") or raw_item.get("category"), + default="MVP 功能", + max_len=40, + ) + title = cls._normalize_requirement_text( + raw_item.get("title") or raw_item.get("name") or raw_item.get("feature"), + default="未命名需求点", + max_len=120, + ) + description = cls._normalize_requirement_text( + raw_item.get("description") or raw_item.get("summary") or title, + default=title, + max_len=600, + ) + source_kind = cls._normalize_requirement_text( + raw_item.get("source_kind"), + default=default_source_kind, + max_len=40, + ).lower() or default_source_kind + source_name = cls._normalize_requirement_text( + raw_item.get("source_name") or f"{section} - {title}", + default=f"{section} - {title}", + max_len=120, + ) + source_identifier = cls._normalize_requirement_text( + raw_item.get("source_identifier"), + default="", + max_len=180, + ) + normalized.append( + { + "section": section or "MVP 功能", + "title": title, + "description": description, + "priority": cls._normalize_requirement_priority(raw_item.get("priority")), + "source_kind": source_kind or default_source_kind, + "source_name": source_name, + "source_identifier": source_identifier, + } + ) + return normalized + + async def materialize_requirement_sources( + self, + *, + tenant_id: int, + project_id: int, + signal_id: Optional[int], + operator_user_id: Optional[int], + requested_via: str, + origin: str, + origin_text: str, + requirement_items: list[dict[str, Any]] | None = None, + analysis: dict[str, Any] | None = None, + default_source_kind: str = "custom", + ) -> list[dict[str, Any]]: + normalized_items = self._normalize_requirement_items( + requirement_items=requirement_items, + analysis=analysis, + default_source_kind=default_source_kind, + ) + if not normalized_items: + raise RuntimeError(f"requirement_items_empty:{origin}") + results: list[dict[str, Any]] = [] + seen_keys: set[str] = set() + for index, item in enumerate(normalized_items): + source_payload = { + "source_kind": item["source_kind"] or default_source_kind, + "source_name": item["source_name"], + "source_identifier": item["source_identifier"], + "description": item["description"], + "config": { + "origin": origin, + "origin_text": str(origin_text or "")[:2000], + "requirement_item": { + "section": item["section"], + "title": item["title"], + "description": item["description"], + "priority": item["priority"], + }, + "analysis": analysis if isinstance(analysis, dict) else None, + "priority": item["priority"], + }, + } + normalized_source = self._normalize_source_payload( + source_payload=source_payload, + fallback_text=origin_text, + requested_via=requested_via, + ) + dedupe_key = f"{normalized_source['source_kind']}::{normalized_source['source_identifier']}".strip().lower() + if dedupe_key in seen_keys: + continue + seen_keys.add(dedupe_key) + + raw_context = { + "origin": origin, + "origin_text": str(origin_text or "")[:2000], + "requested_via": requested_via, + "requirement_item": { + "section": item["section"], + "title": item["title"], + "description": item["description"], + "priority": item["priority"], + }, + "analysis": analysis if isinstance(analysis, dict) else None, + "item_index": index, + } + 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=normalized_source, + initiating_text=origin_text, + dry_run=False, + raw_context=raw_context, + ) + results.append( + { + **registry_result, + "section": item["section"], + "title": item["title"], + "description": item["description"], + "priority": item["priority"], + "origin": origin, + "source_kind": registry_result.get("source_kind", normalized_source["source_kind"]), + "source_name": registry_result.get("source_name", normalized_source["source_name"]), + "source_identifier": registry_result.get("source_identifier", normalized_source["source_identifier"]), + } + ) + return results + async def _create_or_reuse_registry( self, *, @@ -14,6 +221,7 @@ class SignalSourceRegistryExecuteMixin: source_payload: dict[str, Any], initiating_text: str, dry_run: bool, + raw_context: dict[str, Any] | None = None, ) -> dict[str, Any]: normalized_source = self._normalize_source_payload( source_payload=source_payload, @@ -43,7 +251,9 @@ class SignalSourceRegistryExecuteMixin: status="pending", onboarding_status="pending", config=normalized_source.get("config") if isinstance(normalized_source.get("config"), dict) else {}, - raw_context={ + raw_context=raw_context + if isinstance(raw_context, dict) + else { "initiating_text": str(initiating_text or "")[:2000], "requested_via": requested_via, }, @@ -85,10 +295,14 @@ class SignalSourceRegistryExecuteMixin: 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, - } + existing.raw_context = ( + raw_context + if isinstance(raw_context, dict) + else { + "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" @@ -294,4 +508,3 @@ class SignalSourceRegistryExecuteMixin: "used_fallback": False, "execution_id": int(execution_row.id) if execution_row is not None else None, } - diff --git a/backend/tests/test_ai_steward_project_from_text_contract.py b/backend/tests/test_ai_steward_project_from_text_contract.py new file mode 100644 index 00000000..fa41239c --- /dev/null +++ b/backend/tests/test_ai_steward_project_from_text_contract.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from app.api.ai_steward_api import routes_capture +from app.core.database import get_db +from app.dependencies.auth import get_current_user +from app.dependencies.tenant import get_tenant_id +from app.services.ai_steward_service import AIStewardService + + +@pytest.mark.asyncio +async def test_project_from_text_returns_enveloped_signal_sources(monkeypatch) -> None: + async def fake_create_project_from_natural_language(self, **kwargs): # noqa: ANN001 + _ = (self, kwargs) + return { + "project": {"id": "11", "name": "AI 客服", "title": "AI 客服平台", "description": "demo", "status": "active"}, + "signal": {"id": "21", "content": "main signal", "impact_score": 0.91}, + "charter": {"id": "31", "title": "宪章", "content": {"summary": "demo"}}, + "tasks": [], + "risks": [], + "analysis": { + "project_name": "AI 客服", + "project_title": "AI 客服平台", + "tech_stack": ["FastAPI", "React"], + "timeline": "3 周", + "primary_role": "backend", + "requirement_items": [ + { + "section": "核心痛点", + "title": "内容本地化", + "description": "翻译公司贵、慢、不地道", + } + ], + }, + "signal_sources": [ + { + "id": "101", + "source_kind": "custom", + "source_name": "核心痛点 - 内容本地化", + "source_identifier": "custom:core-pain-content-localization", + "description": "翻译公司贵、慢、不地道", + "section": "核心痛点", + } + ], + } + + async def _fake_get_db(): + yield object() + + monkeypatch.setattr(AIStewardService, "create_project_from_natural_language", fake_create_project_from_natural_language) + + app = FastAPI() + app.include_router(routes_capture.router) + app.dependency_overrides[get_db] = _fake_get_db + app.dependency_overrides[get_tenant_id] = lambda: 1 + app.dependency_overrides[get_current_user] = lambda: SimpleNamespace( + id=9, + tenant_id=1, + name="管理员", + username="admin", + role="ADMIN", + ) + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post("/ai-steward/project-from-text", json={"input_text": "我们要做一个 AI 客服工具"}) + + assert response.status_code == 200 + payload = response.json() + assert payload["code"] == 0 + assert payload["message"] == "success" + assert payload["data"]["project"]["id"] == "11" + assert payload["data"]["signal_sources"][0]["source_name"] == "核心痛点 - 内容本地化" diff --git a/backend/tests/test_feishu_new_project_signal_sources.py b/backend/tests/test_feishu_new_project_signal_sources.py new file mode 100644 index 00000000..32b7b739 --- /dev/null +++ b/backend/tests/test_feishu_new_project_signal_sources.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from datetime import datetime + +import pytest +import pytest_asyncio +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.models.base import Base +from app.models.project import Project +from app.models.project_signal_link import ProjectSignalLink +from app.models.signal import Signal +from app.models.signal_source_registry import SignalSourceRegistry +from app.models.user import User +from app.models.user_project import UserProject +from app.services.signal.signal_capture_service import ProactiveSignalCaptureService + + +@pytest_asyncio.fixture +async def db_session(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync( + lambda sync_conn: Base.metadata.create_all( + sync_conn, + tables=[ + User.__table__, + Project.__table__, + Signal.__table__, + ProjectSignalLink.__table__, + SignalSourceRegistry.__table__, + UserProject.__table__, + ], + ) + ) + maker = async_sessionmaker(engine, expire_on_commit=False) + async with maker() as session: + yield session + await engine.dispose() + + +@pytest.mark.asyncio +async def test_draft_new_project_brief_raises_when_llm_fails(db_session, monkeypatch) -> None: + service = ProactiveSignalCaptureService(db_session) + + async def fake_generate(prompt: str, **kwargs): # noqa: ANN001 + _ = (prompt, kwargs) + raise RuntimeError("llm boom") + + monkeypatch.setattr(service.llm, "generate", fake_generate) + + with pytest.raises(RuntimeError, match="llm boom"): + await service._draft_new_project_brief( + message="我们要新建一个 AI 客服项目", + tenant_id=1, + sender="Alice", + requested_at=datetime(2026, 3, 26, 12, 0, 0), + ) + + +@pytest.mark.asyncio +async def test_auto_create_project_from_new_signal_persists_signal_sources(db_session, monkeypatch) -> None: + owner = User( + tenant_id=1, + username="owner", + email="owner@example.com", + password_hash="hash", + name="负责人", + role="ADMIN", + status="active", + is_active=True, + ) + signal = Signal( + tenant_id=1, + source="Feishu", + content="请帮我新建项目", + priority="medium", + processed=False, + ) + db_session.add_all([owner, signal]) + await db_session.commit() + + service = ProactiveSignalCaptureService(db_session) + + async def fake_brief(**kwargs): # noqa: ANN001 + _ = kwargs + return { + "title": "AI 客服平台", + "description": "飞书消息里提到的新项目。", + "primary_role": "backend", + "requirement_items": [ + { + "section": "核心痛点", + "title": "内容本地化", + "description": "翻译公司贵、慢、不地道", + }, + { + "section": "MVP 功能", + "title": "社媒运营", + "description": "自动生成文案并排期发布", + }, + ], + } + + monkeypatch.setattr(service, "_draft_new_project_brief", fake_brief) + + project_id = await service._auto_create_project_from_new_signal( + signal_id=int(signal.id), + message="我们要做一个 AI 客服", + sender="Alice", + tenant_id=1, + requested_at=datetime(2026, 3, 26, 12, 0, 0), + ) + await db_session.commit() + + assert project_id is not None + + project = ( + await db_session.execute( + select(Project).where(Project.id == int(project_id), Project.tenant_id == 1) + ) + ).scalar_one() + assert project.title == "AI 客服平台" + + signal_row = ( + await db_session.execute(select(Signal).where(Signal.id == int(signal.id), Signal.tenant_id == 1)) + ).scalar_one() + assert signal_row.processed is True + + registries = ( + await db_session.execute( + select(SignalSourceRegistry).where( + SignalSourceRegistry.project_id == int(project_id), + SignalSourceRegistry.tenant_id == 1, + ) + ) + ).scalars().all() + assert len(registries) == 2 + assert registries[0].raw_context["origin"] == "feishu_new_project" + assert registries[0].source_kind == "feishu" diff --git a/backend/tests/test_signal_source_requirement_materialization.py b/backend/tests/test_signal_source_requirement_materialization.py new file mode 100644 index 00000000..3a541dc7 --- /dev/null +++ b/backend/tests/test_signal_source_requirement_materialization.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import pytest +import pytest_asyncio +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.models.base import Base +from app.models.project import Project +from app.models.signal import Signal +from app.models.signal_source_registry import SignalSourceRegistry +from app.models.user import User +from app.services.signal.signal_source_orchestration import SignalSourceOrchestrationService + + +@pytest_asyncio.fixture +async def db_session(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync( + lambda sync_conn: Base.metadata.create_all( + sync_conn, + tables=[ + User.__table__, + Project.__table__, + Signal.__table__, + SignalSourceRegistry.__table__, + ], + ) + ) + maker = async_sessionmaker(engine, expire_on_commit=False) + async with maker() as session: + yield session + await engine.dispose() + + +@pytest.mark.asyncio +async def test_materialize_requirement_sources_dedupes_duplicate_items(db_session) -> None: + user = User( + tenant_id=1, + username="owner", + email="owner@example.com", + password_hash="hash", + name="负责人", + role="ADMIN", + status="active", + is_active=True, + ) + project = Project( + tenant_id=1, + name="demo-project", + title="Demo Project", + status="active", + assigned_role="backend", + ) + signal = Signal( + tenant_id=1, + source="AI-Steward", + content="main signal", + priority="high", + processed=True, + ) + db_session.add_all([user, project, signal]) + await db_session.commit() + + service = SignalSourceOrchestrationService(db_session) + results = await service.materialize_requirement_sources( + tenant_id=1, + project_id=int(project.id), + signal_id=int(signal.id), + operator_user_id=int(user.id), + requested_via="ai_steward", + origin="project_analysis", + origin_text="我们要做一个 AI 客服工具,包含内容本地化和社媒运营。", + default_source_kind="custom", + requirement_items=[ + { + "section": "核心痛点", + "title": "内容本地化", + "description": "翻译公司贵、慢、不地道", + }, + { + "section": "核心痛点", + "title": "内容本地化", + "description": "翻译公司贵、慢、不地道", + }, + { + "section": "MVP 功能", + "title": "社媒运营", + "description": "自动生成社媒文案并排期发布", + }, + ], + ) + + assert len(results) == 2 + assert results[0]["section"] == "核心痛点" + assert results[0]["source_kind"] == "custom" + assert results[0]["origin"] == "project_analysis" + + rows = (await db_session.execute(select(SignalSourceRegistry).order_by(SignalSourceRegistry.id))).scalars().all() + assert len(rows) == 2 + assert rows[0].raw_context["origin"] == "project_analysis" + assert rows[0].raw_context["requirement_item"]["title"] == "内容本地化" + assert rows[1].raw_context["requirement_item"]["section"] == "MVP 功能" diff --git a/frontend/src/api/aiSteward.ts b/frontend/src/api/aiSteward.ts index 7ea8dc2e..8173a872 100644 --- a/frontend/src/api/aiSteward.ts +++ b/frontend/src/api/aiSteward.ts @@ -9,6 +9,64 @@ function unwrap(raw: ApiEnvelope | T): T { return raw as T; } +export interface ProjectFromTextRequest { + input_text: string; +} + +export interface ProjectFromTextRequirementItem { + section: string; + title: string; + description: string; + priority?: string; + source_kind?: string; + source_name?: string; + source_identifier?: string; +} + +export interface ProjectFromTextSignalSource { + registry_id: number | string; + id?: number | string; + project_id?: number | string; + signal_id?: number | string | null; + source_kind: string; + source_name: string; + source_identifier: string; + description?: string | null; + requested_via?: string; + status?: string; + onboarding_status?: string; + section?: string; + title?: string; + priority?: string; + origin?: string; + raw_context?: Record | null; + config?: Record | null; +} + +export interface ProjectFromTextResponse { + project: { id: string; name: string; title: string; description: string; status: string }; + signal: { id: string; content: string; impact_score: number }; + charter: { id: string; title: string; content: string | Record | null }; + tasks: Array<{ id: string; title: string; type: string; estimated_hours: number; priority: string }>; + risks: Array<{ type: string; description: string; severity: string }>; + signal_sources: ProjectFromTextSignalSource[]; + analysis: { + project_name: string; + project_title?: string; + tech_stack: string[]; + timeline: string; + primary_role: string; + target_audience?: string; + domain_id?: number; + key_features?: string[]; + competitors?: string[]; + success_metrics?: string[]; + core_pain_points?: string[]; + mvp_features?: string[]; + requirement_items?: ProjectFromTextRequirementItem[]; + }; +} + export interface SignalSourceRepairRequest { project_id: number; registry_ids: number[]; @@ -170,4 +228,11 @@ export const aiStewardApi = { }); return unwrap(raw); }, + async createProjectFromText(body: ProjectFromTextRequest): Promise { + const raw = await apiRequest>("/ai-steward/project-from-text", { + method: "POST", + body: JSON.stringify(body), + }); + return unwrap(raw); + }, }; diff --git a/frontend/src/api/base.ts b/frontend/src/api/base.ts index ea89c76d..559b4847 100644 --- a/frontend/src/api/base.ts +++ b/frontend/src/api/base.ts @@ -35,6 +35,28 @@ export interface ApiRequestOptions { headers?: Record } +function extractErrorMessage(payload: unknown, fallback: string): string { + if (payload && typeof payload === "object") { + const record = payload as Record + if (typeof record.message === "string" && record.message.trim()) { + return record.message.trim() + } + if (typeof record.detail === "string" && record.detail.trim()) { + return record.detail.trim() + } + if (record.data && typeof record.data === "object") { + const data = record.data as Record + if (typeof data.message === "string" && data.message.trim()) { + return data.message.trim() + } + if (typeof data.detail === "string" && data.detail.trim()) { + return data.detail.trim() + } + } + } + return fallback +} + export async function apiRequest(endpoint: string, options: ApiRequestOptions = { method: 'GET' }): Promise { const url = `${API_BASE}${endpoint}` const token = getAuthToken() @@ -54,13 +76,20 @@ export async function apiRequest(endpoint: string, options: ApiRequestOptions try { const response = await fetch(url, config) + const payload = await response.json().catch(() => null) if (!response.ok) { - const error = await response.json().catch(() => ({ detail: 'Unknown error' })) - throw new Error(error.detail || `HTTP ${response.status}`) + throw new Error(extractErrorMessage(payload, `HTTP ${response.status}`)) } - return await response.json() + if (payload && typeof payload === "object" && "code" in payload) { + const code = typeof (payload as Record).code === "number" ? (payload as Record).code : 0 + if (code !== 0) { + throw new Error(extractErrorMessage(payload, `HTTP ${response.status}`)) + } + } + + return payload as T } catch (error) { console.error(`API request failed: ${endpoint}`, error) throw error diff --git a/frontend/src/pages/ai-steward/AIStewardPage.tsx b/frontend/src/pages/ai-steward/AIStewardPage.tsx index 8960c5ac..9815ed00 100644 --- a/frontend/src/pages/ai-steward/AIStewardPage.tsx +++ b/frontend/src/pages/ai-steward/AIStewardPage.tsx @@ -1,7 +1,9 @@ import { useState, useEffect } from "react"; +import { useMutation } from "@tanstack/react-query"; import { useNavigate, useSearchParams } from "react-router-dom"; import { Sparkles } from "lucide-react"; import { apiBaseURL, getAuthToken } from "@/lib/api"; +import { getApiErrorMessage } from "@/lib/apiError"; import { Card, CardContent, } from "@/components/ui/card"; @@ -89,7 +91,6 @@ export default function AIStewardPage() { // Project creation state const [input, setInput] = useState(""); - const [loading, setLoading] = useState(false); const [projectResult, setProjectResult] = useState(null); const [error, setError] = useState(null); @@ -127,6 +128,19 @@ export default function AIStewardPage() { const [engineEditingId, setEngineEditingId] = useState(null); const [engineMessage, setEngineMessage] = useState(null); + const projectCreationMutation = useMutation({ + mutationKey: ["ai-steward", "project-from-text"], + mutationFn: async (text: string) => aiStewardApi.createProjectFromText({ input_text: text }), + onSuccess: (data) => { + setProjectResult(data); + setError(null); + }, + onError: (err) => { + setProjectResult(null); + setError(getApiErrorMessage(err, "项目创建失败")); + }, + }); + // ==================== API Calls ==================== const loadConflicts = async () => { @@ -449,42 +463,21 @@ export default function AIStewardPage() { // ==================== Project Creation ==================== const handleProjectSubmit = async () => { - if (!input.trim()) { + const text = input.trim(); + if (!text) { setError("请输入项目描述"); return; } - setLoading(true); setError(null); setProjectResult(null); - try { - const token = getAuthToken(); - if (!token) { - navigate("/login"); - return; - } - - const response = await fetch(`${apiBaseURL}/ai-steward/project-from-text`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ input_text: input }), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || "项目创建失败"); - } - - const data: ProjectFromTextResponse = await response.json(); - setProjectResult(data); - } catch (err) { - setError(err instanceof Error ? err.message : "未知错误"); - } finally { - setLoading(false); + const token = getAuthToken(); + if (!token) { + navigate("/login"); + return; } + + projectCreationMutation.mutate(text); }; const syncUrlParams = ( @@ -601,12 +594,16 @@ export default function AIStewardPage() { setProjectResult(null)} + onResetProject={() => { + setProjectResult(null); + setError(null); + projectCreationMutation.reset(); + }} /> ; + const text = typeof record.rationale === "string" ? record.rationale.trim() : ""; + if (text) { + return text; + } + try { + return JSON.stringify(content, null, 2); + } catch { + return String(content); + } + } + return "-"; +} + +function groupSignalSources(projectResult: ProjectFromTextResponse | null) { + const sources = projectResult?.signal_sources ?? []; + const groups = new Map(); + for (const source of sources) { + const section = String(source.section || "其他").trim() || "其他"; + const bucket = groups.get(section) || []; + bucket.push(source); + groups.set(section, bucket); + } + const preferredOrder = ["核心痛点", "MVP 功能", "其他"]; + return [ + ...preferredOrder.filter((section) => groups.has(section)), + ...Array.from(groups.keys()).filter((section) => !preferredOrder.includes(section)), + ].map((section) => ({ + section, + items: groups.get(section) || [], + })); +} + interface CreateProjectTabProps { input: string; loading: boolean; @@ -27,6 +67,14 @@ export default function CreateProjectTab({ onSubmit, onResetProject, }: CreateProjectTabProps) { + const techStack = Array.isArray(projectResult?.analysis.tech_stack) ? projectResult.analysis.tech_stack : []; + const tasks = Array.isArray(projectResult?.tasks) ? projectResult.tasks : []; + const risks = Array.isArray(projectResult?.risks) ? projectResult.risks : []; + const requirementItems = Array.isArray(projectResult?.analysis.requirement_items) + ? projectResult.analysis.requirement_items + : []; + const signalSourceGroups = groupSignalSources(projectResult); + return ( {!projectResult ? ( @@ -86,7 +134,7 @@ export default function CreateProjectTab({

项目创建成功!

-

AI 已为您生成了完整的项目配置,包含信号分析、宪章、任务链和风险预警

+

AI 已为您生成了完整的项目配置,包含信号分析、信号源、宪章、任务链和风险预警

@@ -116,7 +164,7 @@ export default function CreateProjectTab({
- {projectResult.analysis.tech_stack.map((tech, i) => ( + {techStack.map((tech, i) => ( {tech} ))}
@@ -140,7 +188,9 @@ export default function CreateProjectTab({

{projectResult.charter.title}

-

{projectResult.charter.content}

+
+                    {formatCharterContent(projectResult.charter.content)}
+                  
@@ -149,11 +199,14 @@ export default function CreateProjectTab({ AI 生成的任务链 - 共 {projectResult.tasks.length} 个任务 + + 共 {tasks.length} 个任务 + {requirementItems.length > 0 ? ` · 抽取 ${requirementItems.length} 个需求点` : ""} +
- {projectResult.tasks.map((task, index) => ( + {tasks.map((task, index) => (
{index + 1} @@ -175,6 +228,58 @@ export default function CreateProjectTab({ + {signalSourceGroups.length > 0 && ( + + + 信号源 + 项目创建时,AI 已把需求点直接落成了现有信号源注册项。 + + + {signalSourceGroups.map(({ section, items }) => ( +
+
+

{section}

+ {items.length} +
+
+ {items.map((source) => { + const sourceKey = String(source.registry_id ?? source.id ?? source.source_identifier ?? source.source_name); + return ( +
+
+
+
+
+ {source.title || source.source_name} +
+ {source.source_kind} + {source.origin && {source.origin}} +
+

+ {source.description || "暂无描述"} +

+
+ {source.requested_via && 入口:{source.requested_via}} + {source.registry_id != null && #{source.registry_id}} + {source.source_identifier && ( + 标识:{source.source_identifier} + )} +
+
+ + {toZhPriority(source.priority)} + +
+
+ ); + })} +
+
+ ))} +
+
+ )} + @@ -182,7 +287,7 @@ export default function CreateProjectTab({
- {projectResult.risks.map((risk, index) => ( + {risks.map((risk, index) => (
diff --git a/frontend/src/pages/ai-steward/types.ts b/frontend/src/pages/ai-steward/types.ts index 2becd92f..b3e63dcc 100644 --- a/frontend/src/pages/ai-steward/types.ts +++ b/frontend/src/pages/ai-steward/types.ts @@ -1,4 +1,10 @@ import type { LLMEngine, LLMEngineListResponse } from "@/api/aiSteward"; +export type { + ProjectFromTextRequest, + ProjectFromTextRequirementItem, + ProjectFromTextResponse, + ProjectFromTextSignalSource, +} from "@/api/aiSteward"; export type { FeedbackCenterTrendWindowDays, FeedbackCenterSourceGroup } from "@/api/feedbackCenter"; export type FeedbackCenterSection = "overview" | "metrics" | "trend" | "task" | "qa" | "signal"; @@ -13,15 +19,6 @@ export type AIStewardTab = | "feedback-center" | "engine"; -export interface ProjectFromTextResponse { - project: { id: string; name: string; title: string; description: string; status: string }; - signal: { id: string; content: string; impact_score: number }; - charter: { id: string; title: string; content: string }; - tasks: Array<{ id: string; title: string; type: string; estimated_hours: number; priority: string }>; - risks: Array<{ type: string; description: string; severity: string }>; - analysis: { project_name: string; tech_stack: string[]; timeline: string; primary_role: string }; -} - export interface CognitiveConflict { id: string; type: string; diff --git a/frontend/src/pages/project-board/ProjectBoardPage.tsx b/frontend/src/pages/project-board/ProjectBoardPage.tsx index f66df28e..fc91a7d2 100644 --- a/frontend/src/pages/project-board/ProjectBoardPage.tsx +++ b/frontend/src/pages/project-board/ProjectBoardPage.tsx @@ -298,8 +298,8 @@ export default function ProjectBoardPage() { const compactNewCharterTitle = compactTitle(newCharterTitle, 24); const boardTitle = compactTitle( formatProjectNameWithId({ - id: overview?.project?.id || chain?.project?.id || id, - title: overview?.project?.title || chain?.project?.title || "项目看板", + id: overview?.project?.id ?? chain?.project?.id ?? id ?? "", + title: overview?.project?.title ?? chain?.project?.title ?? "项目看板", }), 24 ); diff --git a/frontend/src/pages/projects/components/DeleteProjectDialog.tsx b/frontend/src/pages/projects/components/DeleteProjectDialog.tsx index c67bdf13..d0a14133 100644 --- a/frontend/src/pages/projects/components/DeleteProjectDialog.tsx +++ b/frontend/src/pages/projects/components/DeleteProjectDialog.tsx @@ -33,8 +33,8 @@ export default function DeleteProjectDialog({ 删除项目「 {formatProjectNameWithId({ - id: project?.id, - title: project?.title, + id: project?.id ?? "", + title: project?.title ?? "", })} 」后不可恢复,关联的链路数据将一并移除。