feat: auto materialize project signal sources

This commit is contained in:
Yuuuu 2026-03-26 17:36:31 +08:00
parent 4ed9c1d59d
commit 2860f11cec
27 changed files with 941 additions and 121 deletions

View File

@ -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 因缺失事件密钥而拒收消息

View File

@ -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",

View File

@ -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)}"
)

View File

@ -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]

View File

@ -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

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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"],
}

View File

@ -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",
},
]

View File

@ -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

View File

@ -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

View File

@ -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,
}

View File

@ -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

View File

@ -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": "新项目摘要生成失败",
}

View File

@ -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,
}

View File

@ -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"] == "核心痛点 - 内容本地化"

View File

@ -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"

View File

@ -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 功能"

View File

@ -9,6 +9,64 @@ function unwrap<T>(raw: ApiEnvelope<T> | 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<string, unknown> | null;
config?: Record<string, unknown> | 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<string, unknown> | 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<ProjectFromTextResponse> {
const raw = await apiRequest<ApiEnvelope<ProjectFromTextResponse>>("/ai-steward/project-from-text", {
method: "POST",
body: JSON.stringify(body),
});
return unwrap(raw);
},
};

View File

@ -35,6 +35,28 @@ export interface ApiRequestOptions {
headers?: Record<string, string>
}
function extractErrorMessage(payload: unknown, fallback: string): string {
if (payload && typeof payload === "object") {
const record = payload as Record<string, unknown>
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<string, unknown>
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<T>(endpoint: string, options: ApiRequestOptions = { method: 'GET' }): Promise<T> {
const url = `${API_BASE}${endpoint}`
const token = getAuthToken()
@ -54,13 +76,20 @@ export async function apiRequest<T>(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<string, unknown>).code === "number" ? (payload as Record<string, unknown>).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

View File

@ -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<ProjectFromTextResponse | null>(null);
const [error, setError] = useState<string | null>(null);
@ -127,6 +128,19 @@ export default function AIStewardPage() {
const [engineEditingId, setEngineEditingId] = useState<string | null>(null);
const [engineMessage, setEngineMessage] = useState<EngineMessage | null>(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() {
<CreateProjectTab
input={input}
loading={loading}
loading={projectCreationMutation.isPending}
error={error}
projectResult={projectResult}
onInputChange={setInput}
onSubmit={handleProjectSubmit}
onResetProject={() => setProjectResult(null)}
onResetProject={() => {
setProjectResult(null);
setError(null);
projectCreationMutation.reset();
}}
/>
<SignalsTab

View File

@ -8,6 +8,46 @@ import { Textarea } from "@/components/ui/textarea";
import { EXAMPLE_INPUTS } from "../constants";
import type { ProjectFromTextResponse } from "../types";
function formatCharterContent(
content: ProjectFromTextResponse["charter"]["content"] | string | null | undefined
): string {
if (typeof content === "string") {
return content.trim() || "-";
}
if (content && typeof content === "object") {
const record = content as Record<string, unknown>;
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<string, typeof sources>();
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 (
<TabsContent value="create" className="mt-6">
{!projectResult ? (
@ -86,7 +134,7 @@ export default function CreateProjectTab({
<CheckCircle2 className="w-6 h-6 text-green-500" />
<div>
<h3 className="font-semibold text-green-900 dark:text-green-100">项目创建成功!</h3>
<p className="text-sm text-green-700 dark:text-green-300">AI 已为您生成了完整的项目配置,包含信号分析、宪章、任务链和风险预警</p>
<p className="text-sm text-green-700 dark:text-green-300">AI 已为您生成了完整的项目配置,包含信号分析、信号源、宪章、任务链和风险预警</p>
</div>
</div>
</CardContent>
@ -116,7 +164,7 @@ export default function CreateProjectTab({
<div>
<label className="text-sm text-slate-500">技术栈</label>
<div className="flex flex-wrap gap-1 mt-1">
{projectResult.analysis.tech_stack.map((tech, i) => (
{techStack.map((tech, i) => (
<Badge key={i} variant="secondary">{tech}</Badge>
))}
</div>
@ -140,7 +188,9 @@ export default function CreateProjectTab({
</CardHeader>
<CardContent>
<h3 className="text-lg font-semibold mb-2">{projectResult.charter.title}</h3>
<p className="whitespace-pre-wrap text-slate-700 dark:text-slate-300">{projectResult.charter.content}</p>
<pre className="whitespace-pre-wrap rounded-lg bg-slate-50 p-4 text-sm leading-6 text-slate-700 dark:bg-slate-800 dark:text-slate-300">
{formatCharterContent(projectResult.charter.content)}
</pre>
</CardContent>
</Card>
</TabsContent>
@ -149,11 +199,14 @@ export default function CreateProjectTab({
<Card>
<CardHeader>
<CardTitle>AI 生成的任务链</CardTitle>
<CardDescription>共 {projectResult.tasks.length} 个任务</CardDescription>
<CardDescription>
共 {tasks.length} 个任务
{requirementItems.length > 0 ? ` · 抽取 ${requirementItems.length} 个需求点` : ""}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{projectResult.tasks.map((task, index) => (
{tasks.map((task, index) => (
<div key={task.id} className="flex items-start gap-3 p-4 bg-slate-50 dark:bg-slate-800 rounded-lg">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-violet-100 dark:bg-violet-900 flex items-center justify-center text-violet-600 dark:text-violet-400 text-xs font-semibold">
{index + 1}
@ -175,6 +228,58 @@ export default function CreateProjectTab({
</Card>
</TabsContent>
{signalSourceGroups.length > 0 && (
<Card>
<CardHeader>
<CardTitle>信号源</CardTitle>
<CardDescription>项目创建时,AI 已把需求点直接落成了现有信号源注册项。</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{signalSourceGroups.map(({ section, items }) => (
<div key={section} className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-slate-800 dark:text-slate-200">{section}</h4>
<Badge variant="outline">{items.length}</Badge>
</div>
<div className="space-y-3">
{items.map((source) => {
const sourceKey = String(source.registry_id ?? source.id ?? source.source_identifier ?? source.source_name);
return (
<div key={sourceKey} className="rounded-xl border border-slate-200 bg-slate-50/80 p-4 dark:border-slate-700 dark:bg-slate-900/40">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h5 className="font-semibold text-slate-900 dark:text-slate-100">
{source.title || source.source_name}
</h5>
<Badge variant="secondary">{source.source_kind}</Badge>
{source.origin && <Badge variant="outline">{source.origin}</Badge>}
</div>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
{source.description || "暂无描述"}
</p>
<div className="mt-3 flex flex-wrap gap-2 text-xs text-slate-500 dark:text-slate-400">
{source.requested_via && <span>入口:{source.requested_via}</span>}
{source.registry_id != null && <span>#{source.registry_id}</span>}
{source.source_identifier && (
<span className="font-mono">标识:{source.source_identifier}</span>
)}
</div>
</div>
<Badge variant={String(source.priority || "").toLowerCase() === "high" ? "destructive" : "outline"}>
{toZhPriority(source.priority)}
</Badge>
</div>
</div>
);
})}
</div>
</div>
))}
</CardContent>
</Card>
)}
<TabsContent value="project-risks">
<Card>
<CardHeader>
@ -182,7 +287,7 @@ export default function CreateProjectTab({
</CardHeader>
<CardContent>
<div className="space-y-3">
{projectResult.risks.map((risk, index) => (
{risks.map((risk, index) => (
<div key={index} className="flex items-start gap-3 p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
<AlertTriangle className={`w-5 h-5 ${String(risk.severity || "").toLowerCase() === "high" ? "text-red-500" : "text-orange-500"}`} />
<div>

View File

@ -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;

View File

@ -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
);

View File

@ -33,8 +33,8 @@ export default function DeleteProjectDialog({
<DialogDescription>
删除项目「
{formatProjectNameWithId({
id: project?.id,
title: project?.title,
id: project?.id ?? "",
title: project?.title ?? "",
})}
」后不可恢复,关联的链路数据将一并移除。
</DialogDescription>