Compare commits
3 Commits
8b85e19b54
...
d4691f1276
| Author | SHA1 | Date |
|---|---|---|
|
|
d4691f1276 | |
|
|
2860f11cec | |
|
|
4ed9c1d59d |
|
|
@ -45,6 +45,7 @@ lerna-debug.log*
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs/
|
logs/
|
||||||
|
|
|
||||||
13
CLAUDE.md
13
CLAUDE.md
|
|
@ -94,6 +94,8 @@ backend/
|
||||||
- 新增:`backend/app/api/audit.py`(审计查询接口:`GET /audit/operations`)
|
- 新增:`backend/app/api/audit.py`(审计查询接口:`GET /audit/operations`)
|
||||||
- 新增:`backend/app/services/audit_query_service/service.py`(operation_logs 过滤与序列化)
|
- 新增:`backend/app/services/audit_query_service/service.py`(operation_logs 过滤与序列化)
|
||||||
- 新增:`backend/app/services/signal_source_repair_service.py`(脏信号源回填服务)
|
- 新增:`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/20260311_shift_all_timestamps_to_beijing.py`(全库历史时间 +8h,数据库默认时区切到 Asia/Shanghai)
|
||||||
- 新增:`backend/alembic/versions/20260312_add_signal_planner_fields.py`(signals 增加 planner 状态字段与索引)
|
- 新增:`backend/alembic/versions/20260312_add_signal_planner_fields.py`(signals 增加 planner 状态字段与索引)
|
||||||
- 新增:`backend/app/services/signal/signal_planner/signal_strategic_analysis_service.py`(Signal 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_service.py`(反馈中心服务回归:tenant/project 过滤、review 修订、映射与原因候选)
|
||||||
- 新增:`backend/tests/test_feedback_center_api.py`(反馈中心 API 回归:路由、权限与三类来源回填)
|
- 新增:`backend/tests/test_feedback_center_api.py`(反馈中心 API 回归:路由、权限与三类来源回填)
|
||||||
- 新增:`backend/tests/test_daily_briefing_service.py`(每日简报回归:北京时间 naive 时间窗、任务更新采集与空响应边界)
|
- 新增:`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/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/schemas/feedback_center.py`(summary 增加 metrics / trend / insights 结构,并回传 `source_group`)
|
||||||
- 新增:`backend/app/services/signal_planner_service.py`(Signal Planner 异步分析服务)
|
- 新增:`backend/app/services/signal_planner_service.py`(Signal Planner 异步分析服务)
|
||||||
|
|
@ -154,6 +159,7 @@ backend/
|
||||||
- 新增:`backend/app/services/model_context_bundle_service.py`(双模型上下文组包/能力汇总/消费验证服务)
|
- 新增:`backend/app/services/model_context_bundle_service.py`(双模型上下文组包/能力汇总/消费验证服务)
|
||||||
- 新增:`backend/app/api/model_routing.py`(模型路由配置接口)
|
- 新增:`backend/app/api/model_routing.py`(模型路由配置接口)
|
||||||
- 新增:`backend/app/api/task_briefs.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/api/knowledge_graph.py`(新增 `capability-summary` 与 `verify-context-consumption` 接口)
|
||||||
- 更新:`backend/app/services/llm.py`(按 function_name 注入 DDL 接口化硬约束,默认直连 GLM 严格失败;本地 Ollama 仅允许知识库问答链路按开关启用)
|
- 更新:`backend/app/services/llm.py`(按 function_name 注入 DDL 接口化硬约束,默认直连 GLM 严格失败;本地 Ollama 仅允许知识库问答链路按开关启用)
|
||||||
- 更新:`backend/app/api/llm_engines.py`(新增 `PUT /ai-steward/llm-engines/settings`,维护本地知识库问答开关)
|
- 更新:`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/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/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/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/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/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/sections/FeedbackCenterTab.tsx`(趋势页钻取联动列表筛选,支持 reason_code / source_type 的局部跳转)
|
||||||
- 更新:`frontend/src/pages/ai-steward/components/feedback-center/FeedbackList.tsx`(列表增加原因筛选器,支持趋势页高频原因钻取)
|
- 更新:`frontend/src/pages/ai-steward/components/feedback-center/FeedbackList.tsx`(列表增加原因筛选器,支持趋势页高频原因钻取)
|
||||||
- 新增:`frontend/src/api/feedbackCenter.ts`(统一反馈中心 API 封装)
|
- 新增:`frontend/src/api/feedbackCenter.ts`(统一反馈中心 API 封装)
|
||||||
- 新增:`frontend/src/pages/ai-steward/AIStewardPage.tsx`(AI Steward 页面编排层,主文件聚焦状态与事件)
|
- 新增:`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/ProjectBoardPage.tsx`(降级为轻量入口,转发到 `pages/project-board/ProjectBoardPage`)
|
||||||
- 新增:`frontend/src/pages/project-board/helpers.ts`(Project Board 信号筛选状态与 PMO 工作项文本归一 helper)
|
- 新增:`frontend/src/pages/project-board/helpers.ts`(Project Board 信号筛选状态与 PMO 工作项文本归一 helper)
|
||||||
- 新增:`frontend/src/pages/project-board/components/CreateCharterDialog.tsx`、`ProjectSettingsDialog.tsx`(Project Board 大体量弹窗组件拆分)
|
- 新增:`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/...`,仅迁移路径允许执行
|
- 运行期禁止 DDL:`database.py` 钩子统一拦截 `CREATE/ALTER/DROP/...`,仅迁移路径允许执行
|
||||||
- 运行环境数据库仅允许 PostgreSQL(`postgresql+asyncpg`);禁止回落 SQLite
|
- 运行环境数据库仅允许 PostgreSQL(`postgresql+asyncpg`);禁止回落 SQLite
|
||||||
- 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回
|
- 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回
|
||||||
|
- 项目创建时自动生成的信号源必须落到现有 `SignalSourceRegistry`,前端仅允许在现有 AI Steward 创建结果区内联展示,禁止新增独立信号源页面或独立持久化模型
|
||||||
- 禁止在 API 层写业务逻辑,业务逻辑归属 `services/`
|
- 禁止在 API 层写业务逻辑,业务逻辑归属 `services/`
|
||||||
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
|
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
|
||||||
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
|
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
|
||||||
|
|
@ -487,9 +497,11 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
## 9. 新增代码的参考模式
|
## 9. 新增代码的参考模式
|
||||||
- 新增后端接口:参考 `backend/app/api/sstc.py` 的结构
|
- 新增后端接口:参考 `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/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`)
|
- 任务包中心接口:参考 `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`)
|
- 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/audit.py`(`GET /audit/operations`)
|
||||||
- 脏数据修复接口:参考 `backend/app/api/ai_steward.py`(`POST /ai-steward/signal-sources/repair-dirty`)
|
- 脏数据修复接口:参考 `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`,仅管理员可修改本地知识库问答开关)
|
- 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(不要幻构行为)
|
## 11. 已知 BOUNDARY(不要幻构行为)
|
||||||
- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用
|
- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用
|
||||||
|
- 项目创建返回的 `signal_sources` 仅作为 AI Steward 创建结果区的内联展示,现阶段不新增独立页面路由;底层持久化统一复用现有 `SignalSourceRegistry`
|
||||||
- 飞书长连接 websocket 在本地/CI 环境默认使用 `certifi` CA bundle 作为信任链;如需自定义 CA,可通过 `FEISHU_LONG_CONNECTION_SSL_CERT_FILE` 或 `SSL_CERT_FILE` 覆盖
|
- 飞书长连接 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_*`
|
- 飞书长连接状态分为两层:`last_received_at/last_received_message_id` 表示 SDK 回调已收到事件,`last_event_at` 表示 worker 完成处理;排查“没吃进去”时必须优先看 `last_received_*`
|
||||||
- 飞书长连接会话应复用 `Integration.config` 中已有的 `encrypt_key/verification_token`;配置保存时若未显式修改这两个字段,必须保留现有值,避免 SDK 因缺失事件密钥而拒收消息
|
- 飞书长连接会话应复用 `Integration.config` 中已有的 `encrypt_key/verification_token`;配置保存时若未显式修改这两个字段,必须保留现有值,避免 SDK 因缺失事件密钥而拒收消息
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,8 @@
|
||||||
"project_draft_analysis_glm47_fallback",
|
"project_draft_analysis_glm47_fallback",
|
||||||
"project_draft_signal_glm47_fallback",
|
"project_draft_signal_glm47_fallback",
|
||||||
"project_draft_charter_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": [
|
"match_any": [
|
||||||
"adaptive_rag_answer",
|
"adaptive_rag_answer",
|
||||||
"classify_intent_complex",
|
"classify_intent_complex",
|
||||||
|
"project_requirement_extraction",
|
||||||
"feishu_project_prefill",
|
"feishu_project_prefill",
|
||||||
"feishu_task_orchestration",
|
"feishu_task_orchestration",
|
||||||
"generate_task_breakdown",
|
"generate_task_breakdown",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||||
from .shared import *
|
from .shared import *
|
||||||
from .schemas import BrowserCaptureRequest, FeishuCaptureRequest, GitHubCaptureRequest, ProjectFromTextRequest, ProjectFromTextResponse
|
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(
|
async def create_project_from_text(
|
||||||
request: ProjectFromTextRequest,
|
request: ProjectFromTextRequest,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
|
@ -34,12 +34,12 @@ async def create_project_from_text(
|
||||||
tenant_id=tenant_id
|
tenant_id=tenant_id
|
||||||
)
|
)
|
||||||
|
|
||||||
return ProjectFromTextResponse(**result)
|
return success_response(ProjectFromTextResponse(**result).model_dump())
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
return JSONResponse(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail=f"项目创建失败: {str(e)}"
|
content=error_response(ErrorCode.INTERNAL_ERROR, f"项目创建失败: {str(e)}"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.post("/signal/capture-feishu")
|
@router.post("/signal/capture-feishu")
|
||||||
|
|
@ -231,4 +231,3 @@ async def sync_external_signal(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail=f"信号同步失败: {str(e)}"
|
detail=f"信号同步失败: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ class ProjectFromTextResponse(BaseModel):
|
||||||
charter: Dict[str, Any]
|
charter: Dict[str, Any]
|
||||||
tasks: List[Dict[str, Any]]
|
tasks: List[Dict[str, Any]]
|
||||||
risks: List[Dict[str, Any]]
|
risks: List[Dict[str, Any]]
|
||||||
|
signal_sources: List[Dict[str, Any]] = Field(default_factory=list)
|
||||||
analysis: Dict[str, Any]
|
analysis: Dict[str, Any]
|
||||||
|
|
||||||
class CognitiveConflictResponse(BaseModel):
|
class CognitiveConflictResponse(BaseModel):
|
||||||
|
|
@ -93,4 +94,3 @@ class WorkflowTriggerRequest(BaseModel):
|
||||||
"""工作流触发请求"""
|
"""工作流触发请求"""
|
||||||
event_type: str
|
event_type: str
|
||||||
event_data: Dict[str, Any]
|
event_data: Dict[str, Any]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,12 @@ from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Dict, List, Literal, Optional
|
from typing import Any, Dict, List, Literal, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import and_, select
|
from sqlalchemy import and_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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 ...core.database import get_db
|
||||||
from ...dependencies.auth import get_current_user
|
from ...dependencies.auth import get_current_user
|
||||||
from ...dependencies.permissions import require_manager_user
|
from ...dependencies.permissions import require_manager_user
|
||||||
|
|
|
||||||
|
|
@ -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",)
|
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",)
|
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",)
|
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_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",)
|
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")
|
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 对象,包含项目名、描述、负责人、时间线与置信度。",
|
output_contract="输出 JSON 对象,包含项目名、描述、负责人、时间线与置信度。",
|
||||||
call_sites=FEISHU_EXTRACT_CALL_SITES,
|
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(
|
_entry(
|
||||||
"PROMPT_FEISHU_TASK_ORCHESTRATION",
|
"PROMPT_FEISHU_TASK_ORCHESTRATION",
|
||||||
module="part_02",
|
module="part_02",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ from .ai_prompts_catalog import (
|
||||||
PROMPT_FEISHU_LEVEL_CLASSIFICATION,
|
PROMPT_FEISHU_LEVEL_CLASSIFICATION,
|
||||||
PROMPT_FEISHU_SIGNAL_EXTRACTION,
|
PROMPT_FEISHU_SIGNAL_EXTRACTION,
|
||||||
PROMPT_FEISHU_PROJECT_PREFILL,
|
PROMPT_FEISHU_PROJECT_PREFILL,
|
||||||
|
PROMPT_PROJECT_REQUIREMENT_EXTRACTION,
|
||||||
PROMPT_FEISHU_TASK_ORCHESTRATION,
|
PROMPT_FEISHU_TASK_ORCHESTRATION,
|
||||||
PROMPT_SIGNAL_SOURCE_ONBOARDING,
|
PROMPT_SIGNAL_SOURCE_ONBOARDING,
|
||||||
PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION,
|
PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION,
|
||||||
|
|
@ -162,6 +163,7 @@ __all__ = [
|
||||||
"PROMPT_FEISHU_LEVEL_CLASSIFICATION",
|
"PROMPT_FEISHU_LEVEL_CLASSIFICATION",
|
||||||
"PROMPT_FEISHU_SIGNAL_EXTRACTION",
|
"PROMPT_FEISHU_SIGNAL_EXTRACTION",
|
||||||
"PROMPT_FEISHU_PROJECT_PREFILL",
|
"PROMPT_FEISHU_PROJECT_PREFILL",
|
||||||
|
"PROMPT_PROJECT_REQUIREMENT_EXTRACTION",
|
||||||
"PROMPT_FEISHU_TASK_ORCHESTRATION",
|
"PROMPT_FEISHU_TASK_ORCHESTRATION",
|
||||||
"PROMPT_SIGNAL_SOURCE_ONBOARDING",
|
"PROMPT_SIGNAL_SOURCE_ONBOARDING",
|
||||||
"PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION",
|
"PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from __future__ import annotations
|
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_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_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_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
|
from .part_scenes_01 import PROMPT_FEISHU_DIRECT_QA
|
||||||
|
|
@ -23,6 +23,7 @@ __all__ = [
|
||||||
"PROMPT_FEISHU_LEVEL_CLASSIFICATION",
|
"PROMPT_FEISHU_LEVEL_CLASSIFICATION",
|
||||||
"PROMPT_FEISHU_SIGNAL_EXTRACTION",
|
"PROMPT_FEISHU_SIGNAL_EXTRACTION",
|
||||||
"PROMPT_FEISHU_PROJECT_PREFILL",
|
"PROMPT_FEISHU_PROJECT_PREFILL",
|
||||||
|
"PROMPT_PROJECT_REQUIREMENT_EXTRACTION",
|
||||||
"PROMPT_FEISHU_TASK_ORCHESTRATION",
|
"PROMPT_FEISHU_TASK_ORCHESTRATION",
|
||||||
"PROMPT_SIGNAL_SOURCE_ONBOARDING",
|
"PROMPT_SIGNAL_SOURCE_ONBOARDING",
|
||||||
"PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION",
|
"PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION",
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,56 @@ PROMPT_FEISHU_PROJECT_PREFILL = _compose_prompt(
|
||||||
JSON_OUTPUT_GUARDRAIL,
|
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 = """
|
PROMPT_FEISHU_TASK_ORCHESTRATION = """
|
||||||
你是 PMFlow 的任务编排与人员分配 AI。
|
你是 PMFlow 的任务编排与人员分配 AI。
|
||||||
请基于飞书需求信号,为指定项目产出“可执行任务 + 负责人建议”。
|
请基于飞书需求信号,为指定项目产出“可执行任务 + 负责人建议”。
|
||||||
|
|
@ -381,6 +431,7 @@ PROMPT_RAG_INTENT_CLASSIFICATION = _compose_prompt(
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"PROMPT_FEISHU_SIGNAL_EXTRACTION",
|
"PROMPT_FEISHU_SIGNAL_EXTRACTION",
|
||||||
"PROMPT_FEISHU_PROJECT_PREFILL",
|
"PROMPT_FEISHU_PROJECT_PREFILL",
|
||||||
|
"PROMPT_PROJECT_REQUIREMENT_EXTRACTION",
|
||||||
"PROMPT_FEISHU_TASK_ORCHESTRATION",
|
"PROMPT_FEISHU_TASK_ORCHESTRATION",
|
||||||
"PROMPT_SIGNAL_SOURCE_ONBOARDING",
|
"PROMPT_SIGNAL_SOURCE_ONBOARDING",
|
||||||
"PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION",
|
"PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION",
|
||||||
|
|
|
||||||
|
|
@ -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_data = normalized["charter"]
|
||||||
charter = TruthNode(
|
charter = TruthNode(
|
||||||
node_type="charter",
|
node_type="charter",
|
||||||
|
|
@ -244,6 +257,7 @@ class AIStewardCreationPersistMixin:
|
||||||
signal_id=int(signal.id),
|
signal_id=int(signal.id),
|
||||||
charter_id=int(charter.id),
|
charter_id=int(charter.id),
|
||||||
persisted_task_ids=persisted_task_ids,
|
persisted_task_ids=persisted_task_ids,
|
||||||
|
signal_source_count=len(signal_sources),
|
||||||
task_audit=normalized["task_audit"],
|
task_audit=normalized["task_audit"],
|
||||||
input_text=input_text[:500],
|
input_text=input_text[:500],
|
||||||
)
|
)
|
||||||
|
|
@ -268,6 +282,6 @@ class AIStewardCreationPersistMixin:
|
||||||
},
|
},
|
||||||
"tasks": persisted_tasks,
|
"tasks": persisted_tasks,
|
||||||
"risks": risks_payload,
|
"risks": risks_payload,
|
||||||
|
"signal_sources": signal_sources,
|
||||||
"analysis": normalized["analysis"],
|
"analysis": normalized["analysis"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,21 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
|
from ...config.ai_prompts import PROMPT_PROJECT_REQUIREMENT_EXTRACTION
|
||||||
|
|
||||||
class AIStewardDraftGenerationMixin:
|
class AIStewardDraftGenerationMixin:
|
||||||
async def _analyze_project_input(self, input_text: str, tenant_id: int) -> Dict[str, Any]:
|
async def _analyze_project_input(self, input_text: str, tenant_id: int) -> Dict[str, Any]:
|
||||||
"""使用 AI 分析项目输入,提取关键信息。"""
|
"""使用 AI 分析项目输入,提取关键信息。"""
|
||||||
prompt = f"""分析以下项目需求,提取关键信息并以 JSON 格式返回:
|
prompt = PROMPT_PROJECT_REQUIREMENT_EXTRACTION.format(
|
||||||
|
source_label="项目描述",
|
||||||
用户输入:{input_text}
|
source_context="AI Steward 零配置项目创建输入",
|
||||||
|
input_text=input_text[:2000],
|
||||||
请提取以下信息:
|
)
|
||||||
{{
|
|
||||||
"project_name": "项目名称(简短)",
|
|
||||||
"project_title": "项目标题",
|
|
||||||
"tech_stack": ["技术栈列表"],
|
|
||||||
"target_audience": "目标用户",
|
|
||||||
"timeline": "时间周期",
|
|
||||||
"primary_role": "主要角色类型(ai_engineer/backend/frontend)",
|
|
||||||
"domain_id": 1,
|
|
||||||
"key_features": ["核心功能列表"],
|
|
||||||
"competitors": ["竞品列表"],
|
|
||||||
"success_metrics": ["成功指标"]
|
|
||||||
}}
|
|
||||||
|
|
||||||
只返回 JSON,不要其他内容。必须返回可被 json.loads 直接解析的单个对象,不要 markdown。"""
|
|
||||||
|
|
||||||
analysis = await self._generate_project_creation_object(
|
analysis = await self._generate_project_creation_object(
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
function_name="project_draft_analysis_glm47_fallback",
|
function_name="project_requirement_extraction_glm47_fallback",
|
||||||
stage_label="AI 管家项目需求分析失败",
|
stage_label="AI 管家项目需求分析失败",
|
||||||
)
|
)
|
||||||
return analysis
|
return analysis
|
||||||
|
|
@ -197,4 +184,3 @@ class AIStewardDraftGenerationMixin:
|
||||||
"severity": "medium",
|
"severity": "medium",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from ...utils.sequence import ensure_project_id_sequence
|
||||||
from ...utils.title import compact_title
|
from ...utils.title import compact_title
|
||||||
from ..adaptive_rag_service import AdaptiveRAGService
|
from ..adaptive_rag_service import AdaptiveRAGService
|
||||||
from ..llm_service import LLMService
|
from ..llm_service import LLMService
|
||||||
|
from ..signal.signal_source_orchestration import SignalSourceOrchestrationService
|
||||||
from ..task_persistence_core import (
|
from ..task_persistence_core import (
|
||||||
build_task_create_draft,
|
build_task_create_draft,
|
||||||
normalize_skill_names,
|
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_signal_glm47_fallback": "project_from_text_signal",
|
||||||
"project_draft_charter_glm47_fallback": "project_from_text_charter",
|
"project_draft_charter_glm47_fallback": "project_from_text_charter",
|
||||||
"project_draft_task_chain_glm47_fallback": "project_from_text_task_chain",
|
"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
|
PROJECT_CREATION_SERIAL_DRAFT_STEPS = _env_int("AI_STEWARD_PROJECT_CREATION_SERIAL_DRAFT_STEPS", 1) == 1
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,11 @@ class CodeAuditorAIScoreMixin:
|
||||||
issues = []
|
issues = []
|
||||||
|
|
||||||
# 构建 AI 审计 prompt
|
# 构建 AI 审计 prompt
|
||||||
|
full_content_block = ""
|
||||||
|
if full_content:
|
||||||
|
full_content_block = (
|
||||||
|
f"完整文件内容:\n```{self._detect_language(file_path)}\n{full_content}\n```"
|
||||||
|
)
|
||||||
prompt = f"""作为一位资深代码审查员,请审计以下代码变更:
|
prompt = f"""作为一位资深代码审查员,请审计以下代码变更:
|
||||||
|
|
||||||
任务需求:{task_name}
|
任务需求:{task_name}
|
||||||
|
|
@ -21,7 +26,7 @@ class CodeAuditorAIScoreMixin:
|
||||||
{file_changes}
|
{file_changes}
|
||||||
```
|
```
|
||||||
|
|
||||||
{f'完整文件内容:\n```{self._detect_language(file_path)}\n{full_content}\n```' if full_content else ''}
|
{full_content_block}
|
||||||
|
|
||||||
请从以下维度进行审查:
|
请从以下维度进行审查:
|
||||||
1. 功能正确性:代码是否正确实现了需求
|
1. 功能正确性:代码是否正确实现了需求
|
||||||
|
|
@ -213,4 +218,3 @@ class CodeAuditorAIScoreMixin:
|
||||||
for issue in issues:
|
for issue in issues:
|
||||||
counts[issue.category] = counts.get(issue.category, 0) + 1
|
counts[issue.category] = counts.get(issue.category, 0) + 1
|
||||||
return counts
|
return counts
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,8 +85,7 @@ class SignalCaptureLegacyMixin:
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
)
|
)
|
||||||
self.db.add(signal)
|
self.db.add(signal)
|
||||||
await self.db.commit()
|
await self.db.flush()
|
||||||
await self.db.refresh(signal)
|
|
||||||
self._emit_ai_think("signal_created", trace_id=trace_id, signal_id=signal.id)
|
self._emit_ai_think("signal_created", trace_id=trace_id, signal_id=signal.id)
|
||||||
|
|
||||||
linked_project_ids: List[int] = []
|
linked_project_ids: List[int] = []
|
||||||
|
|
@ -115,6 +114,9 @@ class SignalCaptureLegacyMixin:
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._emit_ai_think("project_link_failed", trace_id=trace_id, error=str(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]] = []
|
auto_tasks: List[Dict[str, Any]] = []
|
||||||
ai_execution: Dict[str, Any] = {
|
ai_execution: Dict[str, Any] = {
|
||||||
|
|
@ -196,4 +198,3 @@ class SignalCaptureLegacyMixin:
|
||||||
"proposer": proposer_name,
|
"proposer": proposer_name,
|
||||||
"ai_trace_id": trace_id,
|
"ai_trace_id": trace_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
|
from ....config.ai_prompts import PROMPT_PROJECT_REQUIREMENT_EXTRACTION
|
||||||
|
|
||||||
class SignalCaptureNewProjectMixin:
|
class SignalCaptureNewProjectMixin:
|
||||||
async def _auto_create_project_from_new_signal(
|
async def _auto_create_project_from_new_signal(
|
||||||
|
|
@ -83,17 +84,30 @@ class SignalCaptureNewProjectMixin:
|
||||||
)
|
)
|
||||||
self.db.add(link)
|
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(
|
await self.db.execute(
|
||||||
text("UPDATE signals SET processed = :processed WHERE id = :signal_id"),
|
text("UPDATE signals SET processed = :processed WHERE id = :signal_id"),
|
||||||
{"processed": True, "signal_id": int(signal_id)},
|
{"processed": True, "signal_id": int(signal_id)},
|
||||||
)
|
)
|
||||||
await self.db.commit()
|
|
||||||
|
|
||||||
self._emit_ai_think(
|
self._emit_ai_think(
|
||||||
"new_project_created_from_signal",
|
"new_project_created_from_signal",
|
||||||
signal_id=signal_id,
|
signal_id=signal_id,
|
||||||
project_id=int(project.id),
|
project_id=int(project.id),
|
||||||
sender=sender,
|
sender=sender,
|
||||||
|
signal_source_count=len(signal_sources),
|
||||||
)
|
)
|
||||||
return int(project.id)
|
return int(project.id)
|
||||||
|
|
||||||
|
|
@ -106,22 +120,15 @@ class SignalCaptureNewProjectMixin:
|
||||||
requested_at: datetime,
|
requested_at: datetime,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
requested_at_text = self._format_cn_time(requested_at)
|
requested_at_text = self._format_cn_time(requested_at)
|
||||||
prompt = f"""你是 PMFlow 的立项助手。基于下面需求消息,提炼新项目基础信息。
|
prompt = PROMPT_PROJECT_REQUIREMENT_EXTRACTION.format(
|
||||||
|
source_label="飞书消息",
|
||||||
需求提出人:{sender}
|
source_context=(
|
||||||
提出时间:{requested_at_text}
|
f"需求提出人:{sender}\n"
|
||||||
|
f"提出时间:{requested_at_text}\n"
|
||||||
需求消息:
|
"来源:飞书群聊新项目立项"
|
||||||
{message[:1000]}
|
),
|
||||||
|
input_text=message[:1000],
|
||||||
输出 JSON:
|
)
|
||||||
{{
|
|
||||||
"title": "项目标题(15-30字)",
|
|
||||||
"description": "项目简介(80-200字)",
|
|
||||||
"primary_role": "ai_engineer|backend|frontend|devops"
|
|
||||||
}}
|
|
||||||
|
|
||||||
只输出 JSON,不要 markdown,不要解释,不要乱码。"""
|
|
||||||
try:
|
try:
|
||||||
raw = await self.llm.generate(
|
raw = await self.llm.generate(
|
||||||
prompt,
|
prompt,
|
||||||
|
|
@ -133,16 +140,13 @@ class SignalCaptureNewProjectMixin:
|
||||||
role = str(parsed.get("primary_role") or "ai_engineer").strip().lower()
|
role = str(parsed.get("primary_role") or "ai_engineer").strip().lower()
|
||||||
if role not in {"ai_engineer", "backend", "frontend", "devops"}:
|
if role not in {"ai_engineer", "backend", "frontend", "devops"}:
|
||||||
role = "ai_engineer"
|
role = "ai_engineer"
|
||||||
|
requirement_items = parsed.get("requirement_items") if isinstance(parsed.get("requirement_items"), list) else []
|
||||||
return {
|
return {
|
||||||
"title": self._normalize_single_line_text(str(parsed.get("title") or ""), max_len=120),
|
"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),
|
"description": self._normalize_multiline_text(str(parsed.get("description") or ""), max_len=1200),
|
||||||
"primary_role": role,
|
"primary_role": role,
|
||||||
|
"requirement_items": [item for item in requirement_items if isinstance(item, dict)],
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._emit_ai_think("draft_new_project_brief_failed", error=str(exc))
|
self._emit_ai_think("draft_new_project_brief_failed", error=str(exc))
|
||||||
return {
|
raise RuntimeError(f"feishu_new_project_brief_failed:{exc}") from exc
|
||||||
"title": self._normalize_single_line_text(message or "飞书新需求立项", max_len=40),
|
|
||||||
"description": self._normalize_multiline_text(message, max_len=300),
|
|
||||||
"primary_role": "ai_engineer",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,7 @@ AI_THINK_STAGE_ZH = {
|
||||||
"hn_story_captured": "HackerNews 信号已捕获",
|
"hn_story_captured": "HackerNews 信号已捕获",
|
||||||
"hn_monitor_error": "HackerNews 监控异常",
|
"hn_monitor_error": "HackerNews 监控异常",
|
||||||
"new_project_created_from_signal": "已根据信号自动创建项目",
|
"new_project_created_from_signal": "已根据信号自动创建项目",
|
||||||
"draft_new_project_brief_failed": "新项目摘要生成失败,已走兜底",
|
"draft_new_project_brief_failed": "新项目摘要生成失败",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,213 @@ from __future__ import annotations
|
||||||
from .shared import *
|
from .shared import *
|
||||||
|
|
||||||
class SignalSourceRegistryExecuteMixin:
|
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(
|
async def _create_or_reuse_registry(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -14,6 +221,7 @@ class SignalSourceRegistryExecuteMixin:
|
||||||
source_payload: dict[str, Any],
|
source_payload: dict[str, Any],
|
||||||
initiating_text: str,
|
initiating_text: str,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
|
raw_context: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
normalized_source = self._normalize_source_payload(
|
normalized_source = self._normalize_source_payload(
|
||||||
source_payload=source_payload,
|
source_payload=source_payload,
|
||||||
|
|
@ -43,7 +251,9 @@ class SignalSourceRegistryExecuteMixin:
|
||||||
status="pending",
|
status="pending",
|
||||||
onboarding_status="pending",
|
onboarding_status="pending",
|
||||||
config=normalized_source.get("config") if isinstance(normalized_source.get("config"), dict) else {},
|
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],
|
"initiating_text": str(initiating_text or "")[:2000],
|
||||||
"requested_via": requested_via,
|
"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.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.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.config = normalized_source.get("config") if isinstance(normalized_source.get("config"), dict) else existing.config
|
||||||
existing.raw_context = {
|
existing.raw_context = (
|
||||||
|
raw_context
|
||||||
|
if isinstance(raw_context, dict)
|
||||||
|
else {
|
||||||
"initiating_text": str(initiating_text or "")[:2000],
|
"initiating_text": str(initiating_text or "")[:2000],
|
||||||
"requested_via": requested_via,
|
"requested_via": requested_via,
|
||||||
}
|
}
|
||||||
|
)
|
||||||
if not isinstance(existing.config, dict) or not existing.config:
|
if not isinstance(existing.config, dict) or not existing.config:
|
||||||
existing.status = "pending"
|
existing.status = "pending"
|
||||||
existing.onboarding_status = "pending"
|
existing.onboarding_status = "pending"
|
||||||
|
|
@ -294,4 +508,3 @@ class SignalSourceRegistryExecuteMixin:
|
||||||
"used_fallback": False,
|
"used_fallback": False,
|
||||||
"execution_id": int(execution_row.id) if execution_row is not None else None,
|
"execution_id": int(execution_row.id) if execution_row is not None else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,3 +54,6 @@ COMPLETE_WORKER_WAIT_TIMEOUT_SECONDS = 6.0
|
||||||
|
|
||||||
def _utc_now_naive() -> datetime:
|
def _utc_now_naive() -> datetime:
|
||||||
return now_cn_naive()
|
return now_cn_naive()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [name for name in globals() if not name.startswith("__")]
|
||||||
|
|
|
||||||
|
|
@ -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"] == "核心痛点 - 内容本地化"
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from sqlalchemy import JSON, select
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _use_sqlite_json_types() -> None:
|
||||||
|
for table in (User.__table__, SignalSourceRegistry.__table__):
|
||||||
|
for column in table.columns:
|
||||||
|
if column.name in {"assigned_domains", "config", "raw_context"}:
|
||||||
|
column.type = JSON()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def db_session():
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||||
|
_use_sqlite_json_types()
|
||||||
|
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()
|
||||||
|
await db_session.refresh(signal_row)
|
||||||
|
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"
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from sqlalchemy import JSON, select
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _use_sqlite_json_types() -> None:
|
||||||
|
for table in (User.__table__, SignalSourceRegistry.__table__):
|
||||||
|
for column in table.columns:
|
||||||
|
if column.name in {"assigned_domains", "config", "raw_context"}:
|
||||||
|
column.type = JSON()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def db_session():
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||||
|
_use_sqlite_json_types()
|
||||||
|
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 功能"
|
||||||
|
|
@ -9,6 +9,64 @@ function unwrap<T>(raw: ApiEnvelope<T> | T): T {
|
||||||
return raw as 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 {
|
export interface SignalSourceRepairRequest {
|
||||||
project_id: number;
|
project_id: number;
|
||||||
registry_ids: number[];
|
registry_ids: number[];
|
||||||
|
|
@ -170,4 +228,11 @@ export const aiStewardApi = {
|
||||||
});
|
});
|
||||||
return unwrap(raw);
|
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);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,28 @@ export interface ApiRequestOptions {
|
||||||
headers?: Record<string, string>
|
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> {
|
export async function apiRequest<T>(endpoint: string, options: ApiRequestOptions = { method: 'GET' }): Promise<T> {
|
||||||
const url = `${API_BASE}${endpoint}`
|
const url = `${API_BASE}${endpoint}`
|
||||||
const token = getAuthToken()
|
const token = getAuthToken()
|
||||||
|
|
@ -54,13 +76,20 @@ export async function apiRequest<T>(endpoint: string, options: ApiRequestOptions
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, config)
|
const response = await fetch(url, config)
|
||||||
|
const payload = await response.json().catch(() => null)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ detail: 'Unknown error' }))
|
throw new Error(extractErrorMessage(payload, `HTTP ${response.status}`))
|
||||||
throw new Error(error.detail || `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) {
|
} catch (error) {
|
||||||
console.error(`API request failed: ${endpoint}`, error)
|
console.error(`API request failed: ${endpoint}`, error)
|
||||||
throw error
|
throw error
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { Sparkles } from "lucide-react";
|
import { Sparkles } from "lucide-react";
|
||||||
import { apiBaseURL, getAuthToken } from "@/lib/api";
|
import { apiBaseURL, getAuthToken } from "@/lib/api";
|
||||||
|
import { getApiErrorMessage } from "@/lib/apiError";
|
||||||
import {
|
import {
|
||||||
Card, CardContent,
|
Card, CardContent,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
|
@ -89,7 +91,6 @@ export default function AIStewardPage() {
|
||||||
|
|
||||||
// Project creation state
|
// Project creation state
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [projectResult, setProjectResult] = useState<ProjectFromTextResponse | null>(null);
|
const [projectResult, setProjectResult] = useState<ProjectFromTextResponse | null>(null);
|
||||||
const [error, setError] = useState<string | 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 [engineEditingId, setEngineEditingId] = useState<string | null>(null);
|
||||||
const [engineMessage, setEngineMessage] = useState<EngineMessage | 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 ====================
|
// ==================== API Calls ====================
|
||||||
|
|
||||||
const loadConflicts = async () => {
|
const loadConflicts = async () => {
|
||||||
|
|
@ -449,42 +463,21 @@ export default function AIStewardPage() {
|
||||||
// ==================== Project Creation ====================
|
// ==================== Project Creation ====================
|
||||||
|
|
||||||
const handleProjectSubmit = async () => {
|
const handleProjectSubmit = async () => {
|
||||||
if (!input.trim()) {
|
const text = input.trim();
|
||||||
|
if (!text) {
|
||||||
setError("请输入项目描述");
|
setError("请输入项目描述");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
setProjectResult(null);
|
setProjectResult(null);
|
||||||
|
|
||||||
try {
|
|
||||||
const token = getAuthToken();
|
const token = getAuthToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${apiBaseURL}/ai-steward/project-from-text`, {
|
projectCreationMutation.mutate(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 syncUrlParams = (
|
const syncUrlParams = (
|
||||||
|
|
@ -601,12 +594,16 @@ export default function AIStewardPage() {
|
||||||
|
|
||||||
<CreateProjectTab
|
<CreateProjectTab
|
||||||
input={input}
|
input={input}
|
||||||
loading={loading}
|
loading={projectCreationMutation.isPending}
|
||||||
error={error}
|
error={error}
|
||||||
projectResult={projectResult}
|
projectResult={projectResult}
|
||||||
onInputChange={setInput}
|
onInputChange={setInput}
|
||||||
onSubmit={handleProjectSubmit}
|
onSubmit={handleProjectSubmit}
|
||||||
onResetProject={() => setProjectResult(null)}
|
onResetProject={() => {
|
||||||
|
setProjectResult(null);
|
||||||
|
setError(null);
|
||||||
|
projectCreationMutation.reset();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SignalsTab
|
<SignalsTab
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,46 @@ import { Textarea } from "@/components/ui/textarea";
|
||||||
import { EXAMPLE_INPUTS } from "../constants";
|
import { EXAMPLE_INPUTS } from "../constants";
|
||||||
import type { ProjectFromTextResponse } from "../types";
|
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 {
|
interface CreateProjectTabProps {
|
||||||
input: string;
|
input: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
|
@ -27,6 +67,14 @@ export default function CreateProjectTab({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onResetProject,
|
onResetProject,
|
||||||
}: CreateProjectTabProps) {
|
}: 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 (
|
return (
|
||||||
<TabsContent value="create" className="mt-6">
|
<TabsContent value="create" className="mt-6">
|
||||||
{!projectResult ? (
|
{!projectResult ? (
|
||||||
|
|
@ -86,7 +134,7 @@ export default function CreateProjectTab({
|
||||||
<CheckCircle2 className="w-6 h-6 text-green-500" />
|
<CheckCircle2 className="w-6 h-6 text-green-500" />
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-green-900 dark:text-green-100">项目创建成功!</h3>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -116,7 +164,7 @@ export default function CreateProjectTab({
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-slate-500">技术栈</label>
|
<label className="text-sm text-slate-500">技术栈</label>
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
<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>
|
<Badge key={i} variant="secondary">{tech}</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -140,7 +188,9 @@ export default function CreateProjectTab({
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<h3 className="text-lg font-semibold mb-2">{projectResult.charter.title}</h3>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
@ -149,11 +199,14 @@ export default function CreateProjectTab({
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>AI 生成的任务链</CardTitle>
|
<CardTitle>AI 生成的任务链</CardTitle>
|
||||||
<CardDescription>共 {projectResult.tasks.length} 个任务</CardDescription>
|
<CardDescription>
|
||||||
|
共 {tasks.length} 个任务
|
||||||
|
{requirementItems.length > 0 ? ` · 抽取 ${requirementItems.length} 个需求点` : ""}
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3">
|
<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 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">
|
<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}
|
{index + 1}
|
||||||
|
|
@ -175,6 +228,58 @@ export default function CreateProjectTab({
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</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">
|
<TabsContent value="project-risks">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -182,7 +287,7 @@ export default function CreateProjectTab({
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3">
|
<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">
|
<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"}`} />
|
<AlertTriangle className={`w-5 h-5 ${String(risk.severity || "").toLowerCase() === "high" ? "text-red-500" : "text-orange-500"}`} />
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
import type { LLMEngine, LLMEngineListResponse } from "@/api/aiSteward";
|
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 { FeedbackCenterTrendWindowDays, FeedbackCenterSourceGroup } from "@/api/feedbackCenter";
|
||||||
|
|
||||||
export type FeedbackCenterSection = "overview" | "metrics" | "trend" | "task" | "qa" | "signal";
|
export type FeedbackCenterSection = "overview" | "metrics" | "trend" | "task" | "qa" | "signal";
|
||||||
|
|
@ -13,15 +19,6 @@ export type AIStewardTab =
|
||||||
| "feedback-center"
|
| "feedback-center"
|
||||||
| "engine";
|
| "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 {
|
export interface CognitiveConflict {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
|
|
||||||
|
|
@ -298,8 +298,8 @@ export default function ProjectBoardPage() {
|
||||||
const compactNewCharterTitle = compactTitle(newCharterTitle, 24);
|
const compactNewCharterTitle = compactTitle(newCharterTitle, 24);
|
||||||
const boardTitle = compactTitle(
|
const boardTitle = compactTitle(
|
||||||
formatProjectNameWithId({
|
formatProjectNameWithId({
|
||||||
id: overview?.project?.id || chain?.project?.id || id,
|
id: overview?.project?.id ?? chain?.project?.id ?? id ?? "",
|
||||||
title: overview?.project?.title || chain?.project?.title || "项目看板",
|
title: overview?.project?.title ?? chain?.project?.title ?? "项目看板",
|
||||||
}),
|
}),
|
||||||
24
|
24
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,8 @@ export default function DeleteProjectDialog({
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
删除项目「
|
删除项目「
|
||||||
{formatProjectNameWithId({
|
{formatProjectNameWithId({
|
||||||
id: project?.id,
|
id: project?.id ?? "",
|
||||||
title: project?.title,
|
title: project?.title ?? "",
|
||||||
})}
|
})}
|
||||||
」后不可恢复,关联的链路数据将一并移除。
|
」后不可恢复,关联的链路数据将一并移除。
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue