Add scoped trend controls to feedback center
This commit is contained in:
parent
4da49c7fc4
commit
77a094e363
15
CLAUDE.md
15
CLAUDE.md
|
|
@ -135,8 +135,18 @@ backend/
|
||||||
- 新增:`backend/tests/test_draft_generator_service.py`(草稿生成服务回归:PM 评审草稿包含相似项目、MVP、前景与需求清单)
|
- 新增:`backend/tests/test_draft_generator_service.py`(草稿生成服务回归:PM 评审草稿包含相似项目、MVP、前景与需求清单)
|
||||||
- 新增:`backend/tests/test_signal_planner_contract.py`(Signal Planner 契约回归:最新宪章加载、prompt 注入、无宪章降级)
|
- 新增:`backend/tests/test_signal_planner_contract.py`(Signal Planner 契约回归:最新宪章加载、prompt 注入、无宪章降级)
|
||||||
- 新增:`backend/tests/test_ai_prompt_registry_contract.py`(Prompt registry 契约回归:覆盖率与元数据完整性)
|
- 新增:`backend/tests/test_ai_prompt_registry_contract.py`(Prompt registry 契约回归:覆盖率与元数据完整性)
|
||||||
|
- 新增:`backend/tests/test_prompt_profile_bundle_contract.py`(默认角色 prompt bundle 契约回归:PMFlow PMO 语气、职责边界与协作口径)
|
||||||
- 新增:`backend/alembic/versions/20260324_add_project_create_idempotency_indexes.py`(活动项目标题大小写无关唯一索引,收口项目创建重复写入)
|
- 新增:`backend/alembic/versions/20260324_add_project_create_idempotency_indexes.py`(活动项目标题大小写无关唯一索引,收口项目创建重复写入)
|
||||||
- 新增:`backend/alembic/versions/20260326_add_task_breakdown_payload_fields.py`(tasks 拆解业务对象 JSONB 字段:`ai_breakdown_payload/manual_overrides`)
|
- 新增:`backend/alembic/versions/20260326_add_task_breakdown_payload_fields.py`(tasks 拆解业务对象 JSONB 字段:`ai_breakdown_payload/manual_overrides`)
|
||||||
|
- 新增:`backend/alembic/versions/20260326_create_feedback_center_tables.py`(统一反馈学习中心双表建模与 QA/Signal/Task 初始回填)
|
||||||
|
- 新增:`backend/app/api/ai_steward_api/routes_feedback_center.py`(AI Steward 反馈中心接口:summary/list/detail/review/reason-catalog;summary 支持 `window_days` / `source_group` 趋势作用域)
|
||||||
|
- 新增:`backend/app/models/feedback_center.py`(`FeedbackCenterRecord/FeedbackCenterRecordRevision` 统一反馈中心模型)
|
||||||
|
- 新增:`backend/app/schemas/feedback_center.py`(反馈中心 summary/list/detail/review/reason-catalog 对应 Pydantic 模型)
|
||||||
|
- 新增:`backend/app/services/feedback_center/`(反馈中心服务层:`service.py/mappers.py/reason_catalog.py`)
|
||||||
|
- 新增:`backend/tests/test_feedback_center_service.py`(反馈中心服务回归:tenant/project 过滤、review 修订、映射与原因候选)
|
||||||
|
- 新增:`backend/tests/test_feedback_center_api.py`(反馈中心 API 回归:路由、权限与三类来源回填)
|
||||||
|
- 更新:`backend/app/services/feedback_center/service.py`(summary 输出新增 metrics 学习指标,并支持 `window_days` / `source_group` 的趋势分析作用域)
|
||||||
|
- 更新:`backend/app/schemas/feedback_center.py`(summary 增加 metrics / trend 结构,并回传 `source_group`)
|
||||||
- 新增:`backend/app/services/signal_planner_service.py`(Signal Planner 异步分析服务)
|
- 新增:`backend/app/services/signal_planner_service.py`(Signal Planner 异步分析服务)
|
||||||
- 新增:`backend/app/services/model/model_routing/service.py`(函数级模型路由配置服务)
|
- 新增:`backend/app/services/model/model_routing/service.py`(函数级模型路由配置服务)
|
||||||
- 新增:`backend/app/services/task_brief_service.py`(增量任务包中心服务)
|
- 新增:`backend/app/services/task_brief_service.py`(增量任务包中心服务)
|
||||||
|
|
@ -346,7 +356,9 @@ frontend/
|
||||||
- 更新:`frontend/src/pages/AIStewardPage.tsx`(降级为轻量入口,转发到 `pages/ai-steward/AIStewardPage`)
|
- 更新:`frontend/src/pages/AIStewardPage.tsx`(降级为轻量入口,转发到 `pages/ai-steward/AIStewardPage`)
|
||||||
- 新增:`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`(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/components/feedback-center/FeedbackList.tsx`、`FeedbackDetail.tsx`、`ReasonPicker.tsx`、`MetricsPanel.tsx`、`TrendPanel.tsx`(反馈中心三栏工作台、指标页与趋势页组件;趋势页支持 `7/30 天` 与 `all/task/qa/signal` 局部筛选)
|
||||||
|
- 新增:`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/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)
|
||||||
|
|
@ -377,6 +389,7 @@ frontend/
|
||||||
- `SSTCReleaseCreateDialog.tsx`:发布创建弹窗
|
- `SSTCReleaseCreateDialog.tsx`:发布创建弹窗
|
||||||
- `SSTCReviewCreateDialog.tsx`:复盘创建弹窗
|
- `SSTCReviewCreateDialog.tsx`:复盘创建弹窗
|
||||||
- `AIVisibilityPanel.tsx`:AI 可视化信息面板
|
- `AIVisibilityPanel.tsx`:AI 可视化信息面板
|
||||||
|
- `FeedbackSummaryCard.tsx`:反馈中心摘要卡片(任务阶段/执行看板复用)
|
||||||
- `SSTCGlobalHeader.tsx`:SSTC 全局头部
|
- `SSTCGlobalHeader.tsx`:SSTC 全局头部
|
||||||
- `SSTCStageNav.tsx`:阶段导航
|
- `SSTCStageNav.tsx`:阶段导航
|
||||||
- `SSTCChainView.tsx`:链路视图
|
- `SSTCChainView.tsx`:链路视图
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,542 @@
|
||||||
|
"""create feedback center tables
|
||||||
|
|
||||||
|
Revision ID: 20260326_create_feedback_center_tables
|
||||||
|
Revises: 20260326_add_task_breakdown_payload_fields
|
||||||
|
Create Date: 2026-03-26
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
try:
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
JSONB = sa.JSON
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260326_create_feedback_center_tables"
|
||||||
|
down_revision = "20260326_add_task_breakdown_payload_fields"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _json_type(bind):
|
||||||
|
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(bind, table_name: str) -> bool:
|
||||||
|
return sa.inspect(bind).has_table(table_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if not inspector.has_table(table_name):
|
||||||
|
return False
|
||||||
|
return any(str(item.get("name") or "") == index_name for item in inspector.get_indexes(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_int(value: Any) -> int | None:
|
||||||
|
try:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json_if_needed(value: Any) -> Any:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, (dict, list)):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
return json.loads(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _build_reason_candidates(*, source_type: str, field_name: str, before_value: Any, after_value: Any) -> list[dict[str, str]]:
|
||||||
|
candidates: list[dict[str, str]] = []
|
||||||
|
field = str(field_name or "").strip().lower()
|
||||||
|
source = str(source_type or "").strip().lower()
|
||||||
|
|
||||||
|
def _add(code: str, label: str, description: str) -> None:
|
||||||
|
if any(str(item.get("code") or "") == code for item in candidates):
|
||||||
|
return
|
||||||
|
candidates.append({"code": code, "label": label, "description": description})
|
||||||
|
|
||||||
|
if before_value not in (None, "", [], {}) and after_value in (None, "", [], {}):
|
||||||
|
_add("incomplete_output", "输出信息不完整", "模型输出缺失关键信息字段")
|
||||||
|
if "estimated" in field or "hours" in field:
|
||||||
|
_add("effort_estimation_bias", "工时估算偏差", "任务工时估算与实际认知偏差较大")
|
||||||
|
if "priority" in field:
|
||||||
|
_add("priority_misalignment", "优先级错配", "优先级建议与业务紧急度不匹配")
|
||||||
|
if "assignee" in field or "skill" in field:
|
||||||
|
_add("capability_mismatch", "能力匹配偏差", "人员能力与任务推荐存在偏差")
|
||||||
|
if "strategic" in field or source == "signal_feedback":
|
||||||
|
_add("strategic_judgement_mismatch", "战略判断偏差", "战略属性判定与人工判断不一致")
|
||||||
|
if source == "qa_feedback":
|
||||||
|
_add("answer_incorrect", "答案不准确", "AI 输出与业务事实不一致")
|
||||||
|
if not candidates:
|
||||||
|
_add("manual_override", "人工覆盖", "由人工介入修改后形成的反馈记录")
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _record_exists(
|
||||||
|
bind,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
source_type: str,
|
||||||
|
source_id: int,
|
||||||
|
object_type: str,
|
||||||
|
object_id: int,
|
||||||
|
field_name: str,
|
||||||
|
) -> bool:
|
||||||
|
row = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM feedback_center_records
|
||||||
|
WHERE tenant_id = :tenant_id
|
||||||
|
AND source_type = :source_type
|
||||||
|
AND source_id = :source_id
|
||||||
|
AND object_type = :object_type
|
||||||
|
AND object_id = :object_id
|
||||||
|
AND field_name = :field_name
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"tenant_id": int(tenant_id),
|
||||||
|
"source_type": str(source_type),
|
||||||
|
"source_id": int(source_id),
|
||||||
|
"object_type": str(object_type),
|
||||||
|
"object_id": int(object_id),
|
||||||
|
"field_name": str(field_name),
|
||||||
|
},
|
||||||
|
).first()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_record(bind, payload: dict[str, Any]) -> None:
|
||||||
|
bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO feedback_center_records (
|
||||||
|
tenant_id,
|
||||||
|
project_id,
|
||||||
|
source_type,
|
||||||
|
source_id,
|
||||||
|
object_type,
|
||||||
|
object_id,
|
||||||
|
field_name,
|
||||||
|
before_value,
|
||||||
|
after_value,
|
||||||
|
ai_snapshot,
|
||||||
|
ai_confidence,
|
||||||
|
reason_candidates,
|
||||||
|
reason_code,
|
||||||
|
reason_text,
|
||||||
|
review_status,
|
||||||
|
reviewed_by,
|
||||||
|
reviewed_at,
|
||||||
|
source_title,
|
||||||
|
object_title,
|
||||||
|
auto_assigned
|
||||||
|
) VALUES (
|
||||||
|
:tenant_id,
|
||||||
|
:project_id,
|
||||||
|
:source_type,
|
||||||
|
:source_id,
|
||||||
|
:object_type,
|
||||||
|
:object_id,
|
||||||
|
:field_name,
|
||||||
|
:before_value,
|
||||||
|
:after_value,
|
||||||
|
:ai_snapshot,
|
||||||
|
:ai_confidence,
|
||||||
|
:reason_candidates,
|
||||||
|
:reason_code,
|
||||||
|
:reason_text,
|
||||||
|
:review_status,
|
||||||
|
:reviewed_by,
|
||||||
|
:reviewed_at,
|
||||||
|
:source_title,
|
||||||
|
:object_title,
|
||||||
|
:auto_assigned
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _backfill_from_qa_feedback(bind) -> None:
|
||||||
|
if not _table_exists(bind, "qa_feedback") or not _table_exists(bind, "qa_records"):
|
||||||
|
return
|
||||||
|
rows = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
f.id AS feedback_id,
|
||||||
|
f.tenant_id AS tenant_id,
|
||||||
|
f.qa_id AS qa_id,
|
||||||
|
f.rating AS rating,
|
||||||
|
f.helpfulness AS helpfulness,
|
||||||
|
f.error_types AS error_types,
|
||||||
|
f.correct_answer AS correct_answer,
|
||||||
|
f.submitted_by AS submitted_by,
|
||||||
|
f.created_at AS created_at,
|
||||||
|
r.project_id AS project_id,
|
||||||
|
r.question AS question,
|
||||||
|
r.answer AS answer
|
||||||
|
FROM qa_feedback f
|
||||||
|
JOIN qa_records r ON r.id = f.qa_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
).mappings().all()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
tenant_id = _safe_int(row.get("tenant_id"))
|
||||||
|
source_id = _safe_int(row.get("feedback_id"))
|
||||||
|
object_id = _safe_int(row.get("qa_id"))
|
||||||
|
if tenant_id is None or source_id is None or object_id is None:
|
||||||
|
continue
|
||||||
|
if _record_exists(
|
||||||
|
bind,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_type="qa_feedback",
|
||||||
|
source_id=source_id,
|
||||||
|
object_type="qa_record",
|
||||||
|
object_id=object_id,
|
||||||
|
field_name="qa_feedback",
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
before_value = {"answer": row.get("answer")}
|
||||||
|
after_value = {
|
||||||
|
"rating": row.get("rating"),
|
||||||
|
"helpfulness": row.get("helpfulness"),
|
||||||
|
"error_types": _parse_json_if_needed(row.get("error_types")),
|
||||||
|
"correct_answer": row.get("correct_answer"),
|
||||||
|
}
|
||||||
|
candidates = _build_reason_candidates(
|
||||||
|
source_type="qa_feedback",
|
||||||
|
field_name="qa_feedback",
|
||||||
|
before_value=before_value,
|
||||||
|
after_value=after_value,
|
||||||
|
)
|
||||||
|
_insert_record(
|
||||||
|
bind,
|
||||||
|
{
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"project_id": _safe_int(row.get("project_id")),
|
||||||
|
"source_type": "qa_feedback",
|
||||||
|
"source_id": source_id,
|
||||||
|
"object_type": "qa_record",
|
||||||
|
"object_id": object_id,
|
||||||
|
"field_name": "qa_feedback",
|
||||||
|
"before_value": before_value,
|
||||||
|
"after_value": after_value,
|
||||||
|
"ai_snapshot": {
|
||||||
|
"question": row.get("question"),
|
||||||
|
"answer": row.get("answer"),
|
||||||
|
},
|
||||||
|
"ai_confidence": None,
|
||||||
|
"reason_candidates": candidates,
|
||||||
|
"reason_code": candidates[0]["code"] if candidates else None,
|
||||||
|
"reason_text": row.get("correct_answer"),
|
||||||
|
"review_status": "reviewed",
|
||||||
|
"reviewed_by": _safe_int(row.get("submitted_by")),
|
||||||
|
"reviewed_at": row.get("created_at"),
|
||||||
|
"source_title": str(row.get("question") or "")[:500] or None,
|
||||||
|
"object_title": str(row.get("question") or "")[:500] or None,
|
||||||
|
"auto_assigned": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _backfill_from_signal_feedback(bind) -> None:
|
||||||
|
if not _table_exists(bind, "signal_feedbacks") or not _table_exists(bind, "signals"):
|
||||||
|
return
|
||||||
|
rows = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
f.id AS feedback_id,
|
||||||
|
f.tenant_id AS tenant_id,
|
||||||
|
f.signal_id AS signal_id,
|
||||||
|
f.was_strategic AS was_strategic,
|
||||||
|
f.reason AS reason,
|
||||||
|
f.ai_predicted_strategic AS ai_predicted_strategic,
|
||||||
|
f.ai_confidence AS ai_confidence,
|
||||||
|
f.user_id AS user_id,
|
||||||
|
f.created_at AS created_at,
|
||||||
|
s.matched_project_id AS project_id,
|
||||||
|
s.title AS signal_title
|
||||||
|
FROM signal_feedbacks f
|
||||||
|
JOIN signals s ON s.id = f.signal_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
).mappings().all()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
tenant_id = _safe_int(row.get("tenant_id"))
|
||||||
|
source_id = _safe_int(row.get("feedback_id"))
|
||||||
|
object_id = _safe_int(row.get("signal_id"))
|
||||||
|
if tenant_id is None or source_id is None or object_id is None:
|
||||||
|
continue
|
||||||
|
if _record_exists(
|
||||||
|
bind,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_type="signal_feedback",
|
||||||
|
source_id=source_id,
|
||||||
|
object_type="signal",
|
||||||
|
object_id=object_id,
|
||||||
|
field_name="strategic_classification",
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
before_value = {
|
||||||
|
"ai_predicted_strategic": row.get("ai_predicted_strategic"),
|
||||||
|
}
|
||||||
|
after_value = {
|
||||||
|
"was_strategic": row.get("was_strategic"),
|
||||||
|
"reason": row.get("reason"),
|
||||||
|
}
|
||||||
|
candidates = _build_reason_candidates(
|
||||||
|
source_type="signal_feedback",
|
||||||
|
field_name="strategic_classification",
|
||||||
|
before_value=before_value,
|
||||||
|
after_value=after_value,
|
||||||
|
)
|
||||||
|
_insert_record(
|
||||||
|
bind,
|
||||||
|
{
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"project_id": _safe_int(row.get("project_id")),
|
||||||
|
"source_type": "signal_feedback",
|
||||||
|
"source_id": source_id,
|
||||||
|
"object_type": "signal",
|
||||||
|
"object_id": object_id,
|
||||||
|
"field_name": "strategic_classification",
|
||||||
|
"before_value": before_value,
|
||||||
|
"after_value": after_value,
|
||||||
|
"ai_snapshot": {
|
||||||
|
"ai_predicted_strategic": row.get("ai_predicted_strategic"),
|
||||||
|
"ai_confidence": row.get("ai_confidence"),
|
||||||
|
},
|
||||||
|
"ai_confidence": row.get("ai_confidence"),
|
||||||
|
"reason_candidates": candidates,
|
||||||
|
"reason_code": candidates[0]["code"] if candidates else None,
|
||||||
|
"reason_text": row.get("reason"),
|
||||||
|
"review_status": "reviewed",
|
||||||
|
"reviewed_by": _safe_int(row.get("user_id")),
|
||||||
|
"reviewed_at": row.get("created_at"),
|
||||||
|
"source_title": str(row.get("signal_title") or "")[:500] or None,
|
||||||
|
"object_title": str(row.get("signal_title") or "")[:500] or None,
|
||||||
|
"auto_assigned": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _backfill_from_task_overrides(bind) -> None:
|
||||||
|
if not _table_exists(bind, "tasks"):
|
||||||
|
return
|
||||||
|
rows = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
t.id AS task_id,
|
||||||
|
t.tenant_id AS tenant_id,
|
||||||
|
t.project_id AS project_id,
|
||||||
|
t.title AS task_title,
|
||||||
|
t.ai_breakdown_payload AS ai_breakdown_payload,
|
||||||
|
t.manual_overrides AS manual_overrides,
|
||||||
|
t.updated_at AS updated_at
|
||||||
|
FROM tasks t
|
||||||
|
WHERE t.manual_overrides IS NOT NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
).mappings().all()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
tenant_id = _safe_int(row.get("tenant_id"))
|
||||||
|
task_id = _safe_int(row.get("task_id"))
|
||||||
|
if tenant_id is None or task_id is None:
|
||||||
|
continue
|
||||||
|
overrides = _parse_json_if_needed(row.get("manual_overrides"))
|
||||||
|
ai_payload = _parse_json_if_needed(row.get("ai_breakdown_payload"))
|
||||||
|
if not isinstance(overrides, dict) or not overrides:
|
||||||
|
continue
|
||||||
|
if not isinstance(ai_payload, dict):
|
||||||
|
ai_payload = {}
|
||||||
|
|
||||||
|
for field_name in sorted(overrides.keys()):
|
||||||
|
if _record_exists(
|
||||||
|
bind,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_type="task_override",
|
||||||
|
source_id=task_id,
|
||||||
|
object_type="task",
|
||||||
|
object_id=task_id,
|
||||||
|
field_name=str(field_name),
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
before_value = ai_payload.get(field_name)
|
||||||
|
after_value = overrides.get(field_name)
|
||||||
|
candidates = _build_reason_candidates(
|
||||||
|
source_type="task_override",
|
||||||
|
field_name=str(field_name),
|
||||||
|
before_value=before_value,
|
||||||
|
after_value=after_value,
|
||||||
|
)
|
||||||
|
_insert_record(
|
||||||
|
bind,
|
||||||
|
{
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"project_id": _safe_int(row.get("project_id")),
|
||||||
|
"source_type": "task_override",
|
||||||
|
"source_id": task_id,
|
||||||
|
"object_type": "task",
|
||||||
|
"object_id": task_id,
|
||||||
|
"field_name": str(field_name)[:128],
|
||||||
|
"before_value": before_value,
|
||||||
|
"after_value": after_value,
|
||||||
|
"ai_snapshot": ai_payload,
|
||||||
|
"ai_confidence": None,
|
||||||
|
"reason_candidates": candidates,
|
||||||
|
"reason_code": candidates[0]["code"] if candidates else None,
|
||||||
|
"reason_text": None,
|
||||||
|
"review_status": "reviewed",
|
||||||
|
"reviewed_by": None,
|
||||||
|
"reviewed_at": row.get("updated_at"),
|
||||||
|
"source_title": str(row.get("task_title") or "")[:500] or None,
|
||||||
|
"object_title": str(row.get("task_title") or "")[:500] or None,
|
||||||
|
"auto_assigned": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
json_type = _json_type(bind)
|
||||||
|
|
||||||
|
if not _table_exists(bind, "feedback_center_records"):
|
||||||
|
op.create_table(
|
||||||
|
"feedback_center_records",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("project_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("source_type", sa.VARCHAR(length=32), nullable=False),
|
||||||
|
sa.Column("source_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("object_type", sa.VARCHAR(length=32), nullable=False),
|
||||||
|
sa.Column("object_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("field_name", sa.VARCHAR(length=128), nullable=False),
|
||||||
|
sa.Column("before_value", json_type, nullable=True),
|
||||||
|
sa.Column("after_value", json_type, nullable=True),
|
||||||
|
sa.Column("ai_snapshot", json_type, nullable=True),
|
||||||
|
sa.Column("ai_confidence", sa.Float(), nullable=True),
|
||||||
|
sa.Column("reason_candidates", json_type, nullable=True),
|
||||||
|
sa.Column("reason_code", sa.VARCHAR(length=64), nullable=True),
|
||||||
|
sa.Column("reason_text", sa.Text(), nullable=True),
|
||||||
|
sa.Column("review_status", sa.VARCHAR(length=16), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("reviewed_by", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("reviewed_at", sa.TIMESTAMP(), nullable=True),
|
||||||
|
sa.Column("source_title", sa.VARCHAR(length=500), nullable=True),
|
||||||
|
sa.Column("object_title", sa.VARCHAR(length=500), nullable=True),
|
||||||
|
sa.Column("auto_assigned", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("provider", sa.VARCHAR(length=32), nullable=True),
|
||||||
|
sa.Column("model_name", sa.VARCHAR(length=128), nullable=True),
|
||||||
|
sa.Column("prompt_version", sa.VARCHAR(length=64), nullable=True),
|
||||||
|
sa.Column("schema_version", sa.VARCHAR(length=64), nullable=True),
|
||||||
|
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.text("now()")),
|
||||||
|
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.text("now()")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"source_type",
|
||||||
|
"source_id",
|
||||||
|
"object_type",
|
||||||
|
"object_id",
|
||||||
|
"field_name",
|
||||||
|
name="uq_feedback_center_records_source_object_field",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _table_exists(bind, "feedback_center_record_revisions"):
|
||||||
|
op.create_table(
|
||||||
|
"feedback_center_record_revisions",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("record_id", sa.Integer(), sa.ForeignKey("feedback_center_records.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("revision_no", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("before_snapshot", json_type, nullable=False),
|
||||||
|
sa.Column("after_snapshot", json_type, nullable=False),
|
||||||
|
sa.Column("reason_code", sa.VARCHAR(length=64), nullable=True),
|
||||||
|
sa.Column("reason_text", sa.Text(), nullable=True),
|
||||||
|
sa.Column("review_status", sa.VARCHAR(length=16), nullable=False, server_default="reviewed"),
|
||||||
|
sa.Column("reviewed_by", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("reviewed_at", sa.TIMESTAMP(), nullable=True),
|
||||||
|
sa.Column("change_summary", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.text("now()")),
|
||||||
|
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.text("now()")),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _index_exists(bind, "feedback_center_records", "ix_feedback_center_records_tenant_project_status"):
|
||||||
|
op.create_index(
|
||||||
|
"ix_feedback_center_records_tenant_project_status",
|
||||||
|
"feedback_center_records",
|
||||||
|
["tenant_id", "project_id", "review_status"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
if not _index_exists(bind, "feedback_center_records", "ix_feedback_center_records_tenant_source"):
|
||||||
|
op.create_index(
|
||||||
|
"ix_feedback_center_records_tenant_source",
|
||||||
|
"feedback_center_records",
|
||||||
|
["tenant_id", "source_type", "source_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
if not _index_exists(bind, "feedback_center_records", "ix_feedback_center_records_reason_code"):
|
||||||
|
op.create_index(
|
||||||
|
"ix_feedback_center_records_reason_code",
|
||||||
|
"feedback_center_records",
|
||||||
|
["reason_code"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
if not _index_exists(bind, "feedback_center_record_revisions", "ix_feedback_center_record_revisions_tenant_record"):
|
||||||
|
op.create_index(
|
||||||
|
"ix_feedback_center_record_revisions_tenant_record",
|
||||||
|
"feedback_center_record_revisions",
|
||||||
|
["tenant_id", "record_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
_backfill_from_qa_feedback(bind)
|
||||||
|
_backfill_from_signal_feedback(bind)
|
||||||
|
_backfill_from_task_overrides(bind)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
if _table_exists(bind, "feedback_center_record_revisions"):
|
||||||
|
if _index_exists(bind, "feedback_center_record_revisions", "ix_feedback_center_record_revisions_tenant_record"):
|
||||||
|
op.drop_index(
|
||||||
|
"ix_feedback_center_record_revisions_tenant_record",
|
||||||
|
table_name="feedback_center_record_revisions",
|
||||||
|
)
|
||||||
|
op.drop_table("feedback_center_record_revisions")
|
||||||
|
|
||||||
|
if _table_exists(bind, "feedback_center_records"):
|
||||||
|
for index_name in (
|
||||||
|
"ix_feedback_center_records_reason_code",
|
||||||
|
"ix_feedback_center_records_tenant_source",
|
||||||
|
"ix_feedback_center_records_tenant_project_status",
|
||||||
|
):
|
||||||
|
if _index_exists(bind, "feedback_center_records", index_name):
|
||||||
|
op.drop_index(index_name, table_name="feedback_center_records")
|
||||||
|
op.drop_table("feedback_center_records")
|
||||||
|
|
@ -6,6 +6,7 @@ from . import (
|
||||||
routes_brief_blockers,
|
routes_brief_blockers,
|
||||||
routes_capture,
|
routes_capture,
|
||||||
routes_conflicts,
|
routes_conflicts,
|
||||||
|
routes_feedback_center,
|
||||||
routes_rag_sources,
|
routes_rag_sources,
|
||||||
routes_stats_config,
|
routes_stats_config,
|
||||||
routes_workflow,
|
routes_workflow,
|
||||||
|
|
@ -16,6 +17,7 @@ for _module in [
|
||||||
routes_rag_sources,
|
routes_rag_sources,
|
||||||
routes_capture,
|
routes_capture,
|
||||||
routes_conflicts,
|
routes_conflicts,
|
||||||
|
routes_feedback_center,
|
||||||
routes_brief_blockers,
|
routes_brief_blockers,
|
||||||
routes_stats_config,
|
routes_stats_config,
|
||||||
routes_workflow,
|
routes_workflow,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import and_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ...core.response import success_response
|
||||||
|
from ...dependencies.permissions import require_manager_user
|
||||||
|
from ...models import User, UserProject
|
||||||
|
from ...schemas.feedback_center import FeedbackCenterReviewRequest
|
||||||
|
from ...services.auth import parse_role_values
|
||||||
|
from ...services.feedback_center import FeedbackCenterService, list_reason_catalog
|
||||||
|
from .shared import *
|
||||||
|
|
||||||
|
MANAGER_ROLES = {"ADMIN", "BOSS"}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_manager_user(current_user: User) -> bool:
|
||||||
|
role_value = getattr(current_user, "role", "")
|
||||||
|
if isinstance(role_value, Enum):
|
||||||
|
role_value = role_value.value
|
||||||
|
roles = set(parse_role_values(str(role_value or "")))
|
||||||
|
return bool(roles.intersection(MANAGER_ROLES))
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_summary_scope(
|
||||||
|
*,
|
||||||
|
current_user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
project_id: int | None,
|
||||||
|
) -> None:
|
||||||
|
if _is_manager_user(current_user):
|
||||||
|
return
|
||||||
|
if project_id is None:
|
||||||
|
raise HTTPException(status_code=403, detail="Manager permission required for global feedback center")
|
||||||
|
membership = await db.execute(
|
||||||
|
select(UserProject.id).where(
|
||||||
|
and_(
|
||||||
|
UserProject.tenant_id == int(current_user.tenant_id),
|
||||||
|
UserProject.project_id == int(project_id),
|
||||||
|
UserProject.user_id == int(current_user.id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if membership.scalar_one_or_none() is None:
|
||||||
|
raise HTTPException(status_code=403, detail="Project membership required")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/feedback-center/summary")
|
||||||
|
async def get_feedback_center_summary(
|
||||||
|
project_id: int | None = Query(default=None),
|
||||||
|
window_days: int | None = Query(default=None),
|
||||||
|
source_group: str | None = Query(default=None),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||||
|
await _ensure_summary_scope(current_user=current_user, db=db, project_id=project_id)
|
||||||
|
try:
|
||||||
|
data = await FeedbackCenterService(db).summary(
|
||||||
|
tenant_id=effective_tenant_id,
|
||||||
|
project_id=project_id,
|
||||||
|
window_days=window_days,
|
||||||
|
source_group=source_group,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/feedback-center/items")
|
||||||
|
async def list_feedback_center_items(
|
||||||
|
project_id: int | None = Query(default=None),
|
||||||
|
source_type: str | None = Query(default=None),
|
||||||
|
review_status: str | None = Query(default=None),
|
||||||
|
field_name: str | None = Query(default=None),
|
||||||
|
reason_code: str | None = Query(default=None),
|
||||||
|
search: str | None = Query(default=None),
|
||||||
|
limit: int = Query(default=20, ge=1, le=100),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
current_user: User = Depends(require_manager_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||||
|
data = await FeedbackCenterService(db).list_records(
|
||||||
|
tenant_id=effective_tenant_id,
|
||||||
|
project_id=project_id,
|
||||||
|
source_type=source_type,
|
||||||
|
review_status=review_status,
|
||||||
|
field_name=field_name,
|
||||||
|
reason_code=reason_code,
|
||||||
|
search=search,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/feedback-center/items/{record_id}")
|
||||||
|
async def get_feedback_center_item_detail(
|
||||||
|
record_id: int,
|
||||||
|
project_id: int | None = Query(default=None),
|
||||||
|
current_user: User = Depends(require_manager_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||||
|
data = await FeedbackCenterService(db).get_detail(
|
||||||
|
tenant_id=effective_tenant_id,
|
||||||
|
record_id=record_id,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
if data is None:
|
||||||
|
raise HTTPException(status_code=404, detail="feedback record not found")
|
||||||
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/feedback-center/items/{record_id}/review")
|
||||||
|
async def review_feedback_center_item(
|
||||||
|
record_id: int,
|
||||||
|
request: FeedbackCenterReviewRequest,
|
||||||
|
current_user: User = Depends(require_manager_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||||
|
service = FeedbackCenterService(db)
|
||||||
|
reviewed = await service.review(
|
||||||
|
tenant_id=effective_tenant_id,
|
||||||
|
record_id=record_id,
|
||||||
|
reviewer_id=int(current_user.id),
|
||||||
|
review_status=request.review_status,
|
||||||
|
reason_code=request.reason_code,
|
||||||
|
reason_text=request.reason_text,
|
||||||
|
)
|
||||||
|
return success_response(reviewed)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/feedback-center/reason-catalog")
|
||||||
|
async def get_feedback_center_reason_catalog(
|
||||||
|
source_type: str | None = Query(default=None),
|
||||||
|
field_name: str | None = Query(default=None),
|
||||||
|
current_user: User = Depends(require_manager_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
|
_ = db, tenant_id, current_user
|
||||||
|
return success_response(
|
||||||
|
{
|
||||||
|
"items": list_reason_catalog(source_type=source_type, field_name=field_name),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
@ -1,57 +1,27 @@
|
||||||
"""反馈统计 API"""
|
"""反馈统计 API"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
|
||||||
|
|
||||||
from ..core.database import get_db
|
from ..core.database import get_db
|
||||||
from ..models import SignalFeedback
|
from ..core.response import success_response
|
||||||
|
from ..dependencies.auth import get_current_user
|
||||||
|
from ..dependencies.tenant import get_tenant_id
|
||||||
|
from ..models import User
|
||||||
|
from ..services.feedback_center import FeedbackCenterService
|
||||||
|
|
||||||
router = APIRouter(prefix="/feedbacks", tags=["feedbacks"])
|
router = APIRouter(prefix="/feedbacks", tags=["feedbacks"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
async def get_feedback_stats(db: AsyncSession = Depends(get_db)):
|
async def get_feedback_stats(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
|
):
|
||||||
"""获取反馈统计"""
|
"""获取反馈统计"""
|
||||||
result = await db.execute(select(SignalFeedback))
|
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||||
all_feedbacks = result.scalars().all()
|
data = await FeedbackCenterService(db).get_signal_feedback_stats(tenant_id=effective_tenant_id)
|
||||||
|
return success_response(data)
|
||||||
total = len(all_feedbacks)
|
|
||||||
if total == 0:
|
|
||||||
return {
|
|
||||||
"total_feedbacks": 0,
|
|
||||||
"accuracy": 0.0,
|
|
||||||
"true_positives": 0,
|
|
||||||
"false_positives": 0,
|
|
||||||
"true_negatives": 0,
|
|
||||||
"false_negatives": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
correct = sum(
|
|
||||||
1 for f in all_feedbacks if f.ai_predicted_strategic == f.was_strategic
|
|
||||||
)
|
|
||||||
true_positives = sum(
|
|
||||||
1 for f in all_feedbacks if f.ai_predicted_strategic and f.was_strategic
|
|
||||||
)
|
|
||||||
false_positives = sum(
|
|
||||||
1 for f in all_feedbacks if f.ai_predicted_strategic and not f.was_strategic
|
|
||||||
)
|
|
||||||
true_negatives = sum(
|
|
||||||
1
|
|
||||||
for f in all_feedbacks
|
|
||||||
if not f.ai_predicted_strategic and not f.was_strategic
|
|
||||||
)
|
|
||||||
false_negatives = sum(
|
|
||||||
1
|
|
||||||
for f in all_feedbacks
|
|
||||||
if not f.ai_predicted_strategic and f.was_strategic
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"total_feedbacks": total,
|
|
||||||
"accuracy": correct / total,
|
|
||||||
"true_positives": true_positives,
|
|
||||||
"false_positives": false_positives,
|
|
||||||
"true_negatives": true_negatives,
|
|
||||||
"false_negatives": false_negatives,
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import and_, select
|
||||||
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
from .helpers import _resolve_tenant_id
|
from .helpers import _resolve_tenant_id
|
||||||
|
from ...models import QAFeedback, QARecord
|
||||||
|
from ...services.feedback_center import FeedbackCenterService
|
||||||
|
|
||||||
@router.post("/qa-records/{qa_id}/feedback")
|
@router.post("/qa-records/{qa_id}/feedback")
|
||||||
async def submit_qa_feedback(
|
async def submit_qa_feedback(
|
||||||
|
|
@ -23,6 +27,38 @@ async def submit_qa_feedback(
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
try:
|
||||||
|
feedback_row = (
|
||||||
|
await db.execute(
|
||||||
|
select(QAFeedback)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
QAFeedback.tenant_id == _resolve_tenant_id(current_user, tenant_id),
|
||||||
|
QAFeedback.qa_id == qa_id,
|
||||||
|
QAFeedback.submitted_by == int(current_user.id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(QAFeedback.created_at.desc(), QAFeedback.id.desc())
|
||||||
|
)
|
||||||
|
).scalars().first()
|
||||||
|
qa_row = (
|
||||||
|
await db.execute(
|
||||||
|
select(QARecord).where(
|
||||||
|
and_(
|
||||||
|
QARecord.tenant_id == _resolve_tenant_id(current_user, tenant_id),
|
||||||
|
QARecord.id == qa_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if feedback_row and qa_row:
|
||||||
|
await FeedbackCenterService(db).ingest_qa_feedback(
|
||||||
|
qa_feedback=feedback_row,
|
||||||
|
qa_record=qa_row,
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await db.rollback()
|
||||||
return success_response(result)
|
return success_response(result)
|
||||||
|
|
||||||
@router.get("/qa-records")
|
@router.get("/qa-records")
|
||||||
|
|
@ -195,4 +231,3 @@ async def export_qa_records_jsonl(
|
||||||
date_to=query.date_to,
|
date_to=query.date_to,
|
||||||
)
|
)
|
||||||
return Response(content=payload, media_type="application/x-ndjson")
|
return Response(content=payload, media_type="application/x-ndjson")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,21 +9,33 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
|
from ...core.response import success_response
|
||||||
from ...core.database import get_db
|
from ...core.database import get_db
|
||||||
from ...models import Signal, SignalFeedback
|
from ...dependencies.auth import get_current_user
|
||||||
|
from ...dependencies.tenant import get_tenant_id
|
||||||
|
from ...models import Signal, SignalFeedback, User
|
||||||
from ...schemas import SignalFeedbackCreate, SignalFeedbackResponse
|
from ...schemas import SignalFeedbackCreate, SignalFeedbackResponse
|
||||||
|
from ...services.feedback_center import FeedbackCenterService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{signal_id}/feedback", response_model=SignalFeedbackResponse)
|
@router.post("/{signal_id}/feedback")
|
||||||
async def submit_signal_feedback(
|
async def submit_signal_feedback(
|
||||||
signal_id: int,
|
signal_id: int,
|
||||||
feedback: SignalFeedbackCreate,
|
feedback: SignalFeedbackCreate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
):
|
):
|
||||||
"""提交信号反馈"""
|
"""提交信号反馈"""
|
||||||
result = await db.execute(select(Signal).where(Signal.id == signal_id))
|
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||||
|
result = await db.execute(
|
||||||
|
select(Signal).where(
|
||||||
|
Signal.id == signal_id,
|
||||||
|
Signal.tenant_id == effective_tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
signal = result.scalar_one_or_none()
|
signal = result.scalar_one_or_none()
|
||||||
if not signal:
|
if not signal:
|
||||||
raise HTTPException(status_code=404, detail="Signal not found")
|
raise HTTPException(status_code=404, detail="Signal not found")
|
||||||
|
|
@ -39,10 +51,11 @@ async def submit_signal_feedback(
|
||||||
ai_predicted_strategic = signal.impact_score >= 0.9
|
ai_predicted_strategic = signal.impact_score >= 0.9
|
||||||
|
|
||||||
new_feedback = SignalFeedback(
|
new_feedback = SignalFeedback(
|
||||||
|
tenant_id=effective_tenant_id,
|
||||||
signal_id=signal_id,
|
signal_id=signal_id,
|
||||||
was_strategic=feedback.was_strategic,
|
was_strategic=feedback.was_strategic,
|
||||||
reason=feedback.reason,
|
reason=feedback.reason,
|
||||||
user_id=feedback.user_id,
|
user_id=str(getattr(current_user, "id", feedback.user_id) or feedback.user_id or ""),
|
||||||
ai_predicted_strategic=ai_predicted_strategic,
|
ai_predicted_strategic=ai_predicted_strategic,
|
||||||
ai_confidence=ai_confidence,
|
ai_confidence=ai_confidence,
|
||||||
)
|
)
|
||||||
|
|
@ -50,32 +63,48 @@ async def submit_signal_feedback(
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(new_feedback)
|
await db.refresh(new_feedback)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await FeedbackCenterService(db).ingest_signal_feedback(
|
||||||
|
signal_feedback=new_feedback,
|
||||||
|
signal=signal,
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await db.rollback()
|
||||||
|
|
||||||
is_correct = ai_predicted_strategic == feedback.was_strategic
|
is_correct = ai_predicted_strategic == feedback.was_strategic
|
||||||
|
|
||||||
return SignalFeedbackResponse(
|
return success_response(
|
||||||
id=new_feedback.id,
|
SignalFeedbackResponse(
|
||||||
signal_id=new_feedback.signal_id,
|
id=new_feedback.id,
|
||||||
was_strategic=new_feedback.was_strategic,
|
signal_id=new_feedback.signal_id,
|
||||||
ai_predicted_strategic=new_feedback.ai_predicted_strategic,
|
was_strategic=new_feedback.was_strategic,
|
||||||
ai_confidence=new_feedback.ai_confidence,
|
ai_predicted_strategic=new_feedback.ai_predicted_strategic,
|
||||||
is_correct=is_correct,
|
ai_confidence=new_feedback.ai_confidence or 0.0,
|
||||||
|
is_correct=is_correct,
|
||||||
|
).model_dump()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@router.get("/{signal_id}/feedbacks")
|
||||||
@router.get("/{signal_id}/feedbacks", response_model=List[SignalFeedbackResponse])
|
|
||||||
async def get_signal_feedbacks(
|
async def get_signal_feedbacks(
|
||||||
signal_id: int,
|
signal_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
tenant_id: int = Depends(get_tenant_id),
|
||||||
):
|
):
|
||||||
"""获取某信号的全部反馈"""
|
"""获取某信号的全部反馈"""
|
||||||
|
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(SignalFeedback)
|
select(SignalFeedback)
|
||||||
.where(SignalFeedback.signal_id == signal_id)
|
.where(
|
||||||
|
SignalFeedback.signal_id == signal_id,
|
||||||
|
SignalFeedback.tenant_id == effective_tenant_id,
|
||||||
|
)
|
||||||
.order_by(SignalFeedback.created_at.desc())
|
.order_by(SignalFeedback.created_at.desc())
|
||||||
)
|
)
|
||||||
feedbacks = result.scalars().all()
|
feedbacks = result.scalars().all()
|
||||||
|
|
||||||
return [
|
items = [
|
||||||
SignalFeedbackResponse(
|
SignalFeedbackResponse(
|
||||||
id=feedback.id,
|
id=feedback.id,
|
||||||
signal_id=feedback.signal_id,
|
signal_id=feedback.signal_id,
|
||||||
|
|
@ -83,6 +112,7 @@ async def get_signal_feedbacks(
|
||||||
ai_predicted_strategic=feedback.ai_predicted_strategic,
|
ai_predicted_strategic=feedback.ai_predicted_strategic,
|
||||||
ai_confidence=feedback.ai_confidence or 0.0,
|
ai_confidence=feedback.ai_confidence or 0.0,
|
||||||
is_correct=feedback.ai_predicted_strategic == feedback.was_strategic,
|
is_correct=feedback.ai_predicted_strategic == feedback.was_strategic,
|
||||||
)
|
).model_dump()
|
||||||
for feedback in feedbacks
|
for feedback in feedbacks
|
||||||
]
|
]
|
||||||
|
return success_response({"items": items})
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from ...services.sstc.entity_lookup import _ensure_charter, _ensure_project
|
||||||
from ...services.sstc.governance import _assert_charter_aligned, _require_manager
|
from ...services.sstc.governance import _assert_charter_aligned, _require_manager
|
||||||
from ...services.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async
|
from ...services.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async
|
||||||
from ...services.context_cache_service import context_cache_service
|
from ...services.context_cache_service import context_cache_service
|
||||||
|
from ...services.feedback_center import FeedbackCenterService, map_task_breakdown_to_record_payload
|
||||||
from ...services.task_breakdown import (
|
from ...services.task_breakdown import (
|
||||||
build_task_breakdown_ai_payload,
|
build_task_breakdown_ai_payload,
|
||||||
normalize_task_breakdown_payload,
|
normalize_task_breakdown_payload,
|
||||||
|
|
@ -263,6 +264,23 @@ async def ai_breakdown_tasks(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
||||||
|
try:
|
||||||
|
payloads = [
|
||||||
|
map_task_breakdown_to_record_payload(
|
||||||
|
task=t,
|
||||||
|
project_title=project.title,
|
||||||
|
source_context=ai_breakdown_payload["source_context"],
|
||||||
|
)
|
||||||
|
for t in created
|
||||||
|
]
|
||||||
|
if payloads:
|
||||||
|
await FeedbackCenterService(db).ingest_payloads(payloads=payloads, commit=True)
|
||||||
|
except Exception:
|
||||||
|
if hasattr(db, "rollback"):
|
||||||
|
try:
|
||||||
|
await db.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return success_response(
|
return success_response(
|
||||||
{
|
{
|
||||||
"created_count": len(created),
|
"created_count": len(created),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||||
from .shared import *
|
from .shared import *
|
||||||
|
|
||||||
from ...services.context_cache_service import context_cache_service
|
from ...services.context_cache_service import context_cache_service
|
||||||
|
from ...services.feedback_center import FeedbackCenterService
|
||||||
from ...services.sstc.entity_lookup import _ensure_project, _ensure_task, _parse_due_date
|
from ...services.sstc.entity_lookup import _ensure_project, _ensure_task, _parse_due_date
|
||||||
from ...services.sstc.governance import _require_manager
|
from ...services.sstc.governance import _require_manager
|
||||||
from ...services.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async
|
from ...services.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async
|
||||||
|
|
@ -118,6 +119,14 @@ async def patch_task(
|
||||||
title=task.title,
|
title=task.title,
|
||||||
content=task.description or "",
|
content=task.description or "",
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
await FeedbackCenterService(db).ingest_task_overrides(task=task, commit=True)
|
||||||
|
except Exception:
|
||||||
|
if hasattr(db, "rollback"):
|
||||||
|
try:
|
||||||
|
await db.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return success_response({"id": str(task.id), "status": task.status})
|
return success_response({"id": str(task.id), "status": task.status})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,109 @@ from typing import Any
|
||||||
|
|
||||||
from .ai_prompts_catalog import __all__ as PROMPT_CATALOG_EXPORTS
|
from .ai_prompts_catalog import __all__ as PROMPT_CATALOG_EXPORTS
|
||||||
|
|
||||||
|
PROMPT_FAMILY_CLASSIFICATION = "classification"
|
||||||
|
PROMPT_FAMILY_EXTRACTION = "extraction"
|
||||||
|
PROMPT_FAMILY_PLANNING = "planning"
|
||||||
|
PROMPT_FAMILY_RESPONSE = "response"
|
||||||
|
PROMPT_FAMILY_GOVERNANCE = "governance"
|
||||||
|
|
||||||
|
PROMPT_FAMILY_ALLOWED = {
|
||||||
|
PROMPT_FAMILY_CLASSIFICATION,
|
||||||
|
PROMPT_FAMILY_EXTRACTION,
|
||||||
|
PROMPT_FAMILY_PLANNING,
|
||||||
|
PROMPT_FAMILY_RESPONSE,
|
||||||
|
PROMPT_FAMILY_GOVERNANCE,
|
||||||
|
}
|
||||||
|
|
||||||
|
PROMPT_FAMILY_KEYWORD_RULES: tuple[tuple[str, str], ...] = (
|
||||||
|
("CLASSIFICATION", PROMPT_FAMILY_CLASSIFICATION),
|
||||||
|
("EXTRACTION", PROMPT_FAMILY_EXTRACTION),
|
||||||
|
("PREFILL", PROMPT_FAMILY_EXTRACTION),
|
||||||
|
("INTENT", PROMPT_FAMILY_CLASSIFICATION),
|
||||||
|
("TASK_ORCHESTRATION", PROMPT_FAMILY_PLANNING),
|
||||||
|
("PLANNER", PROMPT_FAMILY_PLANNING),
|
||||||
|
("GENERATE", PROMPT_FAMILY_PLANNING),
|
||||||
|
("DECOMPOSE", PROMPT_FAMILY_PLANNING),
|
||||||
|
("RECOMMEND", PROMPT_FAMILY_PLANNING),
|
||||||
|
("DRAFT", PROMPT_FAMILY_PLANNING),
|
||||||
|
("TASK_BREAKDOWN", PROMPT_FAMILY_PLANNING),
|
||||||
|
("QA", PROMPT_FAMILY_RESPONSE),
|
||||||
|
("REPLY", PROMPT_FAMILY_RESPONSE),
|
||||||
|
)
|
||||||
|
|
||||||
|
PROMPT_FAMILY_SCENARIO_RULES: tuple[tuple[str, str], ...] = (
|
||||||
|
("分类", PROMPT_FAMILY_CLASSIFICATION),
|
||||||
|
("抽取", PROMPT_FAMILY_EXTRACTION),
|
||||||
|
("预填", PROMPT_FAMILY_EXTRACTION),
|
||||||
|
("规划", PROMPT_FAMILY_PLANNING),
|
||||||
|
("编排", PROMPT_FAMILY_PLANNING),
|
||||||
|
("草案", PROMPT_FAMILY_PLANNING),
|
||||||
|
("拆解", PROMPT_FAMILY_PLANNING),
|
||||||
|
("问答", PROMPT_FAMILY_RESPONSE),
|
||||||
|
("回复", PROMPT_FAMILY_RESPONSE),
|
||||||
|
)
|
||||||
|
|
||||||
|
RESPONSIBILITY_SCOPE_BY_FAMILY: dict[str, str] = {
|
||||||
|
PROMPT_FAMILY_CLASSIFICATION: "分类层:只负责识别请求类型、归属和优先级,不生成执行方案。",
|
||||||
|
PROMPT_FAMILY_EXTRACTION: "抽取层:只负责结构化提取实体、槽位和证据,不做规划决策。",
|
||||||
|
PROMPT_FAMILY_PLANNING: "规划层:只负责生成可执行规划、任务和负责人建议,不承担最终对话呈现。",
|
||||||
|
PROMPT_FAMILY_RESPONSE: "呈现层:只负责面向用户输出结论与建议,不直接做写操作决策。",
|
||||||
|
PROMPT_FAMILY_GOVERNANCE: "治理层:只负责约束、评估、同步与风控,不承担主对话生成。",
|
||||||
|
}
|
||||||
|
|
||||||
|
PROMPT_PRIORITY_BY_FAMILY: dict[str, str] = {
|
||||||
|
PROMPT_FAMILY_CLASSIFICATION: "high",
|
||||||
|
PROMPT_FAMILY_EXTRACTION: "high",
|
||||||
|
PROMPT_FAMILY_PLANNING: "high",
|
||||||
|
PROMPT_FAMILY_RESPONSE: "high",
|
||||||
|
PROMPT_FAMILY_GOVERNANCE: "medium",
|
||||||
|
}
|
||||||
|
|
||||||
|
PROMPT_AUDIENCE_BY_FAMILY: dict[str, str] = {
|
||||||
|
PROMPT_FAMILY_CLASSIFICATION: "signal_router",
|
||||||
|
PROMPT_FAMILY_EXTRACTION: "signal_ingestion",
|
||||||
|
PROMPT_FAMILY_PLANNING: "planning_orchestrator",
|
||||||
|
PROMPT_FAMILY_RESPONSE: "assistant_response",
|
||||||
|
PROMPT_FAMILY_GOVERNANCE: "risk_budget_guardrail",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_family(prompt_family: str) -> str:
|
||||||
|
value = str(prompt_family or "").strip().lower()
|
||||||
|
if value in PROMPT_FAMILY_ALLOWED:
|
||||||
|
return value
|
||||||
|
return PROMPT_FAMILY_GOVERNANCE
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_prompt_family(prompt_key: str, business_scenario: str) -> str:
|
||||||
|
key_text = str(prompt_key or "").strip().upper()
|
||||||
|
scenario_text = str(business_scenario or "").strip()
|
||||||
|
|
||||||
|
for keyword, family in PROMPT_FAMILY_KEYWORD_RULES:
|
||||||
|
if keyword in key_text:
|
||||||
|
return family
|
||||||
|
|
||||||
|
for keyword, family in PROMPT_FAMILY_SCENARIO_RULES:
|
||||||
|
if keyword in scenario_text:
|
||||||
|
return family
|
||||||
|
|
||||||
|
return PROMPT_FAMILY_GOVERNANCE
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_responsibility_scope(prompt_family: str) -> str:
|
||||||
|
family = _normalize_family(prompt_family)
|
||||||
|
return RESPONSIBILITY_SCOPE_BY_FAMILY[family]
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_priority(prompt_family: str) -> str:
|
||||||
|
family = _normalize_family(prompt_family)
|
||||||
|
return PROMPT_PRIORITY_BY_FAMILY[family]
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_audience(prompt_family: str) -> str:
|
||||||
|
family = _normalize_family(prompt_family)
|
||||||
|
return PROMPT_AUDIENCE_BY_FAMILY[family]
|
||||||
|
|
||||||
|
|
||||||
def _entry(
|
def _entry(
|
||||||
prompt_key: str,
|
prompt_key: str,
|
||||||
|
|
@ -13,7 +116,16 @@ def _entry(
|
||||||
input_contract: str,
|
input_contract: str,
|
||||||
output_contract: str,
|
output_contract: str,
|
||||||
call_sites: tuple[str, ...] = (),
|
call_sites: tuple[str, ...] = (),
|
||||||
|
prompt_family: str | None = None,
|
||||||
|
responsibility_scope: str | None = None,
|
||||||
|
priority: str | None = None,
|
||||||
|
audience: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
resolved_family = _normalize_family(prompt_family or _derive_prompt_family(prompt_key, business_scenario))
|
||||||
|
resolved_scope = str(responsibility_scope or "").strip() or _derive_responsibility_scope(resolved_family)
|
||||||
|
resolved_priority = str(priority or "").strip().lower() or _derive_priority(resolved_family)
|
||||||
|
resolved_audience = str(audience or "").strip() or _derive_audience(resolved_family)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"prompt_key": prompt_key,
|
"prompt_key": prompt_key,
|
||||||
"module": module,
|
"module": module,
|
||||||
|
|
@ -21,6 +133,10 @@ def _entry(
|
||||||
"call_sites": tuple(str(site) for site in call_sites if str(site or "").strip()),
|
"call_sites": tuple(str(site) for site in call_sites if str(site or "").strip()),
|
||||||
"input_contract": input_contract,
|
"input_contract": input_contract,
|
||||||
"output_contract": output_contract,
|
"output_contract": output_contract,
|
||||||
|
"prompt_family": resolved_family,
|
||||||
|
"responsibility_scope": resolved_scope,
|
||||||
|
"priority": resolved_priority,
|
||||||
|
"audience": resolved_audience,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -64,13 +180,6 @@ def _part_01_entries() -> tuple[dict[str, Any], ...]:
|
||||||
input_contract=JSON_INPUT,
|
input_contract=JSON_INPUT,
|
||||||
output_contract="输出 JSON 对象,包含影响结论、风险、建议。",
|
output_contract="输出 JSON 对象,包含影响结论、风险、建议。",
|
||||||
),
|
),
|
||||||
_entry(
|
|
||||||
"PROMPT_TASK_BREAKDOWN",
|
|
||||||
module="part_01",
|
|
||||||
business_scenario="任务拆解基础片段",
|
|
||||||
input_contract=FRAGMENT_INPUT,
|
|
||||||
output_contract=FRAGMENT_OUTPUT,
|
|
||||||
),
|
|
||||||
_entry(
|
_entry(
|
||||||
"PROMPT_STRATEGIC_ANALYSIS_HEADER",
|
"PROMPT_STRATEGIC_ANALYSIS_HEADER",
|
||||||
module="part_01",
|
module="part_01",
|
||||||
|
|
@ -106,14 +215,6 @@ def _part_01_entries() -> tuple[dict[str, Any], ...]:
|
||||||
input_contract=JSON_INPUT,
|
input_contract=JSON_INPUT,
|
||||||
output_contract="输出 JSON 对象,包含关键洞察与下一步建议。",
|
output_contract="输出 JSON 对象,包含关键洞察与下一步建议。",
|
||||||
),
|
),
|
||||||
_entry(
|
|
||||||
"PROMPT_FEISHU_REQUIREMENT_ANALYSIS",
|
|
||||||
module="part_01",
|
|
||||||
business_scenario="飞书需求解析",
|
|
||||||
input_contract=JSON_INPUT,
|
|
||||||
output_contract="输出 JSON 对象,包含需求抽取、风险与建议。",
|
|
||||||
call_sites=FEISHU_EXTRACT_CALL_SITES,
|
|
||||||
),
|
|
||||||
_entry(
|
_entry(
|
||||||
"PROMPT_FEISHU_HUMANIZED_REPLY",
|
"PROMPT_FEISHU_HUMANIZED_REPLY",
|
||||||
module="part_01",
|
module="part_01",
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,11 @@ AI Prompt 集中管理
|
||||||
|
|
||||||
from .ai_prompts_catalog import (
|
from .ai_prompts_catalog import (
|
||||||
PROMPT_IMPACT_ANALYSIS,
|
PROMPT_IMPACT_ANALYSIS,
|
||||||
PROMPT_TASK_BREAKDOWN,
|
|
||||||
PROMPT_STRATEGIC_ANALYSIS_HEADER,
|
PROMPT_STRATEGIC_ANALYSIS_HEADER,
|
||||||
PROMPT_RAG_CONTEXT_HEADER,
|
PROMPT_RAG_CONTEXT_HEADER,
|
||||||
PROMPT_RAG_CONTEXT_ITEM,
|
PROMPT_RAG_CONTEXT_ITEM,
|
||||||
PROMPT_CODE_AUDIT,
|
PROMPT_CODE_AUDIT,
|
||||||
PROMPT_CONTEXT_INSIGHTS,
|
PROMPT_CONTEXT_INSIGHTS,
|
||||||
PROMPT_FEISHU_REQUIREMENT_ANALYSIS,
|
|
||||||
PROMPT_FEISHU_HUMANIZED_REPLY,
|
PROMPT_FEISHU_HUMANIZED_REPLY,
|
||||||
PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION,
|
PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION,
|
||||||
PROMPT_FEISHU_LEVEL_CLASSIFICATION,
|
PROMPT_FEISHU_LEVEL_CLASSIFICATION,
|
||||||
|
|
@ -62,6 +60,12 @@ from .ai_prompt_registry import (
|
||||||
iter_prompt_registry_keys,
|
iter_prompt_registry_keys,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Deprecated compatibility aliases kept outside the registry surface.
|
||||||
|
PROMPT_FEISHU_REQUIREMENT_ANALYSIS = PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION
|
||||||
|
PROMPT_TASK_BREAKDOWN = PROMPT_DECOMPOSE_TASKS
|
||||||
|
|
||||||
|
|
||||||
def build_default_role_prompt_bundle() -> dict:
|
def build_default_role_prompt_bundle() -> dict:
|
||||||
"""返回默认角色 Prompt 配置(每次调用返回新对象)。"""
|
"""返回默认角色 Prompt 配置(每次调用返回新对象)。"""
|
||||||
return {
|
return {
|
||||||
|
|
@ -74,26 +78,25 @@ def build_default_role_prompt_bundle() -> dict:
|
||||||
"role_description": "负责查重、评审、起草立项建议并给出确认前的产品方案",
|
"role_description": "负责查重、评审、起草立项建议并给出确认前的产品方案",
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"system_prompt": """
|
"system_prompt": """
|
||||||
你是 PMFlow 的产品经理助手。
|
你是 PMFlow 的 PMO 协作中枢中的立项评审角色(pm_dispatcher)。
|
||||||
|
|
||||||
职责定位:
|
职责边界(只做评审与起草,不直接执行写操作):
|
||||||
1. 先判断是否已有同名或相似项目,再给出是否复用、合并或新建的建议。
|
1. 聚焦查重、复用/合并、立项评审,优先判断是否已有同名或相似项目。
|
||||||
2. 先输出产品评审结论,再展开项目定位、MVP、前景、需求清单、风险与负责人建议。
|
2. 输出面向业务决策的结论:是否新建、复用、合并或暂缓。
|
||||||
3. 回复风格要专业、清晰、像真实产品经理评审,允许给出取舍与备选方案。
|
3. 基于项目上下文产出可讨论草案,覆盖项目定位、MVP、需求清单、风险与负责人建议。
|
||||||
4. 只有在用户明确确认后,才进入写入或继续执行流程。
|
4. 若信息不足,提出关键澄清问题(最多 3 个),不凭空补齐。
|
||||||
|
|
||||||
输出要求:
|
输出框架:
|
||||||
1. 先用 1-2 句话复述需求并判断立项方向。
|
1. 先结论:1-2 句复述需求并给出立项方向。
|
||||||
2. 再给出是否有同名/相似项目的判断。
|
2. 再查重:明确同名或相似项目判断,给出差异点与复用价值。
|
||||||
3. 再展开项目定位、MVP、前景、需求清单、风险与待补充信息。
|
3. 再方案:给出 MVP、关键需求、主要风险、负责人建议、后续动作。
|
||||||
4. 如果信息不足,只提关键澄清问题(最多 3 个),不要臆测。
|
4. 全程专业、克制、结论先行,避免空泛口号。
|
||||||
5. 如果涉及风险、依赖、排期冲突或重复建设,要明确提醒。
|
|
||||||
|
|
||||||
禁止项:
|
禁止项:
|
||||||
- 不要输出与项目无关的泛化建议。
|
- 不要编造不存在的数据、进展或执行结果。
|
||||||
- 不要只给机械化模板,不要把评审写成简单入库文案。
|
- 不要把评审写成机械化模板。
|
||||||
- 不要使用命令式生硬语气。
|
- 不要使用命令式、生硬语气。
|
||||||
- 不要编造不存在的数据或结论。
|
- 不要输出与 PMFlow 业务无关的泛化建议。
|
||||||
""".strip(),
|
""".strip(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -102,16 +105,21 @@ def build_default_role_prompt_bundle() -> dict:
|
||||||
"role_description": "负责技术方案约束、风险评估与实现边界",
|
"role_description": "负责技术方案约束、风险评估与实现边界",
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"system_prompt": """
|
"system_prompt": """
|
||||||
你是 PMFlow 的技术架构师。
|
你是 PMFlow 的技术架构角色(tech_architect)。
|
||||||
|
|
||||||
|
职责边界:
|
||||||
|
1. 专注可行性、依赖、风险边界,不代替产品评审或交付推进。
|
||||||
|
2. 输出技术结论与约束,帮助团队判断是否能安全落地。
|
||||||
|
|
||||||
你需要基于需求识别实现边界、关键风险和技术依赖,给出可执行的技术建议。
|
|
||||||
输出要点:
|
输出要点:
|
||||||
1. 先给结论(可行/有风险/需拆分)。
|
1. 先给结论:可行 / 有风险 / 需拆分。
|
||||||
2. 给出技术风险与约束(性能、安全、兼容、成本)。
|
2. 再给依据:关键依赖、性能风险、安全风险、兼容风险、成本约束。
|
||||||
3. 提供建议的实现路径与最小可行步骤。
|
3. 再给路径:最小可行步骤、建议拆分方式、必要前置条件。
|
||||||
4. 结尾给出是否建议立即进入开发。
|
4. 结尾明确:是否建议立即进入开发。
|
||||||
|
|
||||||
风格:简洁、严谨、可执行,不讲空话。
|
风格要求:
|
||||||
|
- 简洁、严谨、可执行。
|
||||||
|
- 不讲空话,不编造验证结果。
|
||||||
""".strip(),
|
""".strip(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -120,15 +128,20 @@ def build_default_role_prompt_bundle() -> dict:
|
||||||
"role_description": "负责推进节奏、协调分工与交付闭环",
|
"role_description": "负责推进节奏、协调分工与交付闭环",
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"system_prompt": """
|
"system_prompt": """
|
||||||
你是 PMFlow 的交付推进官。
|
你是 PMFlow 的交付推进角色(delivery_coach)。
|
||||||
|
|
||||||
|
职责边界:
|
||||||
|
1. 聚焦节奏、排期、负责人、推进动作。
|
||||||
|
2. 不代替技术评审,不输出与推进无关的实现细节。
|
||||||
|
|
||||||
你要把需求转成可执行的交付动作,强调优先级、负责人和时间边界。
|
|
||||||
输出格式:
|
输出格式:
|
||||||
1. 当前状态判断(一句话)。
|
1. 当前状态判断(一句话,先结论)。
|
||||||
2. 建议动作(不超过 3 条,动词开头)。
|
2. 建议动作(不超过 3 条,动词开头,强调优先级与负责人)。
|
||||||
3. 风险提示(如有)。
|
3. 风险提示(如有,突出阻塞与依赖)。
|
||||||
|
|
||||||
风格:明确、友好、推动协作,避免过度技术细节。
|
风格要求:
|
||||||
|
- 明确、友好、推动协作。
|
||||||
|
- 结论先行,动作可执行,避免空泛管理话术。
|
||||||
""".strip(),
|
""".strip(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .part_01 import PROMPT_IMPACT_ANALYSIS, PROMPT_TASK_BREAKDOWN, PROMPT_STRATEGIC_ANALYSIS_HEADER, PROMPT_RAG_CONTEXT_HEADER, PROMPT_RAG_CONTEXT_ITEM, PROMPT_CODE_AUDIT, PROMPT_CONTEXT_INSIGHTS, PROMPT_FEISHU_REQUIREMENT_ANALYSIS, PROMPT_FEISHU_HUMANIZED_REPLY, PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, PROMPT_FEISHU_LEVEL_CLASSIFICATION
|
from .part_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_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
|
||||||
|
|
@ -13,13 +13,11 @@ from .part_scenes_06 import PROMPT_EXTERNAL_TREND_QA, PROMPT_INDUSTRY_TREND_QA
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"PROMPT_IMPACT_ANALYSIS",
|
"PROMPT_IMPACT_ANALYSIS",
|
||||||
"PROMPT_TASK_BREAKDOWN",
|
|
||||||
"PROMPT_STRATEGIC_ANALYSIS_HEADER",
|
"PROMPT_STRATEGIC_ANALYSIS_HEADER",
|
||||||
"PROMPT_RAG_CONTEXT_HEADER",
|
"PROMPT_RAG_CONTEXT_HEADER",
|
||||||
"PROMPT_RAG_CONTEXT_ITEM",
|
"PROMPT_RAG_CONTEXT_ITEM",
|
||||||
"PROMPT_CODE_AUDIT",
|
"PROMPT_CODE_AUDIT",
|
||||||
"PROMPT_CONTEXT_INSIGHTS",
|
"PROMPT_CONTEXT_INSIGHTS",
|
||||||
"PROMPT_FEISHU_REQUIREMENT_ANALYSIS",
|
|
||||||
"PROMPT_FEISHU_HUMANIZED_REPLY",
|
"PROMPT_FEISHU_HUMANIZED_REPLY",
|
||||||
"PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION",
|
"PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION",
|
||||||
"PROMPT_FEISHU_LEVEL_CLASSIFICATION",
|
"PROMPT_FEISHU_LEVEL_CLASSIFICATION",
|
||||||
|
|
|
||||||
|
|
@ -1,116 +1,110 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
def _compose_prompt(*parts: str) -> str:
|
||||||
|
return "\n\n".join(part.strip() for part in parts if part and part.strip())
|
||||||
|
|
||||||
|
PMFLOW_PMO_STYLE_BASE = """
|
||||||
|
你是 PMFlow 的 AI PMO 协作中枢成员。
|
||||||
|
风格要求:
|
||||||
|
1. 专业、克制、结论先行。
|
||||||
|
2. 先给判断,再给依据,再给下一步。
|
||||||
|
3. 优先引用可验证事实,不写空泛套话。
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
JSON_OUTPUT_GUARDRAIL = """
|
||||||
|
统一输出约束:
|
||||||
|
1. 只输出 JSON,不要输出 Markdown、解释文本、思考过程或代码块。
|
||||||
|
2. 字段不确定时返回 null、空字符串或空数组,不要猜测。
|
||||||
|
3. 不要编造执行结果、外部数据或系统内部状态。
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
TEXT_OUTPUT_GUARDRAIL = """
|
||||||
|
统一输出约束:
|
||||||
|
1. 只输出最终回复正文,不要输出 JSON、Markdown、思考过程。
|
||||||
|
2. 若信息不足,明确写出缺失字段或缺失信息,不要编造。
|
||||||
|
3. 禁止宣称未在上下文明确完成的动作。
|
||||||
|
""".strip()
|
||||||
|
|
||||||
PROMPT_IMPACT_ANALYSIS = """
|
PROMPT_IMPACT_ANALYSIS = """
|
||||||
Analyze the following news/signal for an AI engineering team.
|
你是 PMFlow 的技术信号影响评估助手。
|
||||||
Determine its "Impact Score" (0.0 to 1.0) based on how critical it is for our RAG/LLM development.
|
请评估以下技术信号对 AI 开发团队(RAG/LLM)的影响分值(0.0-1.0)。
|
||||||
|
|
||||||
Signal: "{signal_content}"
|
Signal: "{signal_content}"
|
||||||
|
|
||||||
Rules:
|
评分参考:
|
||||||
- 0.9-1.0: Major SOTA model release (e.g., GPT-5, DeepSeek-V4), critical vulnerability, or massive breakthrough.
|
- 0.9-1.0:SOTA 级模型发布、关键安全事件、行业级突破。
|
||||||
- 0.7-0.8: Important update, new useful library, or relevant research paper.
|
- 0.7-0.8:重要版本更新、关键库更新、强相关研究发布。
|
||||||
- 0.4-0.6: Interesting but not critical news.
|
- 0.4-0.6:有参考价值但非关键。
|
||||||
- 0.0-0.3: Irrelevant noise.
|
- 0.0-0.3:噪声或相关性低。
|
||||||
|
|
||||||
Output ONLY the numeric score (e.g., 0.85). Do not output any text.
|
只输出数字(例如 0.85),不要输出其他文本。
|
||||||
"""
|
|
||||||
|
|
||||||
PROMPT_TASK_BREAKDOWN = """
|
|
||||||
You are an expert AI Technical Project Manager.
|
|
||||||
Your goal is to break down a software feature request into specific, actionable technical tasks.
|
|
||||||
|
|
||||||
Feature Request: "{description}"
|
|
||||||
|
|
||||||
Required Output Format: JSON Object only.
|
|
||||||
{
|
|
||||||
"tasks": [
|
|
||||||
{
|
|
||||||
"task_key": "task-1",
|
|
||||||
"title": "Concise task title",
|
|
||||||
"description": "What this task delivers",
|
|
||||||
"type": "frontend|backend|database|ai|devops",
|
|
||||||
"estimated_hours": 2.0,
|
|
||||||
"required_skill_names": ["Skill Name"],
|
|
||||||
"required_skill_tag_ids": [1],
|
|
||||||
"depends_on_task_keys": [],
|
|
||||||
"risk_level": "low|medium|high|critical",
|
|
||||||
"risk_notes": "Risk summary",
|
|
||||||
"confidence": 0.0,
|
|
||||||
"evidence_refs": ["charter.objectives.0"],
|
|
||||||
"priority": "high|medium|low"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"detected_skills": ["Skill Name"]
|
|
||||||
}
|
|
||||||
|
|
||||||
Ensure the breakdown is comprehensive and strictly follows the JSON format.
|
|
||||||
Do not include any markdown formatting or explanation outside the JSON.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
PROMPT_STRATEGIC_ANALYSIS_HEADER = """
|
PROMPT_STRATEGIC_ANALYSIS_HEADER = """
|
||||||
You are a Chief Technology Officer (CTO) AI Assistant for our team.
|
你是 PMFlow 的技术战略分析助手。请评估外部信号对当前产品竞争力的影响并给出策略建议。
|
||||||
|
|
||||||
=== Our Product/Team ===
|
=== 团队业务上下文 ===
|
||||||
{business_context}
|
{business_context}
|
||||||
{rag_context}
|
{rag_context}
|
||||||
=== External Technology Signal ===
|
|
||||||
|
=== 外部技术信号 ===
|
||||||
Signal: "{signal_content}"
|
Signal: "{signal_content}"
|
||||||
|
|
||||||
=== Your Task ===
|
任务:
|
||||||
1. Analyze: How does this signal impact OUR product's competitiveness?
|
1. 判断是否需要立即跟进(is_strategic)。
|
||||||
2. Decide: Is it necessary to follow up immediately? (is_strategic)
|
2. 给出战略建议(即使不跟进,也要说明理由)。
|
||||||
3. Proposal: If YES, generate a "Strategic Decision Proposal".
|
3. 输出竞争力雷达评估(current/projected)。
|
||||||
4. Competitiveness Radar: Evaluate current capabilities vs potential capabilities after adopting this technology.
|
4. 识别信号涉及的技术栈与关联知识节点。
|
||||||
- Dimensions: "Efficiency" (Speed), "Cost" (Savings), "UX" (Experience), "Innovation" (Tech Edge), "Market" (Share).
|
5. 若需要跟进,输出执行任务建议。
|
||||||
- Score 1-10.
|
|
||||||
5. Detected Tech Stacks: Identify technologies mentioned in the signal (frameworks, models, APIs, tools).
|
|
||||||
- Each stack should have: name, category (framework/model/tool/api), relevance (high/medium/low).
|
|
||||||
6. Related Knowledge Nodes: Suggest relevant knowledge domains for context tracking.
|
|
||||||
- Examples: "Civitai LoRA Integration", "Sora API Adoption", "Video Generation Pipeline".
|
|
||||||
7. Plan: Break down the strategy into specific technical tasks.
|
|
||||||
|
|
||||||
Required Output Format (JSON Only):
|
输出 JSON:
|
||||||
{{
|
{{
|
||||||
"is_strategic": boolean,
|
"is_strategic": true,
|
||||||
"confidence": float,
|
"confidence": 0.0,
|
||||||
"decision_proposal": {{
|
"decision_proposal": {{
|
||||||
"situation_analysis": "...",
|
"situation_analysis": "现状判断",
|
||||||
"recommendation": "...",
|
"recommendation": "建议动作",
|
||||||
"expected_benefits": "..."
|
"expected_benefits": "预期收益"
|
||||||
}},
|
}},
|
||||||
"competitiveness_radar": {{
|
"competitiveness_radar": {{
|
||||||
"current": [5, 4, 6, 5, 5],
|
"current": [5, 4, 6, 5, 5],
|
||||||
"projected": [8, 7, 9, 9, 7]
|
"projected": [8, 7, 9, 9, 7]
|
||||||
}},
|
}},
|
||||||
"detected_tech_stacks": [
|
"detected_tech_stacks": [
|
||||||
{{"name": "Technology Name", "category": "framework|model|tool|api", "relevance": "high|medium|low"}}
|
{{"name": "技术名", "category": "framework|model|tool|api", "relevance": "high|medium|low"}}
|
||||||
],
|
],
|
||||||
"related_knowledge_nodes": [
|
"related_knowledge_nodes": [
|
||||||
{{"title": "Knowledge Node Title", "category": "tech_stack|competitor|market", "summary": "Brief summary"}}
|
{{"title": "知识节点", "category": "tech_stack|competitor|market", "summary": "简要说明"}}
|
||||||
],
|
],
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{{
|
{{
|
||||||
"task_name": "Task description",
|
"task_name": "任务描述",
|
||||||
"type": "backend/frontend/database",
|
"type": "backend|frontend|database",
|
||||||
"estimated_hours": float
|
"estimated_hours": 8.0
|
||||||
}}
|
}}
|
||||||
]
|
]
|
||||||
}}
|
}}
|
||||||
|
|
||||||
If is_strategic is false, tasks should be empty, but decision_proposal is still required to explain why.
|
补充:
|
||||||
Do not output any markdown formatting or text outside the JSON.
|
1. 若 is_strategic=false,tasks 必须为空数组。
|
||||||
|
2. decision_proposal 必须始终给出,不允许留空。
|
||||||
"""
|
"""
|
||||||
|
PROMPT_STRATEGIC_ANALYSIS_HEADER = _compose_prompt(
|
||||||
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
PROMPT_STRATEGIC_ANALYSIS_HEADER,
|
||||||
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
PROMPT_RAG_CONTEXT_HEADER = """
|
PROMPT_RAG_CONTEXT_HEADER = """
|
||||||
=== Related Knowledge from Our Database ===
|
=== PMFlow 知识库上下文 ===
|
||||||
The following relevant information was found in our knowledge base:
|
以下内容是当前问题可直接引用的相关资料:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
PROMPT_RAG_CONTEXT_ITEM = "[Relevance: {similarity:.1%}]\n{content}\n\n"
|
PROMPT_RAG_CONTEXT_ITEM = "[相关度:{similarity:.1%}]\n{content}\n\n"
|
||||||
|
|
||||||
PROMPT_CODE_AUDIT = """
|
PROMPT_CODE_AUDIT = """
|
||||||
You are a Senior Tech Lead performing an automated Code Audit.
|
你是 PMFlow 的代码审计助手,请评估以下代码改动是否满足任务目标并符合工程规范。
|
||||||
|
|
||||||
Task: "{task_name}"
|
Task: "{task_name}"
|
||||||
Commit Message: "{commit_message}"
|
Commit Message: "{commit_message}"
|
||||||
|
|
@ -118,61 +112,37 @@ Commit Message: "{commit_message}"
|
||||||
Code Diff:
|
Code Diff:
|
||||||
{code_diff}
|
{code_diff}
|
||||||
|
|
||||||
Evaluate the code change against the task requirements. Check for:
|
请输出 JSON:
|
||||||
1. Security vulnerabilities
|
{{
|
||||||
2. Performance issues
|
"score": 0,
|
||||||
3. Code style and best practices
|
"passed": false,
|
||||||
4. Functional correctness
|
"issues": [
|
||||||
|
{{"severity": "low|medium|high|critical", "message": "问题说明", "suggestion": "修复建议"}}
|
||||||
|
],
|
||||||
|
"summary": "总体结论"
|
||||||
|
}}
|
||||||
|
|
||||||
Output JSON with: score (0-100), passed (boolean), issues (list of {{severity, message, suggestion}}), summary.
|
重点检查:
|
||||||
|
1. 安全性
|
||||||
|
2. 性能与稳定性
|
||||||
|
3. 代码规范与可维护性
|
||||||
|
4. 功能正确性
|
||||||
"""
|
"""
|
||||||
|
PROMPT_CODE_AUDIT = _compose_prompt(PMFLOW_PMO_STYLE_BASE, PROMPT_CODE_AUDIT, JSON_OUTPUT_GUARDRAIL)
|
||||||
|
|
||||||
PROMPT_CONTEXT_INSIGHTS = """
|
PROMPT_CONTEXT_INSIGHTS = """
|
||||||
Analyze the following technical documentation/context and extract key insights.
|
你是 PMFlow 的上下文洞察助手。请从给定资料中提炼可供立项、规划和问答引用的关键洞察。
|
||||||
|
|
||||||
Context: "{content}"
|
输入:
|
||||||
|
{content}
|
||||||
|
|
||||||
Output JSON:
|
输出 JSON:
|
||||||
{{
|
{{
|
||||||
"moat": "Core competitive advantage described",
|
"moat": "核心竞争力描述",
|
||||||
"tech_specs": "Key technical specifications"
|
"tech_specs": "关键技术规格"
|
||||||
}}
|
}}
|
||||||
"""
|
"""
|
||||||
|
PROMPT_CONTEXT_INSIGHTS = _compose_prompt(PMFLOW_PMO_STYLE_BASE, PROMPT_CONTEXT_INSIGHTS, JSON_OUTPUT_GUARDRAIL)
|
||||||
PROMPT_FEISHU_REQUIREMENT_ANALYSIS = """
|
|
||||||
你是 PMFlow 的需求信号分析器。请结合团队现有项目,判断一条飞书群消息是否是“可执行需求”,并判定该需求更可能属于“新项目”还是“已有项目”。
|
|
||||||
|
|
||||||
团队活跃项目列表(每行一个项目):
|
|
||||||
{project_summaries}
|
|
||||||
|
|
||||||
群消息:
|
|
||||||
{message}
|
|
||||||
|
|
||||||
输出 JSON,字段如下:
|
|
||||||
{{
|
|
||||||
"is_valuable": true/false,
|
|
||||||
"reason": "简短原因",
|
|
||||||
"impact_score": 0.0-1.0,
|
|
||||||
"priority": "high|medium|low",
|
|
||||||
"requirement_project_scope": "new_project|existing_project|non_requirement",
|
|
||||||
"target_project_hints": ["可能关联的项目名称或关键词"],
|
|
||||||
"analysis": {{
|
|
||||||
"intent": "需求意图摘要",
|
|
||||||
"evidence": ["判定依据1", "判定依据2"]
|
|
||||||
}},
|
|
||||||
"suggested_tasks": [
|
|
||||||
{{"task_name": "任务名称", "type": "backend|frontend|database|ai|devops", "estimated_hours": 4}}
|
|
||||||
]
|
|
||||||
}}
|
|
||||||
|
|
||||||
判定规则:
|
|
||||||
1. 纯表情、寒暄、确认类回复(如“收到”“好的”“1”)一律判定为 non_requirement。
|
|
||||||
2. 若明确是全新方向、全新业务目标、全新系统建设,判定为 new_project。
|
|
||||||
3. 若明确是现有功能优化、Bug 修复、已有模块迭代,判定为 existing_project。
|
|
||||||
4. 不是需求内容时:is_valuable=false,requirement_project_scope=non_requirement,suggested_tasks 为空数组。
|
|
||||||
|
|
||||||
只输出 JSON,不要输出任何解释文本或 Markdown。
|
|
||||||
"""
|
|
||||||
|
|
||||||
PROMPT_FEISHU_HUMANIZED_REPLY = """
|
PROMPT_FEISHU_HUMANIZED_REPLY = """
|
||||||
直接回答用户问题,不要做自我介绍。
|
直接回答用户问题,不要做自我介绍。
|
||||||
|
|
@ -193,25 +163,24 @@ PROMPT_FEISHU_HUMANIZED_REPLY = """
|
||||||
3. 仅当用户明确询问“你是谁/你能做什么/系统能力”时,才允许用一句话说明能力边界。
|
3. 仅当用户明确询问“你是谁/你能做什么/系统能力”时,才允许用一句话说明能力边界。
|
||||||
4. 若信息不足,请明确缺失信息并给出 1-3 条可执行下一步。
|
4. 若信息不足,请明确缺失信息并给出 1-3 条可执行下一步。
|
||||||
5. 如果用户询问日期/时间/星期,必须基于“当前系统时间(北京时间)”直接回答,不得编造。
|
5. 如果用户询问日期/时间/星期,必须基于“当前系统时间(北京时间)”直接回答,不得编造。
|
||||||
6. 不要输出 Markdown,不要输出 JSON。
|
6. 禁止输出数据库内部实施细节(如数据库迁移、DDL、SQL、ALTER TABLE、DROP TABLE、Alembic)。
|
||||||
7. 禁止宣称“已执行/将执行”任何未在上下文明确完成的动作。
|
7. 当 status/classification 无法证明写入动作已成功时,禁止编造处理步骤。
|
||||||
8. 禁止输出数据库内部实施细节(如数据库迁移、DDL、SQL、ALTER TABLE、DROP TABLE、Alembic)。
|
|
||||||
9. 当 status/classification 无法证明写入动作已成功时,禁止编造处理步骤。
|
|
||||||
|
|
||||||
【输出规范】
|
【输出规范】
|
||||||
- 严禁输出任何思考过程或推理步骤
|
- 严禁输出任何思考过程或推理步骤。
|
||||||
- 严禁以以下句式开头:
|
- 严禁以以下句式开头:
|
||||||
"让我先/让我尝试/我来看看/
|
"让我先/让我尝试/我来看看/
|
||||||
我先查/让我看看/我需要先"
|
我先查/让我看看/我需要先"
|
||||||
- 直接输出最终回复内容
|
- 直接输出最终回复内容,不解释推理过程。
|
||||||
- 不解释你的推理过程
|
"""
|
||||||
|
PROMPT_FEISHU_HUMANIZED_REPLY = _compose_prompt(
|
||||||
只输出最终回复正文。
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
""".strip()
|
PROMPT_FEISHU_HUMANIZED_REPLY,
|
||||||
|
TEXT_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION = """
|
PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION = """
|
||||||
你是 PMFlow 的项目需求信号分流器。
|
你是 PMFlow 的项目需求信号分流器(分类层)。请判断消息是否为有效需求,并识别是否能明确归属已有项目。
|
||||||
请基于“项目列表上下文”和“飞书消息”,判断该消息是否为有效需求信号,并识别它属于哪个已有项目。
|
|
||||||
|
|
||||||
项目列表(JSON 数组):
|
项目列表(JSON 数组):
|
||||||
{projects_json}
|
{projects_json}
|
||||||
|
|
@ -221,29 +190,32 @@ PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION = """
|
||||||
|
|
||||||
输出 JSON:
|
输出 JSON:
|
||||||
{{
|
{{
|
||||||
"is_requirement": true/false,
|
"is_requirement": true,
|
||||||
"reason": "判定原因",
|
"reason": "判定原因",
|
||||||
"confidence": 0.0-1.0,
|
"confidence": 0.0,
|
||||||
"classification": "existing_project|new_project|pending_classification|non_requirement",
|
"classification": "existing_project|new_project|pending_classification|non_requirement",
|
||||||
"matched_project_ids": [1,2],
|
"matched_project_ids": [1, 2],
|
||||||
"matched_project_titles": ["项目A","项目B"],
|
"matched_project_titles": ["项目A", "项目B"],
|
||||||
"impact_score": 0.0-1.0,
|
"impact_score": 0.0,
|
||||||
"priority": "high|medium|low",
|
"priority": "high|medium|low",
|
||||||
"summary": "需求摘要(简短)"
|
"summary": "需求摘要(简短)"
|
||||||
}}
|
}}
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
1. 如果不是需求(闲聊、确认、表情、无业务含义),classification=non_requirement,matched_project_ids 为空。
|
1. 非需求(闲聊/确认/表情/无业务含义)=> classification=non_requirement。
|
||||||
2. 如果是需求但无法明确归属到任一已有项目,classification=pending_classification,matched_project_ids 为空。
|
2. 是需求但无法明确归属已有项目 => classification=pending_classification。
|
||||||
3. 只有明确语义关联到某个已有项目时,classification=existing_project,并给出 matched_project_ids。
|
3. 仅在语义明确关联已有项目时 => classification=existing_project。
|
||||||
4. 如果消息明确在描述“全新立项/新项目”,classification=new_project,matched_project_ids 为空。
|
4. 明确是全新立项 => classification=new_project。
|
||||||
5. 不允许猜测项目归属;不确定时必须给 pending_classification。
|
5. 不允许猜测项目归属;不确定时必须给 pending_classification。
|
||||||
|
|
||||||
只输出 JSON,不要输出任何其他文字。
|
|
||||||
"""
|
"""
|
||||||
|
PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION = _compose_prompt(
|
||||||
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION,
|
||||||
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
PROMPT_FEISHU_LEVEL_CLASSIFICATION = """
|
PROMPT_FEISHU_LEVEL_CLASSIFICATION = """
|
||||||
你是 PMFlow 的飞书消息分流器。请按四维度对消息做 0-1 评分,并判断是 project-level 还是 signal-level。
|
你是 PMFlow 的飞书消息层级分流器(分类层)。请基于消息判断其更偏 project-level 还是 signal-level。
|
||||||
|
|
||||||
输入:
|
输入:
|
||||||
- 飞书消息:{message}
|
- 飞书消息:{message}
|
||||||
|
|
@ -251,37 +223,39 @@ PROMPT_FEISHU_LEVEL_CLASSIFICATION = """
|
||||||
|
|
||||||
输出 JSON:
|
输出 JSON:
|
||||||
{{
|
{{
|
||||||
"build_intent_score": 0.0-1.0,
|
"build_intent_score": 0.0,
|
||||||
"scope_score": 0.0-1.0,
|
"scope_score": 0.0,
|
||||||
"timeline_score": 0.0-1.0,
|
"timeline_score": 0.0,
|
||||||
"owner_score": 0.0-1.0,
|
"owner_score": 0.0,
|
||||||
"project_score": 0.0-1.0,
|
"project_score": 0.0,
|
||||||
"level": "project|signal",
|
"level": "project|signal",
|
||||||
"level_confidence": 0.0-1.0,
|
"level_confidence": 0.0,
|
||||||
"level_reasoning": "1-2句解释",
|
"level_reasoning": "1-2句解释",
|
||||||
"should_split": true/false,
|
"should_split": false,
|
||||||
"split_items": [
|
"split_items": [
|
||||||
{{"level":"project","content":"拆分后的项目部分"}},
|
{{"level": "project", "content": "拆分后的项目部分"}},
|
||||||
{{"level":"signal","content":"拆分后的信号部分"}}
|
{{"level": "signal", "content": "拆分后的信号部分"}}
|
||||||
]
|
]
|
||||||
}}
|
}}
|
||||||
|
|
||||||
约束:
|
约束:
|
||||||
1. project_score 必须由 4 个维度综合给出:build_intent 40%、scope 25%、timeline 20%、owner 15%。
|
1. project_score = build_intent*40% + scope*25% + timeline*20% + owner*15%。
|
||||||
2. 若消息同时包含明确立项意图与明确信号事实(竞品发布/用户反馈/风险事件),should_split=true。
|
2. 同时包含明确立项意图与明确信号事实时,should_split=true。
|
||||||
3. 不确定是否拆分时 should_split=false,且默认 level=signal。
|
3. 不确定是否拆分时 should_split=false,默认 level=signal。
|
||||||
4. 只输出 JSON。
|
|
||||||
"""
|
"""
|
||||||
|
PROMPT_FEISHU_LEVEL_CLASSIFICATION = _compose_prompt(
|
||||||
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
PROMPT_FEISHU_LEVEL_CLASSIFICATION,
|
||||||
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"PROMPT_IMPACT_ANALYSIS",
|
"PROMPT_IMPACT_ANALYSIS",
|
||||||
"PROMPT_TASK_BREAKDOWN",
|
|
||||||
"PROMPT_STRATEGIC_ANALYSIS_HEADER",
|
"PROMPT_STRATEGIC_ANALYSIS_HEADER",
|
||||||
"PROMPT_RAG_CONTEXT_HEADER",
|
"PROMPT_RAG_CONTEXT_HEADER",
|
||||||
"PROMPT_RAG_CONTEXT_ITEM",
|
"PROMPT_RAG_CONTEXT_ITEM",
|
||||||
"PROMPT_CODE_AUDIT",
|
"PROMPT_CODE_AUDIT",
|
||||||
"PROMPT_CONTEXT_INSIGHTS",
|
"PROMPT_CONTEXT_INSIGHTS",
|
||||||
"PROMPT_FEISHU_REQUIREMENT_ANALYSIS",
|
|
||||||
"PROMPT_FEISHU_HUMANIZED_REPLY",
|
"PROMPT_FEISHU_HUMANIZED_REPLY",
|
||||||
"PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION",
|
"PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION",
|
||||||
"PROMPT_FEISHU_LEVEL_CLASSIFICATION",
|
"PROMPT_FEISHU_LEVEL_CLASSIFICATION",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,25 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .part_01 import JSON_OUTPUT_GUARDRAIL, PMFLOW_PMO_STYLE_BASE, _compose_prompt
|
||||||
|
|
||||||
|
EXTRACTION_SCOPE_BASE = """
|
||||||
|
职责边界:
|
||||||
|
1. 你是抽取层,只负责结构化抽取。
|
||||||
|
2. 不做执行决策,不输出无依据承诺。
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
PLANNING_SCOPE_BASE = """
|
||||||
|
职责边界:
|
||||||
|
1. 你是规划层,只负责“接下来怎么做”。
|
||||||
|
2. 不重做分类判断,不解释系统内部实现。
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
GOVERNANCE_SCOPE_BASE = """
|
||||||
|
职责边界:
|
||||||
|
1. 你是治理层,只负责边界约束、确认与安全守护。
|
||||||
|
2. 不编造外部数据、数据库状态或执行结果。
|
||||||
|
""".strip()
|
||||||
|
|
||||||
PROMPT_FEISHU_SIGNAL_EXTRACTION = """
|
PROMPT_FEISHU_SIGNAL_EXTRACTION = """
|
||||||
你是 PMFlow 的信号结构化助手。请从飞书消息中提取信号字段。
|
你是 PMFlow 的信号结构化助手。请从飞书消息中提取信号字段。
|
||||||
|
|
||||||
|
|
@ -14,16 +34,22 @@ PROMPT_FEISHU_SIGNAL_EXTRACTION = """
|
||||||
"priority": "P0|P1|P2|P3",
|
"priority": "P0|P1|P2|P3",
|
||||||
"matched_project_id": 1,
|
"matched_project_id": 1,
|
||||||
"summary": "2-3句摘要",
|
"summary": "2-3句摘要",
|
||||||
"reasoning": ["依据1","依据2","依据3"],
|
"reasoning": ["依据1", "依据2", "依据3"],
|
||||||
"confidence": 0.0-1.0,
|
"confidence": 0.0,
|
||||||
"recommended_action": "enter_only|create_charter|trigger_alert"
|
"recommended_action": "enter_only|create_charter|trigger_alert"
|
||||||
}}
|
}}
|
||||||
|
|
||||||
约束:
|
约束:
|
||||||
1. 无法确定 matched_project_id 时返回 null。
|
1. 无法确定 matched_project_id 时返回 null。
|
||||||
2. reasoning 必须是 3 条简短 bullet。
|
2. reasoning 必须是 3 条简短依据。
|
||||||
3. 只输出 JSON。
|
3. summary 聚焦业务信号,不输出执行承诺。
|
||||||
"""
|
"""
|
||||||
|
PROMPT_FEISHU_SIGNAL_EXTRACTION = _compose_prompt(
|
||||||
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
EXTRACTION_SCOPE_BASE,
|
||||||
|
PROMPT_FEISHU_SIGNAL_EXTRACTION,
|
||||||
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
PROMPT_FEISHU_PROJECT_PREFILL = """
|
PROMPT_FEISHU_PROJECT_PREFILL = """
|
||||||
你是 PMFlow 的项目预填助手。请从飞书消息中抽取新项目建议信息。
|
你是 PMFlow 的项目预填助手。请从飞书消息中抽取新项目建议信息。
|
||||||
|
|
@ -37,17 +63,22 @@ PROMPT_FEISHU_PROJECT_PREFILL = """
|
||||||
"suggested_description": "1-2句描述",
|
"suggested_description": "1-2句描述",
|
||||||
"suggested_owner": "负责人名或角色;不明确时返回 null",
|
"suggested_owner": "负责人名或角色;不明确时返回 null",
|
||||||
"suggested_timeline": "截止日期或周期;不明确时返回 null",
|
"suggested_timeline": "截止日期或周期;不明确时返回 null",
|
||||||
"confidence": 0.0-1.0,
|
"confidence": 0.0,
|
||||||
"reasoning": ["依据1","依据2","依据3"]
|
"reasoning": ["依据1", "依据2", "依据3"]
|
||||||
}}
|
}}
|
||||||
|
|
||||||
约束:
|
约束:
|
||||||
1. reasoning 必须是 3 条简短 bullet。
|
1. reasoning 必须是 3 条简短依据。
|
||||||
2. 只有在消息里明确出现负责人、角色、截止时间、周期时才填写对应字段。
|
2. 仅在消息明确出现负责人/角色/时间线时填写对应字段。
|
||||||
3. 不要为了“填满字段”去猜负责人或时间线;不明确时返回 null。
|
3. 不要为了填满字段而猜测;不明确时返回 null。
|
||||||
4. confidence 低于 0.6 时,suggested_owner 和 suggested_timeline 优先返回 null。
|
4. confidence < 0.6 时,suggested_owner 与 suggested_timeline 优先返回 null。
|
||||||
5. 只输出 JSON。
|
|
||||||
"""
|
"""
|
||||||
|
PROMPT_FEISHU_PROJECT_PREFILL = _compose_prompt(
|
||||||
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
EXTRACTION_SCOPE_BASE,
|
||||||
|
PROMPT_FEISHU_PROJECT_PREFILL,
|
||||||
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
PROMPT_FEISHU_TASK_ORCHESTRATION = """
|
PROMPT_FEISHU_TASK_ORCHESTRATION = """
|
||||||
你是 PMFlow 的任务编排与人员分配 AI。
|
你是 PMFlow 的任务编排与人员分配 AI。
|
||||||
|
|
@ -67,28 +98,33 @@ PROMPT_FEISHU_TASK_ORCHESTRATION = """
|
||||||
"title": "任务标题(8-32字)",
|
"title": "任务标题(8-32字)",
|
||||||
"description": "任务描述(40-200字)",
|
"description": "任务描述(40-200字)",
|
||||||
"priority": "high|medium|low",
|
"priority": "high|medium|low",
|
||||||
"estimated_hours": 1-80,
|
"estimated_hours": 1,
|
||||||
"required_skill_names": ["FastAPI", "React"],
|
"required_skill_names": ["FastAPI", "React"],
|
||||||
"recommended_assignee_id": 123,
|
"recommended_assignee_id": 123,
|
||||||
"assignee_reason": "为什么推荐该成员",
|
"assignee_reason": "为什么推荐该成员",
|
||||||
"confidence": 0.0-1.0
|
"confidence": 0.0
|
||||||
}}
|
}}
|
||||||
]
|
]
|
||||||
}}
|
}}
|
||||||
|
|
||||||
约束:
|
约束:
|
||||||
1. 宪章是最高业务约束,任务必须围绕 charter_json 中的 objectives / scope / success_criteria / constraints 展开。
|
1. 宪章是最高业务约束,优先围绕 objectives/scope/success_criteria/constraints 规划。
|
||||||
2. 如果 charter_json 为空,先依据 project_json 与 signal_json 做最小可执行拆解,不要自行编造宪章。
|
2. charter_json 为空时,基于 project_json 与 signal_json 做最小可执行拆解,不要编造宪章。
|
||||||
3. 任务数量 2-8 条,不要空数组。
|
3. 任务数量 2-8 条,不要空数组。
|
||||||
4. 优先覆盖“立即可执行”的任务,避免泛化管理话术。
|
4. 优先立即可执行任务,避免泛化管理话术。
|
||||||
5. `recommended_assignee_id` 必须来自 members_json.user_id;无法确认时填 null。
|
5. recommended_assignee_id 必须来自 members_json.user_id,无法确认时填 null。
|
||||||
6. 推荐负责人时同时考虑技能匹配与 workload_ratio,避免把任务继续压给高负载成员。
|
6. 推荐负责人时同时考虑技能匹配与 workload_ratio,避免继续压给高负载成员。
|
||||||
7. 只输出 JSON,不要 Markdown,不要解释。
|
|
||||||
"""
|
"""
|
||||||
|
PROMPT_FEISHU_TASK_ORCHESTRATION = _compose_prompt(
|
||||||
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
PLANNING_SCOPE_BASE,
|
||||||
|
PROMPT_FEISHU_TASK_ORCHESTRATION,
|
||||||
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
PROMPT_SIGNAL_SOURCE_ONBOARDING = """
|
PROMPT_SIGNAL_SOURCE_ONBOARDING = """
|
||||||
首字符必须是 {{,末字符必须是 }}。
|
首字符必须是 {{,末字符必须是 }}。
|
||||||
你是 PMFlow 的信号源接入规划器。判断当前请求是不是“新增/接入一个信号源”,并返回结构化计划。
|
你是 PMFlow 的信号源接入规划器。判断当前请求是否属于“新增/接入信号源”,并返回结构化计划。
|
||||||
|
|
||||||
输入:
|
输入:
|
||||||
- request_json: {request_json}
|
- request_json: {request_json}
|
||||||
|
|
@ -121,18 +157,21 @@ PROMPT_SIGNAL_SOURCE_ONBOARDING = """
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
1. 只有明确新增/接入/配置信号源时才返回 should_onboard=true。
|
1. 只有明确新增/接入/配置信号源时才返回 should_onboard=true。
|
||||||
2. 不编造 url / chat_id / repo / webhook。
|
2. 不编造 url/chat_id/repo/webhook。
|
||||||
3. worker_roles 只允许 pm_dispatcher / tech_architect / delivery_coach。
|
3. worker_roles 只允许 pm_dispatcher/tech_architect/delivery_coach。
|
||||||
4. execution_instruction 保持简短,只说接入准备、任务拆解、负责人通知。
|
4. execution_instruction 保持简短,仅描述接入准备、任务拆解、负责人通知。
|
||||||
5. 无法确定 source_identifier 时,返回短且稳定的键,不要输出解释。
|
5. 无法确定 source_identifier 时,返回短且稳定的键。
|
||||||
6. 禁止输出 thinking、analysis、解释句、Markdown、代码块。
|
|
||||||
7. 字段未知时返回空字符串、空数组或 null。
|
|
||||||
8. 不要 Markdown,不要解释,只返回 JSON。
|
|
||||||
"""
|
"""
|
||||||
|
PROMPT_SIGNAL_SOURCE_ONBOARDING = _compose_prompt(
|
||||||
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
GOVERNANCE_SCOPE_BASE,
|
||||||
|
PROMPT_SIGNAL_SOURCE_ONBOARDING,
|
||||||
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION = """
|
PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION = """
|
||||||
首字符必须是 {{,末字符必须是 }}。
|
首字符必须是 {{,末字符必须是 }}。
|
||||||
你是 PMFlow 的下级 AI worker,正在执行一个“signal source onboarding”工作项。
|
你是 PMFlow 的下级 AI worker,正在执行一个 signal source onboarding 工作项。
|
||||||
|
|
||||||
输入:
|
输入:
|
||||||
- role_json: {role_json}
|
- role_json: {role_json}
|
||||||
|
|
@ -150,14 +189,17 @@ PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION = """
|
||||||
}}
|
}}
|
||||||
|
|
||||||
约束:
|
约束:
|
||||||
1. 输出必须体现当前 role_json 对应角色的职责边界。
|
1. 输出必须体现 role_json 对应角色职责边界。
|
||||||
2. `deliverables` / `risks` / `next_actions` 各自最多 4 条。
|
2. deliverables/risks/next_actions 各自最多 4 条。
|
||||||
3. 不要编造数据库 ID,不要输出代码块。
|
3. 顶层必须始终包含 summary/deliverables/risks/next_actions 四个键。
|
||||||
4. 顶层必须始终包含 summary / deliverables / risks / next_actions 四个键,即使数组为空。
|
4. 不要编造数据库 ID。
|
||||||
5. 字段未知时返回空字符串、空数组或 null。
|
|
||||||
6. 禁止输出 thinking、analysis、解释句、Markdown、代码块。
|
|
||||||
7. 只输出 JSON,不要 Markdown,不要解释文字。
|
|
||||||
"""
|
"""
|
||||||
|
PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION = _compose_prompt(
|
||||||
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
PLANNING_SCOPE_BASE,
|
||||||
|
PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION,
|
||||||
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
|
PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
|
||||||
首字符必须是 {{,末字符必须是 }}。
|
首字符必须是 {{,末字符必须是 }}。
|
||||||
|
|
@ -169,7 +211,7 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
|
||||||
- session_project_id: {session_project_id}
|
- session_project_id: {session_project_id}
|
||||||
- world_model_project_id: {world_model_project_id}
|
- world_model_project_id: {world_model_project_id}
|
||||||
|
|
||||||
只输出一个 JSON 对象(不要解释、不要 Markdown):
|
只输出一个 JSON 对象:
|
||||||
{{
|
{{
|
||||||
"family": "query|project_create|project|task|personnel|knowledge|notification|project_signal|signal_source|followup",
|
"family": "query|project_create|project|task|personnel|knowledge|notification|project_signal|signal_source|followup",
|
||||||
"action": "answer|create|update|archive|reopen|delete|assign|prioritize|status_transition|role_update|workload_update|availability_update|doc_upsert|chunk_upsert|link_upsert|reindex|ack|close|discover|onboard|continue|confirm",
|
"action": "answer|create|update|archive|reopen|delete|assign|prioritize|status_transition|role_update|workload_update|availability_update|doc_upsert|chunk_upsert|link_upsert|reindex|ack|close|discover|onboard|continue|confirm",
|
||||||
|
|
@ -178,7 +220,7 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
|
||||||
"project_id": 0,
|
"project_id": 0,
|
||||||
"requested_count": 1,
|
"requested_count": 1,
|
||||||
"requires_confirmation": false,
|
"requires_confirmation": false,
|
||||||
"parameters": {
|
"parameters": {{
|
||||||
"project_id": 0,
|
"project_id": 0,
|
||||||
"project_name": "可选",
|
"project_name": "可选",
|
||||||
"task_id": 0,
|
"task_id": 0,
|
||||||
|
|
@ -205,37 +247,36 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
|
||||||
"target_id": 0,
|
"target_id": 0,
|
||||||
"url": "可选",
|
"url": "可选",
|
||||||
"path": "可选"
|
"path": "可选"
|
||||||
},
|
}},
|
||||||
"reasoning": "一句话简述判定原因"
|
"reasoning": "一句话简述判定原因"
|
||||||
}}
|
}}
|
||||||
|
|
||||||
判定规则(按优先级):
|
判定规则(按优先级):
|
||||||
1. query.read_only 定义:仅当用户是在“查询 / 了解 / 分析已有内容”时,才判定为 query.read_only。
|
1. query.read_only:仅当用户是查询/了解/分析已有内容时才返回。
|
||||||
2. 若消息同时包含“新增 / 创建 / 记录 / 提交”等写操作意图,优先判定为对应写操作意图,不要误判为 query.read_only。
|
2. 出现新增/创建/记录/提交等写操作意图时,优先判定写操作意图。
|
||||||
3. project.create:当用户明确表达“创建一个新项目”时,返回 family=project_create, action=create, intent=project.create。
|
3. project.create:明确“创建新项目”。
|
||||||
4. project.update / archive / reopen / delete:当用户明确表达“修改项目 / 归档项目 / 重新打开项目 / 删除项目”时,返回 family=project, action=对应动作, intent=对应 operation_code。
|
4. project.update/archive/reopen/delete:明确修改、归档、重开、删除项目。
|
||||||
5. task.create:当用户明确表达“创建/新增/安排任务”时,返回 family=task, action=create, intent=task.create。
|
5. task.create:明确创建/新增/安排任务。
|
||||||
6. task.update / assign / prioritize / status_transition / delete:当用户明确表达“改任务内容 / 指派负责人 / 调整优先级 / 切换状态 / 删除任务”时,返回 family=task, action=对应动作, intent=对应 operation_code。
|
6. task.update/assign/prioritize/status_transition/delete:明确修改任务相关动作。
|
||||||
7. personnel.*:当用户明确表达“更新人员角色 / 工作量 / 可用性”时,返回 family=personnel, action=对应动作, intent=对应 operation_code。
|
7. personnel.*:明确更新角色/工作量/可用性。
|
||||||
8. knowledge.*:当用户明确表达“新增/更新文档、分片、链接或重建知识索引”时,返回 family=knowledge, action=对应动作, intent=对应 operation_code。
|
8. knowledge.*:明确文档/分片/链接入库或重建索引。
|
||||||
9. notification.*:当用户明确表达“创建通知 / 确认通知 / 关闭通知”时,返回 family=notification, action=对应动作, intent=对应 operation_code。
|
9. notification.*:明确创建、确认、关闭通知。
|
||||||
10. signal.create 定义:当用户想创建 / 新增 / 录入 / 提交一条新的需求、功能、信号、想法或问题时,返回 family=project_signal, action=create, intent=signal.create。核心特征是“要产生一条新记录”,不管用户用什么词表达。
|
10. signal.create:明确创建/录入新需求、功能、信号、想法或问题。
|
||||||
11. signal.create few-shot:
|
11. project_signal discover:表达“先分析候选并确认”时,action=discover 且 requires_confirmation=true。
|
||||||
- “给XX项目加个需求” -> signal.create
|
12. signal_source.onboard:表达信号源/webhook/rss/github 接入时返回该意图。
|
||||||
- “新增一个功能需求” -> signal.create
|
13. followup.continue 仅在 reply_to 命中待确认草稿时使用。
|
||||||
- “帮我记录这个想法” -> signal.create
|
14. 无 reply_to 草稿锚点时,不要因确认词误判 followup.continue。
|
||||||
- “提个需求:支持导出” -> signal.create
|
15. project_id 不确定返回 null;requested_count 默认 1(project_signal 可取 1-5)。
|
||||||
12. project_signal discover:当用户表达“先分析候选 / 拆分候选并确认”时,返回 family=project_signal, action=discover, intent=signal.create, requires_confirmation=true。
|
16. family/action/intent 只能使用枚举值,不能自造。
|
||||||
13. signal_source.onboard:当用户表达“信号源 / 来源 / webhook / rss / github 接入”时,返回 family=signal_source, action=onboard, intent=signal_source.onboard。
|
17. 写操作的 parameters 能识别则尽可能填写,不能识别时返回 null。
|
||||||
14. 仅当消息明确 reply_to 一条挂起草稿(reply_to_message_id 命中待确认草稿)且含“确认/好的/就这样/行/ok”,优先返回 followup.continue(用于草稿确认执行)。
|
18. 若请求 DDL(建表/改表/删表),必须返回 query.read_only,reasoning 包含 ddl_api_required。
|
||||||
15. 仅当消息明确 reply_to 一条挂起草稿且含“修改/改成/换成”,返回 followup.continue(用于草稿重起草)。
|
|
||||||
16. 仅当消息明确 reply_to 一条挂起草稿且含“取消/不要/算了”,返回 followup.continue(用于草稿取消)。
|
|
||||||
17. 若没有 reply_to 挂起草稿锚点,不要因为确认词直接进入 followup.continue;无把握时返回 query.read_only。
|
|
||||||
18. project_id 不确定返回 null;requested_count 默认 1(仅 project_signal 有效,范围 1-5)。
|
|
||||||
19. family/action/intent 必须严格使用上面 JSON 示例给定的枚举值,不能自造。
|
|
||||||
20. 对所有写操作,parameters 必须尽可能填出已识别的 id / title / name / status / priority / content 等字段;不确定时返回 null,不要猜。
|
|
||||||
21. 若请求 DDL(建表/改表/删表),必须返回 query.read_only,reasoning 含 ddl_api_required。
|
|
||||||
"""
|
"""
|
||||||
|
PROMPT_AI_PMO_INTENT_CLASSIFICATION = _compose_prompt(
|
||||||
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
GOVERNANCE_SCOPE_BASE,
|
||||||
|
PROMPT_AI_PMO_INTENT_CLASSIFICATION,
|
||||||
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """
|
PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """
|
||||||
首字符必须是 {{,末字符必须是 }}。
|
首字符必须是 {{,末字符必须是 }}。
|
||||||
|
|
@ -246,7 +287,7 @@ PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """
|
||||||
- project_id: {project_id}
|
- project_id: {project_id}
|
||||||
- requested_count: {requested_count}
|
- requested_count: {requested_count}
|
||||||
|
|
||||||
输出 JSON(只输出 JSON):
|
输出 JSON:
|
||||||
{{
|
{{
|
||||||
"signals": [
|
"signals": [
|
||||||
{{
|
{{
|
||||||
|
|
@ -262,15 +303,19 @@ PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
1. signals 数量必须等于 requested_count。
|
1. signals 数量必须等于 requested_count。
|
||||||
2. 标题和摘要禁止原样照抄整句用户指令。
|
2. 标题和摘要禁止原样照抄用户原句。
|
||||||
3. 当信息不足时,允许基于指令语义做合理拆分,但必须给出可执行、可入库的信号文本。
|
3. 信息不足时可做合理拆分,但必须输出可执行、可入库文本。
|
||||||
4. 不要输出 Markdown,不要解释,不要代码块。
|
"""
|
||||||
""".strip()
|
PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = _compose_prompt(
|
||||||
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
EXTRACTION_SCOPE_BASE,
|
||||||
|
PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS,
|
||||||
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
PROMPT_DRAFT_GENERATOR = """
|
PROMPT_DRAFT_GENERATOR = """
|
||||||
首字符必须是 {{,末字符必须是 }}。
|
首字符必须是 {{,末字符必须是 }}。
|
||||||
你是 PMFlow 的资深产品经理。
|
你是 PMFlow 的资深产品经理。收到新业务需求后,请先做立项评审,再起草项目方案。
|
||||||
收到一条新的业务需求,请先按产品经理视角做立项评审,再起草详细方案。
|
|
||||||
|
|
||||||
输入:
|
输入:
|
||||||
- 当前上下文项目:{project_title} - {project_description}
|
- 当前上下文项目:{project_title} - {project_description}
|
||||||
|
|
@ -285,7 +330,7 @@ PROMPT_DRAFT_GENERATOR = """
|
||||||
请输出 JSON,包含:
|
请输出 JSON,包含:
|
||||||
- title: 语义化标题(10字以内,必须概括目标,不得复述原话)
|
- title: 语义化标题(10字以内,必须概括目标,不得复述原话)
|
||||||
- summary: 项目定位与需求摘要(80字以内)
|
- summary: 项目定位与需求摘要(80字以内)
|
||||||
- similar_projects: 同名/相似项目列表,元素包含 project_id、title、difference、risk、reuse_suggestion
|
- similar_projects: 同名/相似项目列表,元素含 project_id/title/difference/risk/reuse_suggestion
|
||||||
- mvp_items: MVP 建议数组,3-5 条
|
- mvp_items: MVP 建议数组,3-5 条
|
||||||
- prospect_assessment: 前景判断(1-3 句)
|
- prospect_assessment: 前景判断(1-3 句)
|
||||||
- requirement_items: 需求清单数组,3-8 条
|
- requirement_items: 需求清单数组,3-8 条
|
||||||
|
|
@ -297,22 +342,23 @@ PROMPT_DRAFT_GENERATOR = """
|
||||||
- followup_plan: 数组,每项含 when 和 action
|
- followup_plan: 数组,每项含 when 和 action
|
||||||
|
|
||||||
约束:
|
约束:
|
||||||
1. 只输出 JSON,不要解释,不要思考过程,不要 Markdown。
|
1. title 必须是语义化摘要,不能复述原始消息长片段。
|
||||||
2. title 必须是语义化摘要,不能包含原始消息中的原话或长片段复述。示例:可写“劝退后次日跟进机制”,不要写“给AI劝退助手加个需求...”。
|
2. feasibility:明确可实现=合理;边界不清/依赖冲突=待讨论;明显超范围=不合理。
|
||||||
3. feasibility 判定规则:
|
3. 若存在同名或高度相似项目,必须写出差异与复用/合并建议。
|
||||||
- 明确的功能需求且技术可实现 => 合理
|
4. MVP 建议优先最小可验证闭环,不写泛化愿景。
|
||||||
- 需求模糊、边界冲突或依赖不清 => 待讨论
|
5. 推荐负责人优先选择负载 <80% 成员;若都 >=80%,选最低负载并明确负载风险。
|
||||||
- 明显不可行或超出系统范围 => 不合理
|
6. followup_plan 最少 1 项,最多 3 项。
|
||||||
4. 如果存在同名或高度相似项目,必须写出差异与复用/合并建议。
|
7. 无法确定推荐人时,recommended_assignee_id 返回 null。
|
||||||
5. MVP 建议优先写“最小可验证闭环”,不要写成泛化愿景。
|
"""
|
||||||
6. 推荐负责人优先选择负载低于 80% 的成员。
|
PROMPT_DRAFT_GENERATOR = _compose_prompt(
|
||||||
7. 若所有成员负载都 >= 80%,推荐负载最低者,并在 recommended_assignee_reason 明确写出负载风险。
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
8. followup_plan 最少 1 项,最多 3 项。
|
PLANNING_SCOPE_BASE,
|
||||||
9. 若无法确定推荐人,recommended_assignee_id 返回 null。
|
PROMPT_DRAFT_GENERATOR,
|
||||||
""".strip()
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
PROMPT_RAG_INTENT_CLASSIFICATION = """
|
PROMPT_RAG_INTENT_CLASSIFICATION = """
|
||||||
你是 PMFlow 的意图分类器。请判断输入文本属于:question / statement / mixed。
|
你是 PMFlow 的意图分类器。请判断输入文本属于 question / statement / mixed。
|
||||||
|
|
||||||
输入:
|
输入:
|
||||||
{message}
|
{message}
|
||||||
|
|
@ -324,9 +370,13 @@ PROMPT_RAG_INTENT_CLASSIFICATION = """
|
||||||
"question_part": "若无则空字符串",
|
"question_part": "若无则空字符串",
|
||||||
"statement_part": "若无则空字符串"
|
"statement_part": "若无则空字符串"
|
||||||
}}
|
}}
|
||||||
|
|
||||||
要求:只输出 JSON。
|
|
||||||
"""
|
"""
|
||||||
|
PROMPT_RAG_INTENT_CLASSIFICATION = _compose_prompt(
|
||||||
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
EXTRACTION_SCOPE_BASE,
|
||||||
|
PROMPT_RAG_INTENT_CLASSIFICATION,
|
||||||
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"PROMPT_FEISHU_SIGNAL_EXTRACTION",
|
"PROMPT_FEISHU_SIGNAL_EXTRACTION",
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,86 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
PROMPT_RAG_ANSWER = """
|
from .part_01 import JSON_OUTPUT_GUARDRAIL, PMFLOW_PMO_STYLE_BASE, TEXT_OUTPUT_GUARDRAIL, _compose_prompt
|
||||||
你是 PMFlow 的 RAG 问答助手,负责项目与人员协同管理。
|
|
||||||
要求:
|
|
||||||
1. 只基于给定上下文回答,不要编造。
|
|
||||||
2. 若证据不足,明确说“不确定/信息不足”。
|
|
||||||
3. 回答后用“来源:”列出引用的来源编号。
|
|
||||||
4. 优先从 System Snapshot 与 User Snapshot 作答,不要忽略结构化业务数据。
|
|
||||||
5. 当问题涉及“我/当前用户/我的任务”时,只使用 User Snapshot 对应数据回答。
|
|
||||||
6. 不允许出现“无法访问项目数据”这类泛化托辞,若缺失请明确指出缺失字段。
|
|
||||||
7. 当问题是“你是谁/你能做什么”时,必须优先使用 System Snapshot.assistant_profile 回答。
|
|
||||||
8. 当问题涉及“系统架构/运行机制/技术栈”时,必须优先使用 System Snapshot.platform_profile 回答。
|
|
||||||
9. 即使检索上下文为空,也要基于 Snapshot 给出当前系统可验证信息,禁止输出默认兜底句。
|
|
||||||
10. 若字段缺失,显式列出缺失字段名,不要假设。
|
|
||||||
11. 当问题涉及“成员/人员/负责人/谁在做”时,优先使用 System Snapshot.team_members、User Snapshot 与结构化数据库结果,禁止默认返回项目列表。
|
|
||||||
12. 直接输出最终答案,禁止输出思考过程。禁止以"让我/我来/我先/我需要"等开头。
|
|
||||||
若信息不足直接说:"当前信息不足以回答此问题,缺少:[具体缺失字段]"
|
|
||||||
|
|
||||||
当前项目ID:{project_id}
|
RESPONSE_SCOPE_BASE = """
|
||||||
问题:{question}
|
职责边界:
|
||||||
检索最大相关度:{relevance_max}
|
1. 你是呈现层,只负责把已知事实转成可读答复。
|
||||||
|
2. 不编造数据,不透露系统实现,不替用户做写操作。
|
||||||
|
""".strip()
|
||||||
|
|
||||||
System Snapshot(全局):
|
PLANNING_SCOPE_BASE = """
|
||||||
|
职责边界:
|
||||||
|
1. 你是规划层,只负责把上下文转成可执行方案。
|
||||||
|
2. 不重复分类,不输出空泛管理话术。
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
GOVERNANCE_SCOPE_BASE = """
|
||||||
|
职责边界:
|
||||||
|
1. 你是治理层,只负责评估、约束与风控建议。
|
||||||
|
2. 不替代业务决策,不伪造证据,不输出泛化结论。
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
PROMPT_RAG_ANSWER = _compose_prompt(
|
||||||
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
RESPONSE_SCOPE_BASE,
|
||||||
|
"""
|
||||||
|
你是 PMFlow 的 RAG 问答助手。
|
||||||
|
|
||||||
|
输入:
|
||||||
|
- 当前项目ID:{project_id}
|
||||||
|
- 问题:{question}
|
||||||
|
- 检索最大相关度:{relevance_max}
|
||||||
|
- System Snapshot:
|
||||||
{system_snapshot}
|
{system_snapshot}
|
||||||
|
- User Snapshot:
|
||||||
User Snapshot(当前用户):
|
|
||||||
{user_snapshot}
|
{user_snapshot}
|
||||||
|
- 最近会话:
|
||||||
最近会话(最多10轮):
|
|
||||||
{conversation}
|
{conversation}
|
||||||
|
- 检索上下文:
|
||||||
检索上下文:
|
|
||||||
{context_blocks}
|
{context_blocks}
|
||||||
"""
|
|
||||||
|
|
||||||
PROMPT_DECISION_QA = """
|
回答规则:
|
||||||
你是 PMFlow 的产品顾问,负责处理产品取舍、优先级、二选一和方案比较类问题。
|
1. 只基于给定上下文回答,不要编造。
|
||||||
|
2. 若证据不足,明确说“不确定/信息不足”,并写出缺失项。
|
||||||
|
3. 当问题涉及“我/当前用户/我的任务”时,只使用 User Snapshot 对应数据回答。
|
||||||
|
4. 先给结论,再给关键依据,最后给下一步建议。
|
||||||
|
5. 回答末尾用“来源:...”列出所依据的上下文片段名称或编号。
|
||||||
|
6. 直接输出最终答案,不要输出思考过程。
|
||||||
|
""",
|
||||||
|
TEXT_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
规则:
|
PROMPT_DECISION_QA = _compose_prompt(
|
||||||
1. 直接给出明确选择,不要回避,不要反问。
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
2. 如果用户给出两个或多个选项,必须明确选一个。
|
GOVERNANCE_SCOPE_BASE,
|
||||||
3. 不依赖数据库、历史记录或外部实时数据,基于常识与办公协作场景给出最稳妥建议即可。
|
"""
|
||||||
4. 先给结论,再给 1-2 条理由,回答要简洁。
|
你是 PMFlow 的产品决策顾问,负责处理产品取舍、优先级和方案比较类问题。
|
||||||
5. 不输出思考过程,不要使用“让我/我来/我先/我需要”开头。
|
|
||||||
|
|
||||||
问题:{question}
|
输入:
|
||||||
"""
|
- 问题:{question}
|
||||||
|
|
||||||
PROMPT_RAG_DB_PROJECT_QA = """
|
回答规则:
|
||||||
|
1. 对取舍、优先级、方案比较,必须明确选择一个最稳妥的方向。
|
||||||
|
2. 先给结论,再给 1-2 条理由,回答要简洁。
|
||||||
|
3. 若选项没有写全,基于项目管理与办公协作常识给出最稳妥建议。
|
||||||
|
4. 不要回避,不要反问,不要输出内部推理过程。
|
||||||
|
""",
|
||||||
|
TEXT_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
|
PROMPT_RAG_DB_PROJECT_QA = _compose_prompt(
|
||||||
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
RESPONSE_SCOPE_BASE,
|
||||||
|
"""
|
||||||
你是 PMFlow 的项目数据语义检索助手。
|
你是 PMFlow 的项目数据语义检索助手。
|
||||||
你会收到“用户问题”和“项目索引(JSON)”,请基于索引做语义匹配后回答。
|
请基于“用户问题”和“项目索引(JSON)”做语义匹配后回答。
|
||||||
|
|
||||||
规则:
|
输入:
|
||||||
1. 优先语义理解,不要机械关键词匹配;要容错同义词、口语、轻微错别字(如“键身”≈“健身”)。
|
- 用户问题:{question}
|
||||||
2. 若用户问“有没有/是否存在某类项目”,必须给出明确结论(有/没有)并列出匹配项目。
|
- 项目索引(JSON):
|
||||||
3. 若用户问“进度/阶段/完成率”,请综合 task_total/done_count/doing_count/pending_count 输出量化结论。
|
{project_index}
|
||||||
4. 若证据不足,请明确说明不足点,不要编造。
|
|
||||||
5. 若问题焦点是成员/人员/负责人/谁在做(人员管理),该问题不应由项目索引直接回答,必须返回 handled=false。
|
|
||||||
|
|
||||||
输出 JSON(只输出 JSON,不要 Markdown):
|
输出 JSON:
|
||||||
{{
|
{{
|
||||||
"handled": true,
|
"handled": true,
|
||||||
"answer": "给用户展示的最终中文回答",
|
"answer": "给用户展示的最终中文回答",
|
||||||
|
|
@ -66,65 +88,66 @@ PROMPT_RAG_DB_PROJECT_QA = """
|
||||||
"confidence": 0.0
|
"confidence": 0.0
|
||||||
}}
|
}}
|
||||||
|
|
||||||
用户问题:
|
规则:
|
||||||
{question}
|
1. 若项目索引无法回答,返回 handled=false,answer 置空,matched_project_ids 置空数组。
|
||||||
|
2. answer 只写对用户有用的最终回答,不要复述索引原文。
|
||||||
|
3. confidence 用 0.0-1.0 的小数。
|
||||||
|
4. 不要输出 Markdown、解释文本或思考过程。
|
||||||
|
""",
|
||||||
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
项目索引(JSON):
|
PROMPT_GENERATE_CHARTER = _compose_prompt(
|
||||||
{project_index}
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
"""
|
PLANNING_SCOPE_BASE,
|
||||||
|
"""
|
||||||
PROMPT_GENERATE_CHARTER = """
|
|
||||||
首字符必须是 {{,末字符必须是 }}。
|
|
||||||
你是一位 PMFlow 的资深产品经理。根据当前项目上下文与输入信号,生成一份结构化的需求宪章草案。
|
你是一位 PMFlow 的资深产品经理。根据当前项目上下文与输入信号,生成一份结构化的需求宪章草案。
|
||||||
|
|
||||||
输入:
|
输入:
|
||||||
- 当前项目上下文:{project_context}
|
- 当前项目上下文:{project_context}
|
||||||
- 现有活跃项目(JSON):{active_projects_json}
|
- 现有活跃项目(JSON):{active_projects_json}
|
||||||
- 信号内容:{signal_content}
|
- 信号输入:{signal_content}
|
||||||
|
|
||||||
请先判断这条需求更适合作为:
|
请先判断是否存在同名或高度相似项目,再决定是否新建、复用、合并或暂缓。
|
||||||
1. 当前项目的延展;
|
|
||||||
2. 现有活跃项目的复用/合并;
|
|
||||||
3. 必须新建项目。
|
|
||||||
|
|
||||||
如果存在同名或高度相似项目,必须在背景、范围和风险里明确写出差异点、复用点与合并建议。
|
请输出 JSON,包含:
|
||||||
|
|
||||||
请输出 JSON 格式,包含以下字段:
|
|
||||||
{{
|
{{
|
||||||
"title": "需求宪章标题",
|
"title": "需求宪章标题",
|
||||||
"summary": "一句话摘要",
|
"summary": "一句话摘要",
|
||||||
"background": "背景与动机",
|
"background": "背景与动机",
|
||||||
"objectives": ["目标1", "目标2"],
|
"objectives": ["目标1", "目标2"],
|
||||||
"scope": "范围说明",
|
"scope": "范围说明",
|
||||||
"success_metrics": ["指标1", "指标2"],
|
"success_criteria": ["标准1", "标准2"],
|
||||||
"risks": ["风险1", "风险2"]
|
"risks": ["风险1", "风险2"],
|
||||||
|
"dependencies": ["依赖1", "依赖2"],
|
||||||
|
"mvp_items": ["MVP项1", "MVP项2"],
|
||||||
|
"owner_suggestion": "负责人建议",
|
||||||
|
"followup_questions": ["待确认问题1", "待确认问题2"]
|
||||||
}}
|
}}
|
||||||
|
|
||||||
只输出 JSON,不要其他文字。
|
约束:
|
||||||
"""
|
1. 只围绕 PMFlow 的项目立项、复用、合并与宪章起草目标输出。
|
||||||
|
2. 若信息不足,followup_questions 最多 3 条。
|
||||||
|
3. MVP 优先最小可验证闭环,不要写空泛愿景。
|
||||||
|
4. 输出必须可直接被 json.loads 解析。
|
||||||
|
""",
|
||||||
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
PROMPT_DECOMPOSE_TASKS = """
|
PROMPT_DECOMPOSE_TASKS = _compose_prompt(
|
||||||
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
PLANNING_SCOPE_BASE,
|
||||||
|
"""
|
||||||
你是一位 AI 技术项目经理。根据以下需求宪章与项目上下文,拆解为可执行的任务列表。
|
你是一位 AI 技术项目经理。根据以下需求宪章与项目上下文,拆解为可执行的任务列表。
|
||||||
项目 ID:{project_id}。
|
|
||||||
|
|
||||||
宪章内容:
|
输入:
|
||||||
{charter_content}
|
- 项目 ID:{project_id}
|
||||||
|
- 需求宪章(JSON):{charter_content}
|
||||||
|
- 可用技能:{available_skills}
|
||||||
|
|
||||||
请识别每个任务所需的 AI 开发技能标签(仅从以下系统技能中匹配:{available_skills})。
|
请识别每个任务所需的 AI 开发技能标签(仅从系统技能中匹配)。
|
||||||
|
|
||||||
规则:
|
输出 JSON:
|
||||||
1. 如果宪章内容是结构化 JSON,请优先读取其中的 `request` / `project` / `charter` / `modules` 字段。
|
|
||||||
2. 优先围绕 `objectives`、`scope`、`success_criteria`、`constraints`、`milestones`、`risks` 拆解任务。
|
|
||||||
3. 任务要尽量贴近可执行产出,不要输出空泛的管理话术。
|
|
||||||
4. 如果上下文同时给出项目与宪章信息,以宪章约束为主,项目信息用于补充边界。
|
|
||||||
5. 每个任务必须生成稳定的 `task_key`,推荐格式为 `task-1`、`task-2`、`task-3`。
|
|
||||||
6. `required_skill_names` 只写系统技能名称;`required_skill_tag_ids` 可留空,由后端按技能名称自动回填。
|
|
||||||
7. `depends_on_task_keys` 用于描述任务依赖,必须引用前面任务的 `task_key`。
|
|
||||||
8. `risk_level` 只能是 `low`、`medium`、`high` 或 `critical`。
|
|
||||||
9. `confidence` 使用 0 到 1 的小数,越接近 1 表示越确定。
|
|
||||||
10. `evidence_refs` 填写宪章或项目上下文里的证据位置,例如 `charter.objectives.0`、`charter.risks.1`。
|
|
||||||
|
|
||||||
输出 JSON 格式:
|
|
||||||
{{
|
{{
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{{
|
{{
|
||||||
|
|
@ -146,66 +169,89 @@ PROMPT_DECOMPOSE_TASKS = """
|
||||||
"detected_skills": ["检测到的所有技能"]
|
"detected_skills": ["检测到的所有技能"]
|
||||||
}}
|
}}
|
||||||
|
|
||||||
只输出 JSON,不要其他文字。
|
要求:
|
||||||
"""
|
1. 任务要可执行、可验收,避免空泛管理语言。
|
||||||
|
2. 优先体现与业务目标直接相关的最小闭环。
|
||||||
|
3. evidence_refs 必须能对应到传入的 charter_content。
|
||||||
|
""",
|
||||||
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
PROMPT_RECOMMEND_ASSIGNEES = """
|
PROMPT_RECOMMEND_ASSIGNEES = _compose_prompt(
|
||||||
你是一位项目协调员。根据以下任务内容、所需技能、以及候选人信息,推荐 Top3 最合适的负责人。
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
PLANNING_SCOPE_BASE,
|
||||||
|
"""
|
||||||
|
你是一位项目协调员。根据以下任务内容、所需技能和候选人信息,推荐 Top3 最合适的负责人。
|
||||||
|
|
||||||
任务内容:{task_content}
|
输入:
|
||||||
|
- 任务内容:{task_content}
|
||||||
所需技能标签 IDs:{required_skill_tag_ids}
|
- 所需技能标签 IDs:{required_skill_tag_ids}
|
||||||
|
- 候选人信息(JSON 数组):
|
||||||
候选人信息(JSON 数组,每项包含 user_id, user_name, workload, proficiency_avg, skill_match_count):
|
|
||||||
{candidates_json}
|
{candidates_json}
|
||||||
|
|
||||||
请考虑:当前负载、技能匹配度、历史表现。输出 JSON:
|
输出 JSON:
|
||||||
{{
|
{{
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
{{
|
{{
|
||||||
"user_id": "用户ID",
|
"user_id": "用户ID",
|
||||||
"user_name": "姓名",
|
"user_name": "姓名",
|
||||||
"match_score": 0-100,
|
"match_score": 0,
|
||||||
"reason": "推荐理由"
|
"reason": "推荐理由"
|
||||||
}}
|
}}
|
||||||
]
|
]
|
||||||
}}
|
}}
|
||||||
|
|
||||||
只输出 JSON。
|
规则:
|
||||||
"""
|
1. 至多推荐 3 人,按 match_score 从高到低排序。
|
||||||
|
2. 推荐理由要同时解释技能匹配与负载/可用性判断。
|
||||||
|
3. 不要编造候选人列表中不存在的用户。
|
||||||
|
4. match_score 使用 0-100 整数或小数都可,但需保持一致。
|
||||||
|
""",
|
||||||
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
PROMPT_ALIGNMENT_SCORE = """
|
PROMPT_ALIGNMENT_SCORE = _compose_prompt(
|
||||||
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
GOVERNANCE_SCOPE_BASE,
|
||||||
|
"""
|
||||||
计算用户对真理节点的认知对齐度(0-100 分)。
|
计算用户对真理节点的认知对齐度(0-100 分)。
|
||||||
|
|
||||||
用户 ID:{user_id}
|
输入:
|
||||||
真理节点 ID:{truth_node_id}
|
- 用户 ID:{user_id}
|
||||||
|
- 真理节点 ID:{truth_node_id}
|
||||||
真理节点内容:
|
- 真理节点内容:
|
||||||
{truth_content}
|
{truth_content}
|
||||||
|
- 用户相关行为/产出:
|
||||||
用户相关行为/产出(如有):
|
|
||||||
{user_context}
|
{user_context}
|
||||||
|
|
||||||
请评估该用户对该真理节点的理解与践行程度。输出 JSON:
|
输出 JSON:
|
||||||
{{
|
{{
|
||||||
"alignment_score": 0-100,
|
"alignment_score": 0,
|
||||||
"explanation": "简要说明"
|
"explanation": "简要说明"
|
||||||
}}
|
}}
|
||||||
|
|
||||||
只输出 JSON。
|
规则:
|
||||||
"""
|
1. alignment_score 范围必须是 0-100。
|
||||||
|
2. explanation 只写最关键的判断依据,不要写推理过程。
|
||||||
|
3. 若证据不足,说明缺少什么。
|
||||||
|
""",
|
||||||
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
PROMPT_COGNITIVE_CONFLICTS = """
|
PROMPT_COGNITIVE_CONFLICTS = _compose_prompt(
|
||||||
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
GOVERNANCE_SCOPE_BASE,
|
||||||
|
"""
|
||||||
识别真理节点下的隐性认知冲突。
|
识别真理节点下的隐性认知冲突。
|
||||||
|
|
||||||
真理节点 ID:{truth_node_id}
|
输入:
|
||||||
真理节点内容:
|
- 真理节点 ID:{truth_node_id}
|
||||||
|
- 真理节点内容:
|
||||||
{truth_content}
|
{truth_content}
|
||||||
|
- 用户观点/反馈:
|
||||||
相关用户的观点/产出:
|
|
||||||
{user_viewpoints}
|
{user_viewpoints}
|
||||||
|
|
||||||
请识别冲突类型(如 definition/定义分歧、priority/优先级冲突、scope/范围争议),输出 JSON:
|
输出 JSON:
|
||||||
{{
|
{{
|
||||||
"conflicts": [
|
"conflicts": [
|
||||||
{{
|
{{
|
||||||
|
|
@ -218,38 +264,54 @@ PROMPT_COGNITIVE_CONFLICTS = """
|
||||||
]
|
]
|
||||||
}}
|
}}
|
||||||
|
|
||||||
只输出 JSON。
|
规则:
|
||||||
"""
|
1. 只识别有明确证据支持的冲突。
|
||||||
|
2. 没有冲突时返回空数组,不要编造。
|
||||||
|
3. description 需要业务化表达,避免空泛术语。
|
||||||
|
""",
|
||||||
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
PROMPT_EVALUATE_MODEL_REQUIREMENT = """
|
PROMPT_EVALUATE_MODEL_REQUIREMENT = _compose_prompt(
|
||||||
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
GOVERNANCE_SCOPE_BASE,
|
||||||
|
"""
|
||||||
评估需求与 AI 模型的匹配度。
|
评估需求与 AI 模型的匹配度。
|
||||||
|
|
||||||
宪章内容:{charter_content}
|
输入:
|
||||||
模型版本 ID:{model_version_id}
|
- 需求内容:{charter_content}
|
||||||
模型信息(如有):{model_info}
|
- 模型版本 ID:{model_version_id}
|
||||||
|
- 模型说明:{model_info}
|
||||||
|
|
||||||
请评估兼容性、成本、风险。输出 JSON:
|
输出 JSON:
|
||||||
{{
|
{{
|
||||||
"compatibility_score": 0-100,
|
"compatibility_score": 0,
|
||||||
"cost_estimate_usd": 预估日成本美元,
|
"cost_estimate_usd": 0.0,
|
||||||
"cost_estimate_tokens_per_day": 预估日 token 量,
|
"cost_estimate_tokens_per_day": 0,
|
||||||
"risk_hints": ["风险1", "风险2"],
|
"risk_hints": ["风险1", "风险2"],
|
||||||
"recommendations": ["建议1", "建议2"]
|
"recommendations": ["建议1", "建议2"]
|
||||||
}}
|
}}
|
||||||
|
|
||||||
只输出 JSON。
|
规则:
|
||||||
"""
|
1. compatibility_score 使用 0-100。
|
||||||
|
2. 风险和建议必须能直接指导模型选型或范围调整。
|
||||||
|
3. 若信息不足,明确指出无法判断的关键缺口。
|
||||||
|
""",
|
||||||
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
PROMPT_ANALYZE_CAPABILITY_GAP = """
|
PROMPT_ANALYZE_CAPABILITY_GAP = _compose_prompt(
|
||||||
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
GOVERNANCE_SCOPE_BASE,
|
||||||
|
"""
|
||||||
你是一位 AI 模型能力专家。分析以下需求与模型之间的能力差距。
|
你是一位 AI 模型能力专家。分析以下需求与模型之间的能力差距。
|
||||||
|
|
||||||
需求宪章:
|
输入:
|
||||||
{charter_content}
|
- 需求描述:{requirement_description}
|
||||||
|
- 模型能力说明:{model_capabilities}
|
||||||
|
- 约束条件:{constraints}
|
||||||
|
|
||||||
模型信息:
|
输出 JSON:
|
||||||
{model_info}
|
|
||||||
|
|
||||||
请识别并分类能力差距,输出 JSON:
|
|
||||||
{{
|
{{
|
||||||
"gaps": [
|
"gaps": [
|
||||||
{{
|
{{
|
||||||
|
|
@ -261,39 +323,50 @@ PROMPT_ANALYZE_CAPABILITY_GAP = """
|
||||||
}}
|
}}
|
||||||
],
|
],
|
||||||
"overall_risk_level": "critical|high|medium|low",
|
"overall_risk_level": "critical|high|medium|low",
|
||||||
"confidence": 0.0-1.0
|
"confidence": 0.0
|
||||||
}}
|
}}
|
||||||
|
|
||||||
只输出 JSON。
|
规则:
|
||||||
"""
|
1. gaps 只列出真正影响落地的差距。
|
||||||
|
2. overall_risk_level 要与 gaps 的严重程度保持一致。
|
||||||
|
3. confidence 使用 0-1 小数。
|
||||||
|
""",
|
||||||
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
PROMPT_ANALYZE_COST_ROI = """
|
PROMPT_ANALYZE_COST_ROI = _compose_prompt(
|
||||||
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
GOVERNANCE_SCOPE_BASE,
|
||||||
|
"""
|
||||||
分析 AI 项目的成本效益。
|
分析 AI 项目的成本效益。
|
||||||
|
|
||||||
总成本:{total_cost}
|
输入:
|
||||||
总 Token:{total_tokens}
|
- 总成本:{total_cost}
|
||||||
任务数:{task_count}
|
- 总 token 数:{total_tokens}
|
||||||
项目内容:{truth_node_content}
|
- 任务数:{task_count}
|
||||||
|
- 项目内容:
|
||||||
请计算:
|
{truth_node_content}
|
||||||
1. ROI 评分 (0-10)
|
|
||||||
2. 成本效率评估
|
|
||||||
3. 优化建议
|
|
||||||
|
|
||||||
输出 JSON:
|
输出 JSON:
|
||||||
{{
|
{{
|
||||||
"roi_score": 0.0-10.0,
|
"roi_score": 0.0,
|
||||||
"cost_efficiency": "excellent|good|fair|poor",
|
"cost_efficiency": "excellent|good|fair|poor",
|
||||||
"cost_per_task": 数值,
|
"cost_per_task": 0.0,
|
||||||
"optimization_suggestions": ["建议1", "建议2"],
|
"optimization_suggestions": ["建议1", "建议2"],
|
||||||
"benchmark_comparison": "与行业标准比较"
|
"benchmark_comparison": "与行业标准比较"
|
||||||
}}
|
}}
|
||||||
|
|
||||||
只输出 JSON。
|
规则:
|
||||||
"""
|
1. roi_score 使用 0.0-10.0。
|
||||||
|
2. cost_per_task 必须可由输入推导,不能胡写。
|
||||||
|
3. optimization_suggestions 要能实际指导降本或提效。
|
||||||
|
""",
|
||||||
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"PROMPT_RAG_ANSWER",
|
"PROMPT_RAG_ANSWER",
|
||||||
|
"PROMPT_DECISION_QA",
|
||||||
"PROMPT_RAG_DB_PROJECT_QA",
|
"PROMPT_RAG_DB_PROJECT_QA",
|
||||||
"PROMPT_GENERATE_CHARTER",
|
"PROMPT_GENERATE_CHARTER",
|
||||||
"PROMPT_DECOMPOSE_TASKS",
|
"PROMPT_DECOMPOSE_TASKS",
|
||||||
|
|
|
||||||
|
|
@ -1,67 +1,118 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
PROMPT_ANALYZE_COST_OPTIMIZATION = """
|
from .part_01 import JSON_OUTPUT_GUARDRAIL, PMFLOW_PMO_STYLE_BASE, TEXT_OUTPUT_GUARDRAIL, _compose_prompt
|
||||||
|
|
||||||
|
ANALYSIS_SCOPE_BASE = """
|
||||||
|
职责边界:
|
||||||
|
1. 你是分析层,只负责基于输入做测算、比较与建议。
|
||||||
|
2. 不编造缺失数据,不替代人工验证。
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
PLANNING_SCOPE_BASE = """
|
||||||
|
职责边界:
|
||||||
|
1. 你是规划层,只负责给出可执行优化或重分配建议。
|
||||||
|
2. 不重复分类判断,不解释系统内部实现。
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
GOVERNANCE_SCOPE_BASE = """
|
||||||
|
职责边界:
|
||||||
|
1. 你是治理层,只负责变更评估、同步决策与风控建议。
|
||||||
|
2. 不替代业务决策,不伪造证据,不输出泛化结论。
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
PROMPT_ANALYZE_COST_OPTIMIZATION = _compose_prompt(
|
||||||
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
GOVERNANCE_SCOPE_BASE,
|
||||||
|
"""
|
||||||
分析成本优化机会。
|
分析成本优化机会。
|
||||||
|
|
||||||
成本历史:{cost_history}
|
输入:
|
||||||
项目内容:{truth_node_content}
|
- 成本历史:{cost_history}
|
||||||
|
- 项目内容:
|
||||||
|
{truth_node_content}
|
||||||
|
|
||||||
请识别优化机会,输出 JSON:
|
请识别优化机会,输出 JSON:
|
||||||
{{
|
{{
|
||||||
"can_downgrade_model": true|false,
|
"can_downgrade_model": true,
|
||||||
"downgrade_savings": 预计节省金额,
|
"downgrade_savings": 0.0,
|
||||||
"suggested_model": "建议的替代模型",
|
"suggested_model": "建议的替代模型",
|
||||||
"high_cache_potential": true|false,
|
"high_cache_potential": true,
|
||||||
"cache_savings": 预计节省金额,
|
"cache_savings": 0.0,
|
||||||
"batchable_operations": true|false,
|
"batchable_operations": true,
|
||||||
"batch_savings": 预计节省金额,
|
"batch_savings": 0.0,
|
||||||
"monthly_cost": 数值,
|
"monthly_cost": 0.0,
|
||||||
"total_potential_savings": 数值,
|
"total_potential_savings": 0.0,
|
||||||
"roi_after_optimization": 数值
|
"roi_after_optimization": 0.0
|
||||||
}}
|
}}
|
||||||
|
|
||||||
只输出 JSON。
|
规则:
|
||||||
"""
|
1. 只基于输入中的成本历史与项目内容做判断,不要编造。
|
||||||
|
2. 结果要能直接指导降本动作。
|
||||||
|
3. 不要输出解释文本,只输出 JSON。
|
||||||
|
""",
|
||||||
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
PROMPT_ASSESS_MODEL_UPDATE_IMPACT = """
|
PROMPT_ASSESS_MODEL_UPDATE_IMPACT = _compose_prompt(
|
||||||
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
GOVERNANCE_SCOPE_BASE,
|
||||||
|
"""
|
||||||
评估模型升级影响。
|
评估模型升级影响。
|
||||||
|
|
||||||
旧模型信息:{old_model_info}
|
输入:
|
||||||
新模型信息:{new_model_info}
|
- 旧模型信息:{old_model_info}
|
||||||
受影响宪章:{affected_truth_nodes}
|
- 新模型信息:{new_model_info}
|
||||||
|
- 受影响宪章:{affected_truth_nodes}
|
||||||
|
|
||||||
输出 JSON:
|
输出 JSON:
|
||||||
{{
|
{{
|
||||||
"cost_change_percentage": 数值,
|
"cost_change_percentage": 0.0,
|
||||||
"performance_change": {{
|
"performance_change": {{
|
||||||
"context_window_change_percent": 数值,
|
"context_window_change_percent": 0.0,
|
||||||
"quality_change_percent": 数值
|
"quality_change_percent": 0.0
|
||||||
}},
|
}},
|
||||||
"compatibility_risks": [{{"type": "风险类型", "severity": "low|medium|high"}}],
|
"compatibility_risks": [{{"type": "风险类型", "severity": "low|medium|high"}}],
|
||||||
"recommendation": "immediate_upgrade|evaluate|defer"
|
"recommendation": "immediate_upgrade|evaluate|defer"
|
||||||
}}
|
}}
|
||||||
|
|
||||||
只输出 JSON。
|
规则:
|
||||||
"""
|
1. 结论要明确是否适合立即升级。
|
||||||
|
2. 风险说明必须落到兼容性、稳定性或成本变化。
|
||||||
|
3. 不要输出解释文本,只输出 JSON。
|
||||||
|
""",
|
||||||
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
PROMPT_EXPERIMENT_SYNC_DECISION = """
|
PROMPT_EXPERIMENT_SYNC_DECISION = _compose_prompt(
|
||||||
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
GOVERNANCE_SCOPE_BASE,
|
||||||
|
"""
|
||||||
评估实验结果是否可自动同步到宪章。
|
评估实验结果是否可自动同步到宪章。
|
||||||
|
|
||||||
实验信息:{experiment_info}
|
输入:
|
||||||
宪章内容:{truth_content}
|
- 实验信息:{experiment_info}
|
||||||
阈值:{threshold}
|
- 宪章内容:{truth_content}
|
||||||
|
- 阈值:{threshold}
|
||||||
|
|
||||||
输出 JSON:
|
输出 JSON:
|
||||||
{{
|
{{
|
||||||
"confidence_score": 0.0-1.0,
|
"confidence_score": 0.0,
|
||||||
"auto_apply": true|false,
|
"auto_apply": true,
|
||||||
"reason": "原因"
|
"reason": "原因"
|
||||||
}}
|
}}
|
||||||
|
|
||||||
只输出 JSON。
|
规则:
|
||||||
"""
|
1. 只有实验结果与宪章目标高度一致时才允许 auto_apply=true。
|
||||||
|
2. reason 要明确说明同步或拒绝同步的依据。
|
||||||
|
3. 不要输出解释文本,只输出 JSON。
|
||||||
|
""",
|
||||||
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
PROMPT_SKILL_SCORE_UPDATE = """
|
PROMPT_SKILL_SCORE_UPDATE = _compose_prompt(
|
||||||
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
PLANNING_SCOPE_BASE,
|
||||||
|
"""
|
||||||
你是 PMFlow 的技能评估引擎。
|
你是 PMFlow 的技能评估引擎。
|
||||||
请根据任务完成结果,给每个技能标签输出质量分与效率分的增量(delta)。
|
请根据任务完成结果,给每个技能标签输出质量分与效率分的增量(delta)。
|
||||||
|
|
||||||
|
|
@ -90,9 +141,14 @@ PROMPT_SKILL_SCORE_UPDATE = """
|
||||||
}}
|
}}
|
||||||
]
|
]
|
||||||
}}
|
}}
|
||||||
"""
|
""",
|
||||||
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
PROMPT_WORKLOAD_OPTIMIZATION = """
|
PROMPT_WORKLOAD_OPTIMIZATION = _compose_prompt(
|
||||||
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
PLANNING_SCOPE_BASE,
|
||||||
|
"""
|
||||||
你是 PMFlow 的任务分配优化器。
|
你是 PMFlow 的任务分配优化器。
|
||||||
请基于团队成员技能与当前负载,输出任务重分配建议。
|
请基于团队成员技能与当前负载,输出任务重分配建议。
|
||||||
|
|
||||||
|
|
@ -117,26 +173,29 @@ PROMPT_WORKLOAD_OPTIMIZATION = """
|
||||||
}}
|
}}
|
||||||
]
|
]
|
||||||
}}
|
}}
|
||||||
"""
|
""",
|
||||||
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
PROMPT_ANALYZE_FOOD_CALORIES = """
|
PROMPT_ANALYZE_FOOD_CALORIES = _compose_prompt(
|
||||||
你是一位专业的营养分析师,擅长通过图片识别食物并估算热量和营养成分。
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
ANALYSIS_SCOPE_BASE,
|
||||||
|
"""
|
||||||
|
你是一位专业的营养分析师,擅长通过文本描述识别食物并估算热量和营养成分。
|
||||||
|
|
||||||
请基于提供的食物描述或特征,识别食物类型并估算营养信息。
|
输入:
|
||||||
|
|
||||||
输入信息:
|
|
||||||
{food_description}
|
{food_description}
|
||||||
|
|
||||||
请输出 JSON 格式,包含以下字段:
|
请输出 JSON 格式,包含以下字段:
|
||||||
{{
|
{{
|
||||||
"food_name": "食物名称",
|
"food_name": "食物名称",
|
||||||
"confidence": 0.0-1.0,
|
"confidence": 0.0,
|
||||||
"estimated_portion_size": "份量大小描述(如:一份、100克、半碗等)",
|
"estimated_portion_size": "份量大小描述(如:一份、100克、半碗等)",
|
||||||
"calories": 数值,
|
"calories": 0.0,
|
||||||
"protein": 数值,
|
"protein": 0.0,
|
||||||
"fat": 数值,
|
"fat": 0.0,
|
||||||
"carbs": 数值,
|
"carbs": 0.0,
|
||||||
"fiber": 数值,
|
"fiber": 0.0,
|
||||||
"category": "分类(如:主食、蔬菜、肉类、汤品、甜品、饮品等)",
|
"category": "分类(如:主食、蔬菜、肉类、汤品、甜品、饮品等)",
|
||||||
"cuisine": "菜系(如:中式、西式、日式、韩式等)",
|
"cuisine": "菜系(如:中式、西式、日式、韩式等)",
|
||||||
"description": "简短的食物描述",
|
"description": "简短的食物描述",
|
||||||
|
|
@ -145,15 +204,19 @@ PROMPT_ANALYZE_FOOD_CALORIES = """
|
||||||
}}
|
}}
|
||||||
|
|
||||||
估算规则:
|
估算规则:
|
||||||
1. 热量估算范围:考虑到份量不确定性,给出合理的中位值
|
1. 热量估算考虑份量不确定性,给出合理中位值。
|
||||||
2. 营养成分:基于常见食物数据库估算
|
2. 营养成分基于常见食物数据库估算。
|
||||||
3. confidence 根据描述清晰度和食物识别难度给出
|
3. confidence 根据描述清晰度和食物识别难度给出。
|
||||||
4. 若无法识别,confidence 应 < 0.5,并在 uncertainty_factors 中说明
|
4. 若无法识别,confidence 应 < 0.5,并在 uncertainty_factors 中说明。
|
||||||
|
5. 只输出 JSON,不要其他文字或 Markdown。
|
||||||
|
""",
|
||||||
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
只输出 JSON,不要其他文字或 Markdown。
|
PROMPT_SIGNAL_PLANNER = _compose_prompt(
|
||||||
"""
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
PLANNING_SCOPE_BASE,
|
||||||
PROMPT_SIGNAL_PLANNER = """
|
"""
|
||||||
你是 PMFlow 的 Signal Planner。请基于信号内容、项目上下文和团队成员能力,给出可执行的规划建议。
|
你是 PMFlow 的 Signal Planner。请基于信号内容、项目上下文和团队成员能力,给出可执行的规划建议。
|
||||||
|
|
||||||
输入:
|
输入:
|
||||||
|
|
@ -193,7 +256,9 @@ PROMPT_SIGNAL_PLANNER = """
|
||||||
4. execution_plan 必须是 2-5 条可执行动作,不要空泛描述。
|
4. execution_plan 必须是 2-5 条可执行动作,不要空泛描述。
|
||||||
5. confidence 范围 0-1,不能省略。
|
5. confidence 范围 0-1,不能省略。
|
||||||
6. 只输出 JSON,不要输出 Markdown 或解释文本。
|
6. 只输出 JSON,不要输出 Markdown 或解释文本。
|
||||||
"""
|
""",
|
||||||
|
JSON_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"PROMPT_ANALYZE_COST_OPTIMIZATION",
|
"PROMPT_ANALYZE_COST_OPTIMIZATION",
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,24 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
FEISHU_QA_RESPONSE_STYLE = """
|
from .part_01 import PMFLOW_PMO_STYLE_BASE, TEXT_OUTPUT_GUARDRAIL, _compose_prompt
|
||||||
|
|
||||||
|
FEISHU_QA_RESPONSE_STYLE = _compose_prompt(
|
||||||
|
PMFLOW_PMO_STYLE_BASE,
|
||||||
|
"""
|
||||||
回复风格要求:
|
回复风格要求:
|
||||||
1. 先给结论,再给关键依据,最后再给下一步。
|
1. 先给结论,再给关键依据,最后再给下一步。
|
||||||
2. 用正式、克制、面向协作的中文,像资深项目经理而不是聊天机器人。
|
2. 用正式、克制、面向协作的中文,像资深项目经理而不是聊天机器人。
|
||||||
3. 如果能用数字、项目名、负责人、时间点表达,请直接写明。
|
3. 如果能用数字、项目名、负责人、时间点表达,请直接写明。
|
||||||
4. 若信息不足,必须明确说明缺少什么,最多列 3 项。
|
4. 若信息不足,必须明确说明缺少什么,最多列 3 项。
|
||||||
5. 禁止自我介绍、寒暄、思考过程、数据库/模型/接口实现细节。
|
5. 禁止自我介绍、寒暄、思考过程、数据库/模型/接口实现细节。
|
||||||
""".strip()
|
|
||||||
|
|
||||||
PROMPT_FEISHU_DIRECT_QA = "\n\n".join(
|
|
||||||
[
|
|
||||||
"""
|
|
||||||
你是 PMFlow 的飞书项目管理顾问,负责直接问答。
|
|
||||||
""".strip(),
|
""".strip(),
|
||||||
FEISHU_QA_RESPONSE_STYLE,
|
TEXT_OUTPUT_GUARDRAIL,
|
||||||
"""
|
)
|
||||||
|
|
||||||
|
PROMPT_FEISHU_DIRECT_QA = _compose_prompt(
|
||||||
|
"你是 PMFlow 的飞书项目管理顾问,负责直接问答。",
|
||||||
|
FEISHU_QA_RESPONSE_STYLE,
|
||||||
|
"""
|
||||||
回答要求:
|
回答要求:
|
||||||
1. 仅基于给定上下文回答,优先使用结构化数据。
|
1. 仅基于给定上下文回答,优先使用结构化数据。
|
||||||
2. 如果问题涉及项目状态、进展、成员或任务,请优先给出可验证的事实和数字。
|
2. 如果问题涉及项目状态、进展、成员或任务,请优先给出可验证的事实和数字。
|
||||||
|
|
@ -32,7 +35,6 @@ User Snapshot:{user_snapshot}
|
||||||
最近会话:{conversation}
|
最近会话:{conversation}
|
||||||
检索上下文:{context_blocks}
|
检索上下文:{context_blocks}
|
||||||
""".strip(),
|
""".strip(),
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = ["FEISHU_QA_RESPONSE_STYLE", "PROMPT_FEISHU_DIRECT_QA"]
|
__all__ = ["FEISHU_QA_RESPONSE_STYLE", "PROMPT_FEISHU_DIRECT_QA"]
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
PROMPT_PROJECT_STATUS_QA = """
|
from .part_01 import TEXT_OUTPUT_GUARDRAIL, _compose_prompt
|
||||||
你是 PMFlow 的项目状态分析助手。
|
|
||||||
|
PROMPT_PROJECT_STATUS_QA = _compose_prompt(
|
||||||
|
"你是 PMFlow 的项目状态分析助手。",
|
||||||
|
"""
|
||||||
用户关注项目进度、状态、完成率、阶段和卡点。
|
用户关注项目进度、状态、完成率、阶段和卡点。
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
|
|
@ -21,6 +24,8 @@ PROMPT_PROJECT_STATUS_QA = """
|
||||||
System Snapshot:{system_snapshot}
|
System Snapshot:{system_snapshot}
|
||||||
User Snapshot:{user_snapshot}
|
User Snapshot:{user_snapshot}
|
||||||
检索上下文:{context_blocks}
|
检索上下文:{context_blocks}
|
||||||
""".strip()
|
""".strip(),
|
||||||
|
TEXT_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = ["PROMPT_PROJECT_STATUS_QA"]
|
__all__ = ["PROMPT_PROJECT_STATUS_QA"]
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,27 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
PROMPT_MEMBER_WORKLOAD_QA = """
|
from .part_01 import TEXT_OUTPUT_GUARDRAIL, _compose_prompt
|
||||||
你是 PMFlow 的团队协作助手。
|
|
||||||
|
PROMPT_MEMBER_WORKLOAD_QA = _compose_prompt(
|
||||||
|
"你是 PMFlow 的团队协作助手。",
|
||||||
|
"""
|
||||||
用户询问团队成员或负载相关信息。
|
用户询问团队成员或负载相关信息。
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
1. 优先使用 System Snapshot.team_members 和 User Snapshot 数据
|
1. 优先使用 System Snapshot.team_members 和 User Snapshot 数据。
|
||||||
2. 禁止默认返回项目列表
|
2. 禁止默认返回项目列表。
|
||||||
3. 涉及负载用百分比表示
|
3. 涉及负载用百分比表示。
|
||||||
4. 忽略与成员负载无关的项目列表和历史项目详情
|
4. 忽略与成员负载无关的项目列表和历史项目详情。
|
||||||
5. 推荐负责人时说明推荐理由
|
5. 推荐负责人时说明推荐理由。
|
||||||
6. 禁止输出思考过程
|
6. 禁止输出思考过程。
|
||||||
7. 不超过150字
|
7. 不超过 150 字。
|
||||||
|
|
||||||
问题:{question}
|
问题:{question}
|
||||||
System Snapshot:{system_snapshot}
|
System Snapshot:{system_snapshot}
|
||||||
User Snapshot:{user_snapshot}
|
User Snapshot:{user_snapshot}
|
||||||
检索上下文:{context_blocks}
|
检索上下文:{context_blocks}
|
||||||
""".strip()
|
""".strip(),
|
||||||
|
TEXT_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = ["PROMPT_MEMBER_WORKLOAD_QA"]
|
__all__ = ["PROMPT_MEMBER_WORKLOAD_QA"]
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
PROMPT_AI_PMO_QA = """
|
from .part_01 import TEXT_OUTPUT_GUARDRAIL, _compose_prompt
|
||||||
你是 PMFlow 的 AI 项目管理官。
|
|
||||||
|
PROMPT_AI_PMO_QA = _compose_prompt(
|
||||||
|
"你是 PMFlow 的 AI 项目管理官。",
|
||||||
|
"""
|
||||||
用户通过指令入口提问。
|
用户通过指令入口提问。
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
1. 你既是执行者也是回答者
|
1. 你既是执行者也是回答者,但只回答当前上下文明确支持的内容。
|
||||||
2. 问题涉及操作时先回答再提示可用指令
|
2. 问题涉及操作时先回答再提示可用指令。
|
||||||
3. 只基于给定上下文回答
|
3. 只基于给定上下文回答,不要编造系统状态。
|
||||||
4. 禁止输出思考过程
|
4. 禁止输出思考过程。
|
||||||
5. 回答后附可操作建议:
|
5. 回答后附可操作建议:
|
||||||
"你可以继续:[操作A]/[操作B]"
|
"你可以继续:[操作A]/[操作B]"
|
||||||
|
|
||||||
|
|
@ -16,6 +19,8 @@ PROMPT_AI_PMO_QA = """
|
||||||
当前项目:{project_id}
|
当前项目:{project_id}
|
||||||
World Model:{world_model}
|
World Model:{world_model}
|
||||||
检索上下文:{context_blocks}
|
检索上下文:{context_blocks}
|
||||||
""".strip()
|
""".strip(),
|
||||||
|
TEXT_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = ["PROMPT_AI_PMO_QA"]
|
__all__ = ["PROMPT_AI_PMO_QA"]
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,35 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
PROMPT_EXTERNAL_TREND_QA = """
|
from .part_01 import TEXT_OUTPUT_GUARDRAIL, _compose_prompt
|
||||||
你是 PMFlow 的市场情报助手。
|
|
||||||
|
PROMPT_EXTERNAL_TREND_QA = _compose_prompt(
|
||||||
|
"你是 PMFlow 的市场情报助手。",
|
||||||
|
"""
|
||||||
用户询问外部行业或市场信息。
|
用户询问外部行业或市场信息。
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
1. 若有外部搜索结果优先使用
|
1. 若有外部搜索结果优先使用。
|
||||||
2. 若无外部数据明确说:
|
2. 若无外部数据明确说:
|
||||||
"当前未接入实时数据,以下基于通用行业认知。"
|
"当前未接入实时数据,以下基于通用行业认知。"
|
||||||
3. 结合项目实际给出应用建议:
|
3. 结合项目实际给出应用建议:
|
||||||
"对你们[项目名]的影响是..."
|
"对你们[项目名]的影响是..."
|
||||||
4. 禁止输出思考过程
|
4. 禁止输出思考过程。
|
||||||
5. 分段输出:
|
5. 分段输出:
|
||||||
[行业动态] xxx
|
[行业动态] xxx
|
||||||
[对项目的影响] xxx
|
[对项目的影响] xxx
|
||||||
[建议] xxx
|
[建议] xxx
|
||||||
6. 控制总回复长度不超过800字,优先保留[建议]段
|
6. 控制总回复长度不超过 800 字,优先保留[建议]段。
|
||||||
|
|
||||||
问题:{question}
|
问题:{question}
|
||||||
项目上下文:{project_context}
|
项目上下文:{project_context}
|
||||||
外部搜索结果:{external_results}
|
外部搜索结果:{external_results}
|
||||||
""".strip()
|
""".strip(),
|
||||||
|
TEXT_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
PROMPT_INDUSTRY_TREND_QA = """
|
PROMPT_INDUSTRY_TREND_QA = _compose_prompt(
|
||||||
你是 PMFlow 的行业趋势助手。
|
"你是 PMFlow 的行业趋势助手。",
|
||||||
|
"""
|
||||||
用户在询问某个行业、市场或产品方向的通用趋势判断。
|
用户在询问某个行业、市场或产品方向的通用趋势判断。
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
|
|
@ -35,6 +41,8 @@ PROMPT_INDUSTRY_TREND_QA = """
|
||||||
6. 不要输出思考过程,不要使用“让我/我来/我先/我需要”开头。
|
6. 不要输出思考过程,不要使用“让我/我来/我先/我需要”开头。
|
||||||
|
|
||||||
问题:{question}
|
问题:{question}
|
||||||
""".strip()
|
""".strip(),
|
||||||
|
TEXT_OUTPUT_GUARDRAIL,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = ["PROMPT_EXTERNAL_TREND_QA", "PROMPT_INDUSTRY_TREND_QA"]
|
__all__ = ["PROMPT_EXTERNAL_TREND_QA", "PROMPT_INDUSTRY_TREND_QA"]
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ from .knowledge_base_doc import KnowledgeBaseDoc
|
||||||
from .knowledge_base_chunk import KnowledgeBaseChunk
|
from .knowledge_base_chunk import KnowledgeBaseChunk
|
||||||
from .qa_record import QARecord
|
from .qa_record import QARecord
|
||||||
from .qa_feedback import QAFeedback
|
from .qa_feedback import QAFeedback
|
||||||
|
from .feedback_center import FeedbackCenterRecord, FeedbackCenterRecordRevision
|
||||||
from .qa_session import QASession
|
from .qa_session import QASession
|
||||||
from .qa_session_state import QASessionState
|
from .qa_session_state import QASessionState
|
||||||
from .kg_entity import KGEntity
|
from .kg_entity import KGEntity
|
||||||
|
|
@ -111,6 +112,8 @@ __all__ = [
|
||||||
"KnowledgeBaseChunk",
|
"KnowledgeBaseChunk",
|
||||||
"QARecord",
|
"QARecord",
|
||||||
"QAFeedback",
|
"QAFeedback",
|
||||||
|
"FeedbackCenterRecord",
|
||||||
|
"FeedbackCenterRecordRevision",
|
||||||
"QASession",
|
"QASession",
|
||||||
"QASessionState",
|
"QASessionState",
|
||||||
"KGEntity",
|
"KGEntity",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
"""统一反馈中心模型。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, Boolean, Float, ForeignKey, Integer, Text, TIMESTAMP, UniqueConstraint, VARCHAR
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
from .types import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackCenterRecord(Base):
|
||||||
|
"""统一反馈记录:承载任务、QA、信号三类学习样本。"""
|
||||||
|
|
||||||
|
__tablename__ = "feedback_center_records"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
project_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
|
||||||
|
source_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True)
|
||||||
|
source_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||||
|
object_type: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True)
|
||||||
|
object_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||||
|
field_name: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True)
|
||||||
|
source_title: Mapped[Optional[str]] = mapped_column(VARCHAR(500), nullable=True)
|
||||||
|
object_title: Mapped[Optional[str]] = mapped_column(VARCHAR(500), nullable=True)
|
||||||
|
before_value: Mapped[Any] = mapped_column(JSONB, nullable=True)
|
||||||
|
after_value: Mapped[Any] = mapped_column(JSONB, nullable=True)
|
||||||
|
ai_snapshot: Mapped[Any] = mapped_column(JSONB, nullable=True)
|
||||||
|
ai_confidence: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||||
|
reason_candidates: Mapped[Any] = mapped_column(JSONB, nullable=True)
|
||||||
|
reason_code: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True)
|
||||||
|
reason_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
review_status: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, default="pending", index=True)
|
||||||
|
reviewed_by: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
|
||||||
|
reviewed_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True)
|
||||||
|
auto_assigned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"source_type",
|
||||||
|
"source_id",
|
||||||
|
"object_type",
|
||||||
|
"object_id",
|
||||||
|
"field_name",
|
||||||
|
name="uq_feedback_center_records_source_object_field",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
revisions = relationship(
|
||||||
|
"FeedbackCenterRecordRevision",
|
||||||
|
back_populates="record",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackCenterRecordRevision(Base):
|
||||||
|
"""统一反馈记录的修订历史。"""
|
||||||
|
|
||||||
|
__tablename__ = "feedback_center_record_revisions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
record_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("feedback_center_records.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
before_snapshot: Mapped[Any] = mapped_column(JSONB, nullable=False)
|
||||||
|
after_snapshot: Mapped[Any] = mapped_column(JSONB, nullable=False)
|
||||||
|
reviewed_by: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
|
||||||
|
reviewed_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True)
|
||||||
|
|
||||||
|
record = relationship("FeedbackCenterRecord", back_populates="revisions")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["FeedbackCenterRecord", "FeedbackCenterRecordRevision"]
|
||||||
|
|
@ -23,6 +23,21 @@ from .common import (
|
||||||
TaskItem,
|
TaskItem,
|
||||||
)
|
)
|
||||||
from .feedback import SignalFeedbackCreate, SignalFeedbackResponse
|
from .feedback import SignalFeedbackCreate, SignalFeedbackResponse
|
||||||
|
from .feedback_center import (
|
||||||
|
FeedbackReasonCandidate,
|
||||||
|
FeedbackCenterRecordRevisionRead,
|
||||||
|
FeedbackCenterRecordRead,
|
||||||
|
FeedbackCenterSummaryReasonRead,
|
||||||
|
FeedbackCenterSummaryMetricsRead,
|
||||||
|
FeedbackCenterTrendDailyRead,
|
||||||
|
FeedbackCenterTrendWindowRead,
|
||||||
|
FeedbackCenterTrendDeltaRead,
|
||||||
|
FeedbackCenterTrendRead,
|
||||||
|
FeedbackCenterSummaryRead,
|
||||||
|
FeedbackCenterListRead,
|
||||||
|
FeedbackCenterReviewRequest,
|
||||||
|
FeedbackCenterReasonCatalogRead,
|
||||||
|
)
|
||||||
from .model_capability import (
|
from .model_capability import (
|
||||||
ValidateMatchRequest,
|
ValidateMatchRequest,
|
||||||
LifecycleImpactRequest,
|
LifecycleImpactRequest,
|
||||||
|
|
@ -79,6 +94,19 @@ __all__ = [
|
||||||
"TaskItem",
|
"TaskItem",
|
||||||
"SignalFeedbackCreate",
|
"SignalFeedbackCreate",
|
||||||
"SignalFeedbackResponse",
|
"SignalFeedbackResponse",
|
||||||
|
"FeedbackReasonCandidate",
|
||||||
|
"FeedbackCenterRecordRevisionRead",
|
||||||
|
"FeedbackCenterRecordRead",
|
||||||
|
"FeedbackCenterSummaryReasonRead",
|
||||||
|
"FeedbackCenterSummaryMetricsRead",
|
||||||
|
"FeedbackCenterTrendDailyRead",
|
||||||
|
"FeedbackCenterTrendWindowRead",
|
||||||
|
"FeedbackCenterTrendDeltaRead",
|
||||||
|
"FeedbackCenterTrendRead",
|
||||||
|
"FeedbackCenterSummaryRead",
|
||||||
|
"FeedbackCenterListRead",
|
||||||
|
"FeedbackCenterReviewRequest",
|
||||||
|
"FeedbackCenterReasonCatalogRead",
|
||||||
"ValidateMatchRequest",
|
"ValidateMatchRequest",
|
||||||
"LifecycleImpactRequest",
|
"LifecycleImpactRequest",
|
||||||
"GenerateMigrationTasksRequest",
|
"GenerateMigrationTasksRequest",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,209 @@
|
||||||
|
"""统一反馈中心 Pydantic 模型。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackReasonCandidate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
code: str
|
||||||
|
label: str
|
||||||
|
confidence: float = 0.0
|
||||||
|
description: Optional[str] = None
|
||||||
|
source_type: Optional[str] = None
|
||||||
|
field_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackCenterRecordRevisionRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True, extra="ignore")
|
||||||
|
|
||||||
|
id: int
|
||||||
|
record_id: int
|
||||||
|
before_snapshot: Any
|
||||||
|
after_snapshot: Any
|
||||||
|
reviewed_by: Optional[int] = None
|
||||||
|
reviewed_at: Optional[datetime] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackCenterRecordRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True, extra="ignore")
|
||||||
|
|
||||||
|
id: int
|
||||||
|
tenant_id: int
|
||||||
|
project_id: Optional[int] = None
|
||||||
|
source_type: str
|
||||||
|
source_id: int
|
||||||
|
object_type: str
|
||||||
|
object_id: int
|
||||||
|
field_name: str
|
||||||
|
source_title: Optional[str] = None
|
||||||
|
object_title: Optional[str] = None
|
||||||
|
before_value: Any = None
|
||||||
|
after_value: Any = None
|
||||||
|
ai_snapshot: Any = None
|
||||||
|
ai_confidence: Optional[float] = None
|
||||||
|
reason_candidates: list[FeedbackReasonCandidate] = Field(default_factory=list)
|
||||||
|
reason_code: Optional[str] = None
|
||||||
|
reason_text: Optional[str] = None
|
||||||
|
review_status: str
|
||||||
|
reviewed_by: Optional[int] = None
|
||||||
|
reviewed_at: Optional[datetime] = None
|
||||||
|
auto_assigned: bool = False
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
revisions: list[FeedbackCenterRecordRevisionRead] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackCenterSummaryReasonRead(BaseModel):
|
||||||
|
code: str
|
||||||
|
label: str
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackCenterSummaryMetricsRead(BaseModel):
|
||||||
|
accepted_rate_reviewed: float
|
||||||
|
manual_override_rate: float
|
||||||
|
rework_rate: float
|
||||||
|
auto_assigned_rate: float
|
||||||
|
avg_review_latency_minutes: float
|
||||||
|
total_task_breakdown_count: int
|
||||||
|
total_task_override_count: int
|
||||||
|
total_qa_feedback_count: int
|
||||||
|
total_signal_feedback_count: int
|
||||||
|
total_revision_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackCenterTrendDailyRead(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
date: date
|
||||||
|
total_records: int
|
||||||
|
accepted_count: int
|
||||||
|
rejected_count: int
|
||||||
|
reviewed_count: int
|
||||||
|
pending_count: int
|
||||||
|
task_breakdown_count: int
|
||||||
|
task_override_count: int
|
||||||
|
qa_feedback_count: int
|
||||||
|
signal_feedback_count: int
|
||||||
|
auto_assigned_count: int
|
||||||
|
override_count: int
|
||||||
|
rework_count: int
|
||||||
|
accepted_rate_reviewed: float
|
||||||
|
manual_override_rate: float
|
||||||
|
rework_rate: float
|
||||||
|
auto_assigned_rate: float
|
||||||
|
avg_review_latency_minutes: float
|
||||||
|
revision_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackCenterTrendWindowRead(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
total_records: int
|
||||||
|
accepted_count: int
|
||||||
|
rejected_count: int
|
||||||
|
reviewed_count: int
|
||||||
|
pending_count: int
|
||||||
|
task_breakdown_count: int
|
||||||
|
task_override_count: int
|
||||||
|
qa_feedback_count: int
|
||||||
|
signal_feedback_count: int
|
||||||
|
auto_assigned_count: int
|
||||||
|
override_count: int
|
||||||
|
rework_count: int
|
||||||
|
accepted_rate_reviewed: float
|
||||||
|
manual_override_rate: float
|
||||||
|
rework_rate: float
|
||||||
|
auto_assigned_rate: float
|
||||||
|
avg_review_latency_minutes: float
|
||||||
|
revision_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackCenterTrendDeltaRead(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
total_records_delta: int
|
||||||
|
accepted_rate_reviewed_delta: float
|
||||||
|
manual_override_rate_delta: float
|
||||||
|
rework_rate_delta: float
|
||||||
|
auto_assigned_rate_delta: float
|
||||||
|
avg_review_latency_minutes_delta: float
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackCenterTrendRead(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
window_days: int
|
||||||
|
current_window_start: date
|
||||||
|
previous_window_start: date
|
||||||
|
daily: list[FeedbackCenterTrendDailyRead]
|
||||||
|
current: FeedbackCenterTrendWindowRead
|
||||||
|
previous: FeedbackCenterTrendWindowRead
|
||||||
|
deltas: FeedbackCenterTrendDeltaRead
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackCenterSummaryRead(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
tenant_id: int
|
||||||
|
project_id: Optional[int] = None
|
||||||
|
source_group: str = "all"
|
||||||
|
total: int
|
||||||
|
backlog_count: int
|
||||||
|
pending_count: int
|
||||||
|
reviewed_count: int
|
||||||
|
accepted_count: int
|
||||||
|
rejected_count: int
|
||||||
|
accepted_rate: float
|
||||||
|
avg_confidence: float
|
||||||
|
by_status: dict[str, int]
|
||||||
|
by_source_type: dict[str, int]
|
||||||
|
top_reasons: list[FeedbackCenterSummaryReasonRead]
|
||||||
|
metrics: FeedbackCenterSummaryMetricsRead
|
||||||
|
trend: FeedbackCenterTrendRead
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackCenterListRead(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
items: list[FeedbackCenterRecordRead]
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
has_more: bool
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackCenterReviewRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
review_status: Literal["reviewed", "accepted", "rejected"] = "accepted"
|
||||||
|
reason_code: Optional[str] = None
|
||||||
|
reason_text: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackCenterReasonCatalogRead(BaseModel):
|
||||||
|
items: list[FeedbackReasonCandidate]
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FeedbackReasonCandidate",
|
||||||
|
"FeedbackCenterRecordRevisionRead",
|
||||||
|
"FeedbackCenterRecordRead",
|
||||||
|
"FeedbackCenterSummaryReasonRead",
|
||||||
|
"FeedbackCenterSummaryMetricsRead",
|
||||||
|
"FeedbackCenterTrendDailyRead",
|
||||||
|
"FeedbackCenterTrendWindowRead",
|
||||||
|
"FeedbackCenterTrendDeltaRead",
|
||||||
|
"FeedbackCenterTrendRead",
|
||||||
|
"FeedbackCenterSummaryRead",
|
||||||
|
"FeedbackCenterListRead",
|
||||||
|
"FeedbackCenterReviewRequest",
|
||||||
|
"FeedbackCenterReasonCatalogRead",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
"""统一反馈中心服务。"""
|
||||||
|
|
||||||
|
from .mappers import (
|
||||||
|
map_qa_feedback_to_record_payload,
|
||||||
|
map_signal_feedback_to_record_payload,
|
||||||
|
map_task_breakdown_to_record_payload,
|
||||||
|
map_task_override_to_record_payloads,
|
||||||
|
)
|
||||||
|
from .reason_catalog import build_reason_candidates, list_reason_catalog
|
||||||
|
from .service import FeedbackCenterService
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FeedbackCenterService",
|
||||||
|
"build_reason_candidates",
|
||||||
|
"list_reason_catalog",
|
||||||
|
"map_qa_feedback_to_record_payload",
|
||||||
|
"map_signal_feedback_to_record_payload",
|
||||||
|
"map_task_breakdown_to_record_payload",
|
||||||
|
"map_task_override_to_record_payloads",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,296 @@
|
||||||
|
"""统一反馈中心数据映射器。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .reason_catalog import build_reason_candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _jsonable(value: Any) -> Any:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, (str, int, float, bool)):
|
||||||
|
return value
|
||||||
|
if isinstance(value, Decimal):
|
||||||
|
return float(value)
|
||||||
|
if isinstance(value, (datetime, date)):
|
||||||
|
return value.isoformat()
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {str(key): _jsonable(item) for key, item in value.items()}
|
||||||
|
if isinstance(value, (list, tuple, set)):
|
||||||
|
return [_jsonable(item) for item in value]
|
||||||
|
model_dump = getattr(value, "model_dump", None)
|
||||||
|
if callable(model_dump):
|
||||||
|
return _jsonable(model_dump(mode="json"))
|
||||||
|
if hasattr(value, "__dict__"):
|
||||||
|
raw = {key: item for key, item in vars(value).items() if not key.startswith("_")}
|
||||||
|
if raw:
|
||||||
|
return _jsonable(raw)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_int(value: Any) -> int | None:
|
||||||
|
try:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
return int(str(value).strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_task_breakdown_object(task: Any) -> dict[str, Any]:
|
||||||
|
payload = getattr(task, "ai_breakdown_payload", None)
|
||||||
|
if isinstance(payload, str):
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
|
||||||
|
payload = json.loads(payload)
|
||||||
|
except Exception:
|
||||||
|
payload = None
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
if isinstance(payload.get("task"), dict):
|
||||||
|
return _jsonable(payload["task"])
|
||||||
|
tasks = payload.get("tasks")
|
||||||
|
if isinstance(tasks, list) and tasks:
|
||||||
|
current_title = str(getattr(task, "title", "") or "").strip().lower()
|
||||||
|
current_id = str(getattr(task, "id", "") or "").strip()
|
||||||
|
for item in tasks:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
title = str(item.get("title") or "").strip().lower()
|
||||||
|
task_key = str(item.get("task_key") or "").strip()
|
||||||
|
if (current_title and title == current_title) or (current_id and task_key == current_id):
|
||||||
|
return _jsonable(item)
|
||||||
|
first = tasks[0]
|
||||||
|
if isinstance(first, dict):
|
||||||
|
return _jsonable(first)
|
||||||
|
return {
|
||||||
|
"task_key": str(getattr(task, "id", "") or "").strip() or str(getattr(task, "title", "") or "").strip(),
|
||||||
|
"title": getattr(task, "title", None),
|
||||||
|
"description": getattr(task, "description", None),
|
||||||
|
"type": "task",
|
||||||
|
"estimated_hours": _jsonable(getattr(task, "estimated_hours", None)),
|
||||||
|
"required_skill_names": [],
|
||||||
|
"required_skill_tag_ids": list(getattr(task, "required_skill_tag_ids", None) or []),
|
||||||
|
"depends_on_task_keys": [],
|
||||||
|
"risk_level": "medium",
|
||||||
|
"risk_notes": None,
|
||||||
|
"confidence": 0.0,
|
||||||
|
"evidence_refs": [],
|
||||||
|
"priority": getattr(task, "priority", "medium"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def map_task_breakdown_to_record_payload(
|
||||||
|
*,
|
||||||
|
task: Any,
|
||||||
|
project_title: str | None = None,
|
||||||
|
source_context: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
task_object = _extract_task_breakdown_object(task)
|
||||||
|
ai_snapshot = _jsonable(getattr(task, "ai_breakdown_payload", None))
|
||||||
|
reason_candidates = build_reason_candidates(
|
||||||
|
source_type="task_breakdown",
|
||||||
|
field_name="task_object",
|
||||||
|
before_value=None,
|
||||||
|
after_value=task_object,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"tenant_id": int(getattr(task, "tenant_id", 1) or 1),
|
||||||
|
"project_id": int(getattr(task, "project_id", 0) or 0) or None,
|
||||||
|
"source_type": "task_breakdown",
|
||||||
|
"source_id": int(getattr(task, "id", 0) or 0),
|
||||||
|
"object_type": "task",
|
||||||
|
"object_id": int(getattr(task, "id", 0) or 0),
|
||||||
|
"field_name": "task_object",
|
||||||
|
"source_title": str(project_title or getattr(task, "title", "") or "")[:500] or None,
|
||||||
|
"object_title": str(getattr(task, "title", "") or "")[:500] or None,
|
||||||
|
"before_value": None,
|
||||||
|
"after_value": task_object,
|
||||||
|
"ai_snapshot": ai_snapshot,
|
||||||
|
"ai_confidence": float(task_object.get("confidence") or 0.0) if isinstance(task_object, dict) else None,
|
||||||
|
"reason_candidates": reason_candidates,
|
||||||
|
"reason_code": reason_candidates[0]["code"] if reason_candidates else None,
|
||||||
|
"reason_text": None,
|
||||||
|
"review_status": "pending",
|
||||||
|
"reviewed_by": None,
|
||||||
|
"reviewed_at": None,
|
||||||
|
"auto_assigned": bool(getattr(task, "assignee_id", None)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def map_task_override_to_record_payloads(*, task: Any) -> list[dict[str, Any]]:
|
||||||
|
ai_object = _extract_task_breakdown_object(task)
|
||||||
|
raw_ai_payload = getattr(task, "ai_breakdown_payload", None)
|
||||||
|
if isinstance(raw_ai_payload, str):
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
|
||||||
|
raw_ai_payload = json.loads(raw_ai_payload)
|
||||||
|
except Exception:
|
||||||
|
raw_ai_payload = None
|
||||||
|
raw_ai_map = raw_ai_payload if isinstance(raw_ai_payload, dict) else {}
|
||||||
|
overrides = getattr(task, "manual_overrides", None) or {}
|
||||||
|
if isinstance(overrides, str):
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
|
||||||
|
overrides = json.loads(overrides)
|
||||||
|
except Exception:
|
||||||
|
overrides = {}
|
||||||
|
if not isinstance(overrides, dict):
|
||||||
|
overrides = dict(overrides or {})
|
||||||
|
|
||||||
|
payloads: list[dict[str, Any]] = []
|
||||||
|
for field_name, after_value in overrides.items():
|
||||||
|
if field_name == "manual_overrides":
|
||||||
|
continue
|
||||||
|
before_value = raw_ai_map.get(field_name)
|
||||||
|
if before_value is None:
|
||||||
|
before_value = ai_object.get(field_name) if isinstance(ai_object, dict) else getattr(task, field_name, None)
|
||||||
|
if _jsonable(before_value) == _jsonable(after_value):
|
||||||
|
continue
|
||||||
|
field_candidates = build_reason_candidates(
|
||||||
|
source_type="task_override",
|
||||||
|
field_name=str(field_name),
|
||||||
|
before_value=before_value,
|
||||||
|
after_value=after_value,
|
||||||
|
)
|
||||||
|
payloads.append(
|
||||||
|
{
|
||||||
|
"tenant_id": int(getattr(task, "tenant_id", 1) or 1),
|
||||||
|
"project_id": int(getattr(task, "project_id", 0) or 0) or None,
|
||||||
|
"source_type": "task_override",
|
||||||
|
"source_id": int(getattr(task, "id", 0) or 0),
|
||||||
|
"object_type": "task",
|
||||||
|
"object_id": int(getattr(task, "id", 0) or 0),
|
||||||
|
"field_name": str(field_name),
|
||||||
|
"source_title": str(getattr(task, "title", "") or "")[:500] or None,
|
||||||
|
"object_title": str(getattr(task, "title", "") or "")[:500] or None,
|
||||||
|
"before_value": _jsonable(before_value),
|
||||||
|
"after_value": _jsonable(after_value),
|
||||||
|
"ai_snapshot": {
|
||||||
|
"ai_breakdown_payload": _jsonable(getattr(task, "ai_breakdown_payload", None)),
|
||||||
|
"manual_overrides": _jsonable(overrides),
|
||||||
|
},
|
||||||
|
"ai_confidence": float(ai_object.get("confidence") or 0.0) if isinstance(ai_object, dict) else None,
|
||||||
|
"reason_candidates": field_candidates,
|
||||||
|
"reason_code": field_candidates[0]["code"] if field_candidates else None,
|
||||||
|
"reason_text": None,
|
||||||
|
"review_status": "accepted",
|
||||||
|
"reviewed_by": None,
|
||||||
|
"reviewed_at": None,
|
||||||
|
"auto_assigned": bool(getattr(task, "assignee_id", None)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return payloads
|
||||||
|
|
||||||
|
|
||||||
|
def map_qa_feedback_to_record_payload(*, qa_feedback: Any, qa_record: Any) -> dict[str, Any]:
|
||||||
|
rating = str(getattr(qa_feedback, "rating", "") or "").strip().lower()
|
||||||
|
helpfulness = getattr(qa_feedback, "helpfulness", None)
|
||||||
|
error_types = list(getattr(qa_feedback, "error_types", None) or [])
|
||||||
|
correct_answer = getattr(qa_feedback, "correct_answer", None)
|
||||||
|
before_value = {
|
||||||
|
"question": getattr(qa_record, "question", None),
|
||||||
|
"answer": getattr(qa_record, "answer", None),
|
||||||
|
"sources": _jsonable(getattr(qa_record, "sources", None) or []),
|
||||||
|
}
|
||||||
|
after_value = {
|
||||||
|
"rating": rating,
|
||||||
|
"helpfulness": helpfulness,
|
||||||
|
"error_types": [str(item).strip() for item in error_types if str(item).strip()],
|
||||||
|
"correct_answer": correct_answer,
|
||||||
|
}
|
||||||
|
reason_candidates = build_reason_candidates(
|
||||||
|
source_type="qa_feedback",
|
||||||
|
field_name="qa_feedback",
|
||||||
|
before_value=before_value,
|
||||||
|
after_value=after_value,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"tenant_id": int(getattr(qa_feedback, "tenant_id", getattr(qa_record, "tenant_id", 1)) or 1),
|
||||||
|
"project_id": int(getattr(qa_record, "project_id", 0) or 0) or None,
|
||||||
|
"source_type": "qa_feedback",
|
||||||
|
"source_id": int(getattr(qa_feedback, "id", 0) or 0),
|
||||||
|
"object_type": "qa_record",
|
||||||
|
"object_id": int(getattr(qa_feedback, "qa_id", 0) or 0),
|
||||||
|
"field_name": "qa_feedback",
|
||||||
|
"source_title": str(getattr(qa_record, "question", "") or "")[:500] or None,
|
||||||
|
"object_title": str(getattr(qa_record, "question", "") or "")[:500] or None,
|
||||||
|
"before_value": before_value,
|
||||||
|
"after_value": after_value,
|
||||||
|
"ai_snapshot": {
|
||||||
|
"question": getattr(qa_record, "question", None),
|
||||||
|
"answer": getattr(qa_record, "answer", None),
|
||||||
|
"sources": _jsonable(getattr(qa_record, "sources", None) or []),
|
||||||
|
"domains_used": _jsonable(getattr(qa_record, "domains_used", None) or []),
|
||||||
|
},
|
||||||
|
"ai_confidence": None,
|
||||||
|
"reason_candidates": reason_candidates,
|
||||||
|
"reason_code": reason_candidates[0]["code"] if reason_candidates else None,
|
||||||
|
"reason_text": correct_answer or None,
|
||||||
|
"review_status": "accepted",
|
||||||
|
"reviewed_by": int(getattr(qa_feedback, "submitted_by", 0) or 0) or None,
|
||||||
|
"reviewed_at": None,
|
||||||
|
"auto_assigned": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def map_signal_feedback_to_record_payload(*, signal_feedback: Any, signal: Any) -> dict[str, Any]:
|
||||||
|
predicted = bool(getattr(signal_feedback, "ai_predicted_strategic", False))
|
||||||
|
actual = bool(getattr(signal_feedback, "was_strategic", False))
|
||||||
|
before_value = {
|
||||||
|
"ai_predicted_strategic": predicted,
|
||||||
|
"ai_confidence": float(getattr(signal_feedback, "ai_confidence", 0.0) or 0.0),
|
||||||
|
}
|
||||||
|
after_value = {
|
||||||
|
"was_strategic": actual,
|
||||||
|
"reason": getattr(signal_feedback, "reason", None),
|
||||||
|
}
|
||||||
|
reason_candidates = build_reason_candidates(
|
||||||
|
source_type="signal_feedback",
|
||||||
|
field_name="strategic_classification",
|
||||||
|
before_value=before_value,
|
||||||
|
after_value=after_value,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"tenant_id": int(getattr(signal_feedback, "tenant_id", getattr(signal, "tenant_id", 1)) or 1),
|
||||||
|
"project_id": int(getattr(signal, "matched_project_id", 0) or 0) or None,
|
||||||
|
"source_type": "signal_feedback",
|
||||||
|
"source_id": int(getattr(signal_feedback, "id", 0) or 0),
|
||||||
|
"object_type": "signal",
|
||||||
|
"object_id": int(getattr(signal_feedback, "signal_id", 0) or 0),
|
||||||
|
"field_name": "strategic_classification",
|
||||||
|
"source_title": str(getattr(signal, "title", "") or getattr(signal, "source", "") or "")[:500] or None,
|
||||||
|
"object_title": str(getattr(signal, "title", "") or getattr(signal, "source", "") or "")[:500] or None,
|
||||||
|
"before_value": before_value,
|
||||||
|
"after_value": after_value,
|
||||||
|
"ai_snapshot": {
|
||||||
|
"signal_id": getattr(signal_feedback, "signal_id", None),
|
||||||
|
"title": getattr(signal, "title", None),
|
||||||
|
"content": getattr(signal, "content", None),
|
||||||
|
"impact_score": getattr(signal, "impact_score", None),
|
||||||
|
"priority": getattr(signal, "priority", None),
|
||||||
|
"strategic_analysis": _jsonable(getattr(signal, "strategic_analysis", None)),
|
||||||
|
},
|
||||||
|
"ai_confidence": float(getattr(signal_feedback, "ai_confidence", 0.0) or 0.0),
|
||||||
|
"reason_candidates": reason_candidates,
|
||||||
|
"reason_code": reason_candidates[0]["code"] if reason_candidates else None,
|
||||||
|
"reason_text": getattr(signal_feedback, "reason", None),
|
||||||
|
"review_status": "accepted",
|
||||||
|
"reviewed_by": _safe_int(getattr(signal_feedback, "user_id", None)),
|
||||||
|
"reviewed_at": None,
|
||||||
|
"auto_assigned": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"map_qa_feedback_to_record_payload",
|
||||||
|
"map_signal_feedback_to_record_payload",
|
||||||
|
"map_task_breakdown_to_record_payload",
|
||||||
|
"map_task_override_to_record_payloads",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,364 @@
|
||||||
|
"""统一反馈中心的原因池与候选生成。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import OrderedDict
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
_REASON_DEFINITIONS: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"code": "owner_mismatch",
|
||||||
|
"label": "负责人不匹配",
|
||||||
|
"description": "AI 推荐、人工指派或协作责任边界需要复核。",
|
||||||
|
"source_types": {"task_breakdown", "task_override"},
|
||||||
|
"field_names": {"assignee_id"},
|
||||||
|
"confidence": 0.96,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "capability_mismatch",
|
||||||
|
"label": "能力匹配偏差",
|
||||||
|
"description": "人员能力、技能覆盖或职责分配与任务要求不一致。",
|
||||||
|
"source_types": {"task_breakdown", "task_override"},
|
||||||
|
"field_names": {"assignee_id", "required_skill_tag_ids", "required_skill_names"},
|
||||||
|
"confidence": 0.91,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "skill_gap",
|
||||||
|
"label": "技能要求不准",
|
||||||
|
"description": "任务技能标签、能力要求或技能覆盖范围需要修正。",
|
||||||
|
"source_types": {"task_breakdown", "task_override"},
|
||||||
|
"field_names": {"required_skill_tag_ids", "required_skill_names"},
|
||||||
|
"confidence": 0.94,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "priority_misalignment",
|
||||||
|
"label": "优先级错配",
|
||||||
|
"description": "优先级建议与业务紧急度或协作节奏不匹配。",
|
||||||
|
"source_types": {"task_breakdown", "task_override"},
|
||||||
|
"field_names": {"priority"},
|
||||||
|
"confidence": 0.89,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "dependency_wrong",
|
||||||
|
"label": "依赖关系错误",
|
||||||
|
"description": "任务之间的依赖、先后顺序或阻塞链路判断有误。",
|
||||||
|
"source_types": {"task_breakdown", "task_override"},
|
||||||
|
"field_names": {"depends_on_task_keys"},
|
||||||
|
"confidence": 0.92,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "risk_underestimated",
|
||||||
|
"label": "风险低估",
|
||||||
|
"description": "任务风险等级、风险说明或执行风险识别偏低。",
|
||||||
|
"source_types": {"task_breakdown", "task_override", "signal_feedback"},
|
||||||
|
"field_names": {"risk_level", "risk_notes", "strategic_classification"},
|
||||||
|
"confidence": 0.9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "effort_estimation_bias",
|
||||||
|
"label": "工时预估偏差",
|
||||||
|
"description": "任务预估工时与人工判断差距较大。",
|
||||||
|
"source_types": {"task_breakdown", "task_override"},
|
||||||
|
"field_names": {"estimated_hours"},
|
||||||
|
"confidence": 0.95,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "scope_overbroad",
|
||||||
|
"label": "范围过大",
|
||||||
|
"description": "任务标题、描述或业务范围切分不够清晰。",
|
||||||
|
"source_types": {"task_breakdown", "task_override"},
|
||||||
|
"field_names": {"title", "description"},
|
||||||
|
"confidence": 0.82,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "incomplete_output",
|
||||||
|
"label": "输出信息不完整",
|
||||||
|
"description": "AI 输出缺少关键字段、上下文或业务约束。",
|
||||||
|
"source_types": {"task_breakdown", "task_override", "qa_feedback"},
|
||||||
|
"field_names": {"task_object", "qa_feedback"},
|
||||||
|
"confidence": 0.78,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "answer_incorrect",
|
||||||
|
"label": "答案不正确",
|
||||||
|
"description": "QA 回复存在事实错误、遗漏或推断偏差。",
|
||||||
|
"source_types": {"qa_feedback"},
|
||||||
|
"field_names": {"qa_feedback"},
|
||||||
|
"confidence": 0.93,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "source_issue",
|
||||||
|
"label": "来源问题",
|
||||||
|
"description": "QA 的来源引用、检索材料或证据组织存在问题。",
|
||||||
|
"source_types": {"qa_feedback"},
|
||||||
|
"field_names": {"qa_feedback"},
|
||||||
|
"confidence": 0.86,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "strategic_judgement_mismatch",
|
||||||
|
"label": "战略判断偏差",
|
||||||
|
"description": "AI 对信号是否战略相关的判断与人工判断不一致。",
|
||||||
|
"source_types": {"signal_feedback"},
|
||||||
|
"field_names": {"strategic_classification"},
|
||||||
|
"confidence": 0.97,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "signal_noise",
|
||||||
|
"label": "噪声信号",
|
||||||
|
"description": "信号本身噪声过高或不值得进入后续流程。",
|
||||||
|
"source_types": {"signal_feedback"},
|
||||||
|
"field_names": {"strategic_classification"},
|
||||||
|
"confidence": 0.8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "manual_override",
|
||||||
|
"label": "人工覆盖",
|
||||||
|
"description": "字段由人工介入修改后形成反馈样本。",
|
||||||
|
"source_types": {"task_breakdown", "task_override", "qa_feedback", "signal_feedback"},
|
||||||
|
"field_names": {"*", "task_object", "qa_feedback", "strategic_classification"},
|
||||||
|
"confidence": 0.75,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "manual_review_needed",
|
||||||
|
"label": "需要人工复核",
|
||||||
|
"description": "当前样本仍需要人工确认,不足以自动通过。",
|
||||||
|
"source_types": {"task_breakdown", "task_override", "qa_feedback", "signal_feedback"},
|
||||||
|
"field_names": {"*"},
|
||||||
|
"confidence": 0.7,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_payload(definition: dict[str, Any], *, source_type: str, field_name: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"code": definition["code"],
|
||||||
|
"label": definition["label"],
|
||||||
|
"description": definition["description"],
|
||||||
|
"source_type": source_type,
|
||||||
|
"field_name": field_name,
|
||||||
|
"confidence": float(definition.get("confidence") or 0.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_reason_catalog(
|
||||||
|
*,
|
||||||
|
source_type: str | None = None,
|
||||||
|
field_name: str | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for definition in _REASON_DEFINITIONS:
|
||||||
|
if source_type and source_type not in definition["source_types"]:
|
||||||
|
continue
|
||||||
|
if field_name and "*" not in definition["field_names"] and field_name not in definition["field_names"]:
|
||||||
|
continue
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"code": definition["code"],
|
||||||
|
"label": definition["label"],
|
||||||
|
"description": definition["description"],
|
||||||
|
"source_types": sorted(definition["source_types"]),
|
||||||
|
"field_names": sorted(definition["field_names"]),
|
||||||
|
"confidence": float(definition.get("confidence") or 0.0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _append_unique(candidate_map: OrderedDict[str, dict[str, Any]], candidate: dict[str, Any]) -> None:
|
||||||
|
code = str(candidate.get("code") or "").strip()
|
||||||
|
if not code or code in candidate_map:
|
||||||
|
return
|
||||||
|
candidate_map[code] = candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_text(value: Any) -> str:
|
||||||
|
return str(value or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_truthy_change(before_value: Any, after_value: Any) -> bool:
|
||||||
|
if before_value is None and after_value is None:
|
||||||
|
return False
|
||||||
|
return before_value != after_value
|
||||||
|
|
||||||
|
|
||||||
|
def _estimated_hours_direction(before_value: Any, after_value: Any) -> str:
|
||||||
|
try:
|
||||||
|
before = float(before_value or 0)
|
||||||
|
after = float(after_value or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "unknown"
|
||||||
|
if after > before:
|
||||||
|
return "increase"
|
||||||
|
if after < before:
|
||||||
|
return "decrease"
|
||||||
|
return "flat"
|
||||||
|
|
||||||
|
|
||||||
|
def build_reason_candidates(
|
||||||
|
*,
|
||||||
|
source_type: str,
|
||||||
|
field_name: str,
|
||||||
|
before_value: Any = None,
|
||||||
|
after_value: Any = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""基于字段差异构建稳定、可解释的候选原因。"""
|
||||||
|
|
||||||
|
source_key = _normalize_text(source_type)
|
||||||
|
field_key = _normalize_text(field_name)
|
||||||
|
candidates: OrderedDict[str, dict[str, Any]] = OrderedDict()
|
||||||
|
|
||||||
|
def add(code: str, label: str, confidence: float, description: str) -> None:
|
||||||
|
_append_unique(
|
||||||
|
candidates,
|
||||||
|
{
|
||||||
|
"code": code,
|
||||||
|
"label": label,
|
||||||
|
"description": description,
|
||||||
|
"source_type": source_key,
|
||||||
|
"field_name": field_key,
|
||||||
|
"confidence": float(confidence),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if source_key == "task_override" or source_key == "task_breakdown":
|
||||||
|
if field_key in {"assignee_id", "owner_id"}:
|
||||||
|
add(
|
||||||
|
"owner_mismatch",
|
||||||
|
"负责人不匹配",
|
||||||
|
0.96 if _is_truthy_change(before_value, after_value) else 0.88,
|
||||||
|
"负责人或责任归属与任务实际执行人不一致。",
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
"capability_mismatch",
|
||||||
|
"能力匹配偏差",
|
||||||
|
0.91,
|
||||||
|
"人员能力、技能覆盖或职责分配与任务要求不一致。",
|
||||||
|
)
|
||||||
|
if field_key in {"required_skill_tag_ids", "required_skill_names"}:
|
||||||
|
add(
|
||||||
|
"skill_gap",
|
||||||
|
"技能要求不准",
|
||||||
|
0.94 if _is_truthy_change(before_value, after_value) else 0.82,
|
||||||
|
"任务所需技能标签需要复核。",
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
"capability_mismatch",
|
||||||
|
"能力匹配偏差",
|
||||||
|
0.9,
|
||||||
|
"人员能力、技能覆盖或职责分配与任务要求不一致。",
|
||||||
|
)
|
||||||
|
if field_key in {"depends_on_task_keys", "dependency", "dependencies"}:
|
||||||
|
add(
|
||||||
|
"dependency_wrong",
|
||||||
|
"依赖关系错误",
|
||||||
|
0.92,
|
||||||
|
"任务依赖、前置关系或阻塞关系需要修正。",
|
||||||
|
)
|
||||||
|
if field_key == "priority":
|
||||||
|
add(
|
||||||
|
"priority_misalignment",
|
||||||
|
"优先级错配",
|
||||||
|
0.9,
|
||||||
|
"优先级建议与业务紧急度或协作节奏不匹配。",
|
||||||
|
)
|
||||||
|
if field_key == "estimated_hours":
|
||||||
|
direction = _estimated_hours_direction(before_value, after_value)
|
||||||
|
confidence = 0.95 if direction in {"increase", "decrease"} else 0.8
|
||||||
|
add(
|
||||||
|
"effort_estimation_bias",
|
||||||
|
"工时预估偏差",
|
||||||
|
confidence,
|
||||||
|
"AI 工时预估与人工判断存在明显差异。",
|
||||||
|
)
|
||||||
|
if direction == "increase":
|
||||||
|
add(
|
||||||
|
"scope_overbroad",
|
||||||
|
"范围过大",
|
||||||
|
0.78,
|
||||||
|
"任务范围可能需要进一步拆分。",
|
||||||
|
)
|
||||||
|
if field_key in {"risk_level", "risk_notes"}:
|
||||||
|
add(
|
||||||
|
"risk_underestimated",
|
||||||
|
"风险低估",
|
||||||
|
0.9,
|
||||||
|
"任务风险说明可能低估了实际执行复杂度。",
|
||||||
|
)
|
||||||
|
if field_key in {"title", "description"}:
|
||||||
|
add(
|
||||||
|
"scope_overbroad",
|
||||||
|
"范围过大",
|
||||||
|
0.84,
|
||||||
|
"任务标题或描述可能覆盖了过多内容。",
|
||||||
|
)
|
||||||
|
if field_key == "task_object":
|
||||||
|
add(
|
||||||
|
"incomplete_output",
|
||||||
|
"输出信息不完整",
|
||||||
|
0.76,
|
||||||
|
"模型输出仍可能缺失关键细节,需要人工补全。",
|
||||||
|
)
|
||||||
|
add("manual_override", "人工覆盖", 0.78, "AI 拆解结果经过人工修改后形成学习样本。")
|
||||||
|
elif source_key == "qa_feedback":
|
||||||
|
rating = _normalize_text((after_value or {}).get("rating") if isinstance(after_value, dict) else None)
|
||||||
|
error_types = []
|
||||||
|
if isinstance(after_value, dict):
|
||||||
|
error_types = [str(item).strip().lower() for item in after_value.get("error_types") or [] if str(item).strip()]
|
||||||
|
if rating == "negative":
|
||||||
|
if any(token in {"hallucination", "幻觉", "错误", "wrong"} for token in error_types):
|
||||||
|
add(
|
||||||
|
"answer_incorrect",
|
||||||
|
"答案不正确",
|
||||||
|
0.95,
|
||||||
|
"反馈显示回答内容存在事实错误或幻觉。",
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
"source_issue",
|
||||||
|
"来源问题",
|
||||||
|
0.84,
|
||||||
|
"回答所依赖的来源或证据组织需要复核。",
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
"incomplete_output",
|
||||||
|
"输出信息不完整",
|
||||||
|
0.78,
|
||||||
|
"回答缺少关键信息或上下文不够完整。",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
add("manual_review_needed", "需要人工复核", 0.72, "QA 反馈建议保留人工复核以确认样本质量。")
|
||||||
|
elif source_key == "signal_feedback":
|
||||||
|
predicted = None
|
||||||
|
actual = None
|
||||||
|
if isinstance(before_value, dict):
|
||||||
|
predicted = before_value.get("ai_predicted_strategic")
|
||||||
|
if isinstance(after_value, dict):
|
||||||
|
actual = after_value.get("was_strategic")
|
||||||
|
if predicted is not None and actual is not None and bool(predicted) != bool(actual):
|
||||||
|
add(
|
||||||
|
"strategic_judgement_mismatch",
|
||||||
|
"战略性判断偏差",
|
||||||
|
0.97,
|
||||||
|
"AI 对信号是否需要进入战略流程的判断与人工结果不一致。",
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
"signal_noise",
|
||||||
|
"噪声信号",
|
||||||
|
0.82,
|
||||||
|
"当前信号可能缺少足够强的业务价值信号。",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
add("manual_review_needed", "需要人工复核", 0.7, "当前样本需要进一步人工确认。")
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
add(
|
||||||
|
"manual_review_needed",
|
||||||
|
"需要人工复核",
|
||||||
|
0.7,
|
||||||
|
"当前样本需要进一步人工确认。",
|
||||||
|
)
|
||||||
|
|
||||||
|
return list(candidates.values())
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["build_reason_candidates", "list_reason_catalog"]
|
||||||
|
|
@ -0,0 +1,883 @@
|
||||||
|
"""统一反馈中心业务服务。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, time, timedelta
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import and_, func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from ...models import FeedbackCenterRecord, FeedbackCenterRecordRevision
|
||||||
|
from ...utils.timezone import now_cn_naive
|
||||||
|
from .mappers import (
|
||||||
|
map_qa_feedback_to_record_payload,
|
||||||
|
map_signal_feedback_to_record_payload,
|
||||||
|
map_task_breakdown_to_record_payload,
|
||||||
|
map_task_override_to_record_payloads,
|
||||||
|
)
|
||||||
|
from .reason_catalog import build_reason_candidates, list_reason_catalog
|
||||||
|
|
||||||
|
SOURCE_GROUP_VALUES = {"all", "task", "qa", "signal"}
|
||||||
|
SOURCE_GROUP_TASK_TYPES = ("task_breakdown", "task_override")
|
||||||
|
WINDOW_DAYS_VALUES = {7, 30}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_jsonable(value: Any) -> Any:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {str(key): _normalize_jsonable(item) for key, item in value.items()}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_normalize_jsonable(item) for item in value]
|
||||||
|
if isinstance(value, tuple):
|
||||||
|
return [_normalize_jsonable(item) for item in value]
|
||||||
|
if hasattr(value, "model_dump"):
|
||||||
|
return _normalize_jsonable(value.model_dump(mode="json"))
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_source_group(value: Any) -> str:
|
||||||
|
normalized = str(value or "all").strip().lower()
|
||||||
|
if normalized not in SOURCE_GROUP_VALUES:
|
||||||
|
raise ValueError(f"unsupported source_group: {normalized}")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_window_days(value: Any) -> int:
|
||||||
|
if value is None:
|
||||||
|
return 7
|
||||||
|
normalized = int(value)
|
||||||
|
if normalized not in WINDOW_DAYS_VALUES:
|
||||||
|
raise ValueError(f"unsupported window_days: {normalized}")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _source_group_clause(source_group: str):
|
||||||
|
if source_group == "task":
|
||||||
|
return FeedbackCenterRecord.source_type.in_(SOURCE_GROUP_TASK_TYPES)
|
||||||
|
if source_group == "qa":
|
||||||
|
return FeedbackCenterRecord.source_type == "qa_feedback"
|
||||||
|
if source_group == "signal":
|
||||||
|
return FeedbackCenterRecord.source_type == "signal_feedback"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_source_group(stmt, source_group: str):
|
||||||
|
clause = _source_group_clause(source_group)
|
||||||
|
if clause is not None:
|
||||||
|
stmt = stmt.where(clause)
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
def _record_snapshot(record: FeedbackCenterRecord) -> dict[str, Any]:
|
||||||
|
# 避免异步会话下属性过期触发隐式 IO(MissingGreenlet)。
|
||||||
|
created_at = record.__dict__.get("created_at")
|
||||||
|
updated_at = record.__dict__.get("updated_at")
|
||||||
|
return {
|
||||||
|
"id": int(record.id),
|
||||||
|
"tenant_id": int(record.tenant_id),
|
||||||
|
"project_id": int(record.project_id) if record.project_id is not None else None,
|
||||||
|
"source_type": record.source_type,
|
||||||
|
"source_id": int(record.source_id),
|
||||||
|
"object_type": record.object_type,
|
||||||
|
"object_id": int(record.object_id),
|
||||||
|
"field_name": record.field_name,
|
||||||
|
"source_title": record.source_title,
|
||||||
|
"object_title": record.object_title,
|
||||||
|
"before_value": _normalize_jsonable(record.before_value),
|
||||||
|
"after_value": _normalize_jsonable(record.after_value),
|
||||||
|
"ai_snapshot": _normalize_jsonable(record.ai_snapshot),
|
||||||
|
"ai_confidence": float(record.ai_confidence or 0.0) if record.ai_confidence is not None else None,
|
||||||
|
"reason_candidates": _normalize_jsonable(record.reason_candidates or []),
|
||||||
|
"reason_code": record.reason_code,
|
||||||
|
"reason_text": record.reason_text,
|
||||||
|
"review_status": record.review_status,
|
||||||
|
"reviewed_by": int(record.reviewed_by) if record.reviewed_by is not None else None,
|
||||||
|
"reviewed_at": record.reviewed_at.isoformat() if record.reviewed_at else None,
|
||||||
|
"auto_assigned": bool(record.auto_assigned),
|
||||||
|
"created_at": created_at.isoformat() if created_at else None,
|
||||||
|
"updated_at": updated_at.isoformat() if updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _revision_snapshot(revision: FeedbackCenterRecordRevision) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": int(revision.id),
|
||||||
|
"record_id": int(revision.record_id),
|
||||||
|
"before_snapshot": _normalize_jsonable(revision.before_snapshot),
|
||||||
|
"after_snapshot": _normalize_jsonable(revision.after_snapshot),
|
||||||
|
"reviewed_by": int(revision.reviewed_by) if revision.reviewed_by is not None else None,
|
||||||
|
"reviewed_at": revision.reviewed_at.isoformat() if revision.reviewed_at else None,
|
||||||
|
"created_at": revision.created_at.isoformat() if revision.created_at else None,
|
||||||
|
"updated_at": revision.updated_at.isoformat() if revision.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _trend_accumulator(date_value: date | None = None) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"date": date_value,
|
||||||
|
"total_records": 0,
|
||||||
|
"accepted_count": 0,
|
||||||
|
"rejected_count": 0,
|
||||||
|
"reviewed_count": 0,
|
||||||
|
"pending_count": 0,
|
||||||
|
"task_breakdown_count": 0,
|
||||||
|
"task_override_count": 0,
|
||||||
|
"qa_feedback_count": 0,
|
||||||
|
"signal_feedback_count": 0,
|
||||||
|
"auto_assigned_count": 0,
|
||||||
|
"override_count": 0,
|
||||||
|
"rework_count": 0,
|
||||||
|
"revision_count": 0,
|
||||||
|
"_breakdown_task_ids": set(),
|
||||||
|
"_override_task_ids": set(),
|
||||||
|
"_auto_assigned_task_ids": set(),
|
||||||
|
"_latencies": [],
|
||||||
|
"_reworked_record_ids": set(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _finalize_trend_accumulator(accumulator: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
breakdown_count = len(accumulator.pop("_breakdown_task_ids", set()))
|
||||||
|
override_count = len(accumulator.pop("_override_task_ids", set()))
|
||||||
|
auto_assigned_count = len(accumulator.pop("_auto_assigned_task_ids", set()))
|
||||||
|
rework_count = len(accumulator.pop("_reworked_record_ids", set()))
|
||||||
|
latencies = accumulator.pop("_latencies", [])
|
||||||
|
accepted_count = int(accumulator.get("accepted_count", 0))
|
||||||
|
rejected_count = int(accumulator.get("rejected_count", 0))
|
||||||
|
reviewed_count = int(accumulator.get("reviewed_count", 0))
|
||||||
|
pending_count = int(accumulator.get("pending_count", 0))
|
||||||
|
total_records = int(accumulator.get("total_records", 0))
|
||||||
|
avg_review_latency_minutes = sum(latencies) / len(latencies) if latencies else 0.0
|
||||||
|
reviewed_decisions = accepted_count + rejected_count
|
||||||
|
|
||||||
|
accumulator["task_breakdown_count"] = breakdown_count
|
||||||
|
accumulator["task_override_count"] = override_count
|
||||||
|
accumulator["qa_feedback_count"] = int(accumulator.get("qa_feedback_count", 0))
|
||||||
|
accumulator["signal_feedback_count"] = int(accumulator.get("signal_feedback_count", 0))
|
||||||
|
accumulator["auto_assigned_count"] = auto_assigned_count
|
||||||
|
accumulator["override_count"] = override_count
|
||||||
|
accumulator["rework_count"] = rework_count
|
||||||
|
accumulator["revision_count"] = int(accumulator.get("revision_count", 0))
|
||||||
|
accumulator["accepted_rate_reviewed"] = accepted_count / reviewed_decisions if reviewed_decisions else 0.0
|
||||||
|
accumulator["manual_override_rate"] = override_count / breakdown_count if breakdown_count else 0.0
|
||||||
|
accumulator["rework_rate"] = rework_count / total_records if total_records else 0.0
|
||||||
|
accumulator["auto_assigned_rate"] = auto_assigned_count / breakdown_count if breakdown_count else 0.0
|
||||||
|
accumulator["avg_review_latency_minutes"] = avg_review_latency_minutes
|
||||||
|
accumulator["reviewed_count"] = reviewed_count
|
||||||
|
accumulator["pending_count"] = pending_count
|
||||||
|
accumulator["accepted_count"] = accepted_count
|
||||||
|
accumulator["rejected_count"] = rejected_count
|
||||||
|
accumulator["total_records"] = total_records
|
||||||
|
if accumulator.get("date") is not None:
|
||||||
|
accumulator["date"] = accumulator["date"].isoformat()
|
||||||
|
return accumulator
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackCenterService:
|
||||||
|
"""统一反馈中心服务。"""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _base_stmt(*, tenant_id: int, project_id: Optional[int] = None):
|
||||||
|
stmt = select(FeedbackCenterRecord).where(FeedbackCenterRecord.tenant_id == int(tenant_id))
|
||||||
|
if project_id is not None:
|
||||||
|
stmt = stmt.where(FeedbackCenterRecord.project_id == int(project_id))
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _identity_filters(payload: dict[str, Any]):
|
||||||
|
return (
|
||||||
|
FeedbackCenterRecord.tenant_id == int(payload["tenant_id"]),
|
||||||
|
FeedbackCenterRecord.source_type == str(payload["source_type"]),
|
||||||
|
FeedbackCenterRecord.source_id == int(payload["source_id"]),
|
||||||
|
FeedbackCenterRecord.object_type == str(payload["object_type"]),
|
||||||
|
FeedbackCenterRecord.object_id == int(payload["object_id"]),
|
||||||
|
FeedbackCenterRecord.field_name == str(payload["field_name"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _find_existing_record(self, payload: dict[str, Any]) -> FeedbackCenterRecord | None:
|
||||||
|
stmt = select(FeedbackCenterRecord).where(*self._identity_filters(payload))
|
||||||
|
return (await self.db.execute(stmt)).scalar_one_or_none()
|
||||||
|
|
||||||
|
def _merge_existing_record(self, record: FeedbackCenterRecord, payload: dict[str, Any]) -> bool:
|
||||||
|
mutable_fields = (
|
||||||
|
"project_id",
|
||||||
|
"source_title",
|
||||||
|
"object_title",
|
||||||
|
"before_value",
|
||||||
|
"after_value",
|
||||||
|
"ai_snapshot",
|
||||||
|
"ai_confidence",
|
||||||
|
"reason_candidates",
|
||||||
|
"reason_code",
|
||||||
|
"reason_text",
|
||||||
|
"review_status",
|
||||||
|
"reviewed_by",
|
||||||
|
"reviewed_at",
|
||||||
|
"auto_assigned",
|
||||||
|
)
|
||||||
|
changed = False
|
||||||
|
for field in mutable_fields:
|
||||||
|
next_value = payload.get(field)
|
||||||
|
current_value = getattr(record, field)
|
||||||
|
if _normalize_jsonable(current_value) == _normalize_jsonable(next_value):
|
||||||
|
continue
|
||||||
|
setattr(record, field, next_value)
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
record.updated_at = now_cn_naive()
|
||||||
|
return changed
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _apply_filters(
|
||||||
|
stmt,
|
||||||
|
*,
|
||||||
|
source_type: Optional[str] = None,
|
||||||
|
review_status: Optional[str] = None,
|
||||||
|
field_name: Optional[str] = None,
|
||||||
|
reason_code: Optional[str] = None,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
):
|
||||||
|
if source_type:
|
||||||
|
stmt = stmt.where(FeedbackCenterRecord.source_type == str(source_type))
|
||||||
|
if review_status:
|
||||||
|
stmt = stmt.where(FeedbackCenterRecord.review_status == str(review_status))
|
||||||
|
if field_name:
|
||||||
|
stmt = stmt.where(FeedbackCenterRecord.field_name == str(field_name))
|
||||||
|
if reason_code:
|
||||||
|
stmt = stmt.where(FeedbackCenterRecord.reason_code == str(reason_code))
|
||||||
|
if search:
|
||||||
|
like = f"%{str(search).strip()}%"
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
func.coalesce(FeedbackCenterRecord.object_title, "").ilike(like),
|
||||||
|
func.coalesce(FeedbackCenterRecord.source_title, "").ilike(like),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _trend_add_record(accumulator: dict[str, Any], record: FeedbackCenterRecord, revision_count: int) -> None:
|
||||||
|
accumulator["total_records"] += 1
|
||||||
|
status = str(record.review_status or "pending")
|
||||||
|
if status == "accepted":
|
||||||
|
accumulator["accepted_count"] += 1
|
||||||
|
elif status == "rejected":
|
||||||
|
accumulator["rejected_count"] += 1
|
||||||
|
elif status == "reviewed":
|
||||||
|
accumulator["reviewed_count"] += 1
|
||||||
|
else:
|
||||||
|
accumulator["pending_count"] += 1
|
||||||
|
|
||||||
|
source_type = str(record.source_type or "")
|
||||||
|
object_type = str(record.object_type or "")
|
||||||
|
if source_type == "task_breakdown" and object_type == "task":
|
||||||
|
accumulator["_breakdown_task_ids"].add(int(record.object_id))
|
||||||
|
if bool(record.auto_assigned):
|
||||||
|
accumulator["_auto_assigned_task_ids"].add(int(record.object_id))
|
||||||
|
if source_type == "task_override" and object_type == "task":
|
||||||
|
accumulator["_override_task_ids"].add(int(record.object_id))
|
||||||
|
if source_type == "qa_feedback":
|
||||||
|
accumulator["qa_feedback_count"] += 1
|
||||||
|
if source_type == "signal_feedback":
|
||||||
|
accumulator["signal_feedback_count"] += 1
|
||||||
|
|
||||||
|
if revision_count > 0:
|
||||||
|
accumulator["revision_count"] += int(revision_count)
|
||||||
|
if revision_count > 1:
|
||||||
|
accumulator["_reworked_record_ids"].add(int(record.id))
|
||||||
|
|
||||||
|
if record.created_at and record.reviewed_at:
|
||||||
|
delta = (record.reviewed_at - record.created_at).total_seconds() / 60
|
||||||
|
if delta >= 0:
|
||||||
|
accumulator["_latencies"].append(delta)
|
||||||
|
|
||||||
|
async def _build_trend(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
project_id: Optional[int] = None,
|
||||||
|
window_days: int = 7,
|
||||||
|
source_group: str = "all",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
window_days = _normalize_window_days(window_days)
|
||||||
|
source_group = _normalize_source_group(source_group)
|
||||||
|
today = now_cn_naive().date()
|
||||||
|
current_start = today - timedelta(days=window_days - 1)
|
||||||
|
previous_start = current_start - timedelta(days=window_days)
|
||||||
|
end_exclusive = today + timedelta(days=1)
|
||||||
|
start_dt = datetime.combine(previous_start, time.min)
|
||||||
|
end_dt = datetime.combine(end_exclusive, time.min)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(FeedbackCenterRecord)
|
||||||
|
.options(selectinload(FeedbackCenterRecord.revisions))
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
FeedbackCenterRecord.tenant_id == int(tenant_id),
|
||||||
|
FeedbackCenterRecord.created_at.is_not(None),
|
||||||
|
FeedbackCenterRecord.created_at >= start_dt,
|
||||||
|
FeedbackCenterRecord.created_at < end_dt,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if project_id is not None:
|
||||||
|
stmt = stmt.where(FeedbackCenterRecord.project_id == int(project_id))
|
||||||
|
stmt = _apply_source_group(stmt, source_group)
|
||||||
|
records = (await self.db.execute(stmt)).scalars().all()
|
||||||
|
|
||||||
|
current_daily: dict[date, dict[str, Any]] = {
|
||||||
|
current_start + timedelta(days=index): _trend_accumulator(current_start + timedelta(days=index))
|
||||||
|
for index in range(window_days)
|
||||||
|
}
|
||||||
|
current_window = _trend_accumulator()
|
||||||
|
previous_window = _trend_accumulator()
|
||||||
|
|
||||||
|
for record in records:
|
||||||
|
created_at = getattr(record, "created_at", None)
|
||||||
|
if created_at is None:
|
||||||
|
continue
|
||||||
|
created_date = created_at.date()
|
||||||
|
revision_count = len(record.revisions or [])
|
||||||
|
if current_start <= created_date <= today:
|
||||||
|
bucket = current_daily.get(created_date)
|
||||||
|
if bucket is not None:
|
||||||
|
self._trend_add_record(bucket, record, revision_count)
|
||||||
|
self._trend_add_record(current_window, record, revision_count)
|
||||||
|
continue
|
||||||
|
if previous_start <= created_date < current_start:
|
||||||
|
self._trend_add_record(previous_window, record, revision_count)
|
||||||
|
|
||||||
|
daily = [_finalize_trend_accumulator(current_daily[current_start + timedelta(days=index)]) for index in range(window_days)]
|
||||||
|
current = _finalize_trend_accumulator(current_window)
|
||||||
|
previous = _finalize_trend_accumulator(previous_window)
|
||||||
|
|
||||||
|
deltas = {
|
||||||
|
"total_records_delta": int(current["total_records"] - previous["total_records"]),
|
||||||
|
"accepted_rate_reviewed_delta": float(current["accepted_rate_reviewed"] - previous["accepted_rate_reviewed"]),
|
||||||
|
"manual_override_rate_delta": float(current["manual_override_rate"] - previous["manual_override_rate"]),
|
||||||
|
"rework_rate_delta": float(current["rework_rate"] - previous["rework_rate"]),
|
||||||
|
"auto_assigned_rate_delta": float(current["auto_assigned_rate"] - previous["auto_assigned_rate"]),
|
||||||
|
"avg_review_latency_minutes_delta": float(
|
||||||
|
current["avg_review_latency_minutes"] - previous["avg_review_latency_minutes"]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"window_days": window_days,
|
||||||
|
"current_window_start": current_start,
|
||||||
|
"previous_window_start": previous_start,
|
||||||
|
"daily": daily,
|
||||||
|
"current": current,
|
||||||
|
"previous": previous,
|
||||||
|
"deltas": deltas,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def summary(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
project_id: Optional[int] = None,
|
||||||
|
window_days: int = 7,
|
||||||
|
source_group: str = "all",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
window_days = _normalize_window_days(window_days)
|
||||||
|
source_group = _normalize_source_group(source_group)
|
||||||
|
stmt = self._base_stmt(tenant_id=tenant_id, project_id=project_id)
|
||||||
|
stmt = _apply_source_group(stmt, source_group)
|
||||||
|
total_stmt = select(func.count()).select_from(stmt.subquery())
|
||||||
|
total = int((await self.db.execute(total_stmt)).scalar_one() or 0)
|
||||||
|
|
||||||
|
status_stmt = select(FeedbackCenterRecord.review_status, func.count()).group_by(
|
||||||
|
FeedbackCenterRecord.review_status
|
||||||
|
)
|
||||||
|
status_stmt = status_stmt.where(FeedbackCenterRecord.tenant_id == int(tenant_id))
|
||||||
|
if project_id is not None:
|
||||||
|
status_stmt = status_stmt.where(FeedbackCenterRecord.project_id == int(project_id))
|
||||||
|
status_stmt = _apply_source_group(status_stmt, source_group)
|
||||||
|
status_rows = await self.db.execute(status_stmt)
|
||||||
|
by_status = {str(status or "pending"): int(count or 0) for status, count in status_rows.all()}
|
||||||
|
|
||||||
|
source_stmt = select(FeedbackCenterRecord.source_type, func.count()).group_by(FeedbackCenterRecord.source_type)
|
||||||
|
source_stmt = source_stmt.where(FeedbackCenterRecord.tenant_id == int(tenant_id))
|
||||||
|
if project_id is not None:
|
||||||
|
source_stmt = source_stmt.where(FeedbackCenterRecord.project_id == int(project_id))
|
||||||
|
source_stmt = _apply_source_group(source_stmt, source_group)
|
||||||
|
source_rows = await self.db.execute(source_stmt)
|
||||||
|
by_source_type = {str(source_type or "unknown"): int(count or 0) for source_type, count in source_rows.all()}
|
||||||
|
|
||||||
|
confidence_stmt = select(func.avg(FeedbackCenterRecord.ai_confidence))
|
||||||
|
confidence_stmt = confidence_stmt.where(FeedbackCenterRecord.tenant_id == int(tenant_id))
|
||||||
|
if project_id is not None:
|
||||||
|
confidence_stmt = confidence_stmt.where(FeedbackCenterRecord.project_id == int(project_id))
|
||||||
|
confidence_stmt = _apply_source_group(confidence_stmt, source_group)
|
||||||
|
avg_confidence = float((await self.db.execute(confidence_stmt)).scalar_one() or 0.0)
|
||||||
|
|
||||||
|
trend = await self._build_trend(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
project_id=project_id,
|
||||||
|
window_days=window_days,
|
||||||
|
source_group=source_group,
|
||||||
|
)
|
||||||
|
|
||||||
|
accepted_count = int(by_status.get("accepted", 0))
|
||||||
|
reviewed_count = int(by_status.get("reviewed", 0))
|
||||||
|
pending_count = int(by_status.get("pending", 0))
|
||||||
|
rejected_count = int(by_status.get("rejected", 0))
|
||||||
|
backlog_count = pending_count + reviewed_count
|
||||||
|
accepted_rate = (accepted_count / total) if total else 0.0
|
||||||
|
|
||||||
|
reason_stmt = select(FeedbackCenterRecord.reason_code, func.count())
|
||||||
|
reason_stmt = reason_stmt.where(FeedbackCenterRecord.tenant_id == int(tenant_id))
|
||||||
|
if project_id is not None:
|
||||||
|
reason_stmt = reason_stmt.where(FeedbackCenterRecord.project_id == int(project_id))
|
||||||
|
reason_stmt = _apply_source_group(reason_stmt, source_group)
|
||||||
|
reason_stmt = reason_stmt.where(FeedbackCenterRecord.reason_code.is_not(None))
|
||||||
|
reason_stmt = reason_stmt.group_by(FeedbackCenterRecord.reason_code).order_by(func.count().desc())
|
||||||
|
reason_rows = (await self.db.execute(reason_stmt)).all()
|
||||||
|
top_reasons: list[dict[str, Any]] = []
|
||||||
|
for reason_code, count in reason_rows[:5]:
|
||||||
|
if not reason_code:
|
||||||
|
continue
|
||||||
|
catalog = list_reason_catalog()
|
||||||
|
label = next((item["label"] for item in catalog if item["code"] == reason_code), reason_code)
|
||||||
|
top_reasons.append(
|
||||||
|
{
|
||||||
|
"code": str(reason_code),
|
||||||
|
"label": label,
|
||||||
|
"count": int(count or 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _distinct_task_count(*, source_type: str, auto_assigned: Optional[bool] = None) -> int:
|
||||||
|
stmt = select(func.count(func.distinct(FeedbackCenterRecord.object_id))).where(
|
||||||
|
and_(
|
||||||
|
FeedbackCenterRecord.tenant_id == int(tenant_id),
|
||||||
|
FeedbackCenterRecord.source_type == source_type,
|
||||||
|
FeedbackCenterRecord.object_type == "task",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if project_id is not None:
|
||||||
|
stmt = stmt.where(FeedbackCenterRecord.project_id == int(project_id))
|
||||||
|
stmt = _apply_source_group(stmt, source_group)
|
||||||
|
if auto_assigned is not None:
|
||||||
|
stmt = stmt.where(FeedbackCenterRecord.auto_assigned.is_(auto_assigned))
|
||||||
|
return int((await self.db.execute(stmt)).scalar_one() or 0)
|
||||||
|
|
||||||
|
task_breakdown_count = int(by_source_type.get("task_breakdown", 0))
|
||||||
|
task_override_count = int(by_source_type.get("task_override", 0))
|
||||||
|
qa_feedback_count = int(by_source_type.get("qa_feedback", 0))
|
||||||
|
signal_feedback_count = int(by_source_type.get("signal_feedback", 0))
|
||||||
|
|
||||||
|
breakdown_task_count = await _distinct_task_count(source_type="task_breakdown")
|
||||||
|
override_task_count = await _distinct_task_count(source_type="task_override")
|
||||||
|
auto_assigned_task_count = await _distinct_task_count(source_type="task_breakdown", auto_assigned=True)
|
||||||
|
|
||||||
|
accepted_count_reviewed = accepted_count
|
||||||
|
reviewed_decision_total = accepted_count + rejected_count
|
||||||
|
accepted_rate_reviewed = accepted_count_reviewed / reviewed_decision_total if reviewed_decision_total else 0.0
|
||||||
|
|
||||||
|
manual_override_rate = override_task_count / breakdown_task_count if breakdown_task_count else 0.0
|
||||||
|
auto_assigned_rate = auto_assigned_task_count / breakdown_task_count if breakdown_task_count else 0.0
|
||||||
|
|
||||||
|
revision_scope = (
|
||||||
|
select(FeedbackCenterRecordRevision)
|
||||||
|
.join(
|
||||||
|
FeedbackCenterRecord,
|
||||||
|
FeedbackCenterRecord.id == FeedbackCenterRecordRevision.record_id,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
FeedbackCenterRecordRevision.tenant_id == int(tenant_id),
|
||||||
|
FeedbackCenterRecord.tenant_id == int(tenant_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if project_id is not None:
|
||||||
|
revision_scope = revision_scope.where(FeedbackCenterRecord.project_id == int(project_id))
|
||||||
|
revision_scope = _apply_source_group(revision_scope, source_group)
|
||||||
|
revision_count_stmt = select(func.count()).select_from(revision_scope.subquery())
|
||||||
|
revision_count = int((await self.db.execute(revision_count_stmt)).scalar_one() or 0)
|
||||||
|
|
||||||
|
rework_stmt = (
|
||||||
|
select(FeedbackCenterRecordRevision.record_id, func.count().label("cnt"))
|
||||||
|
.join(
|
||||||
|
FeedbackCenterRecord,
|
||||||
|
FeedbackCenterRecord.id == FeedbackCenterRecordRevision.record_id,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
FeedbackCenterRecordRevision.tenant_id == int(tenant_id),
|
||||||
|
FeedbackCenterRecord.tenant_id == int(tenant_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.group_by(FeedbackCenterRecordRevision.record_id)
|
||||||
|
.having(func.count() > 1)
|
||||||
|
)
|
||||||
|
if project_id is not None:
|
||||||
|
rework_stmt = rework_stmt.where(FeedbackCenterRecord.project_id == int(project_id))
|
||||||
|
rework_stmt = _apply_source_group(rework_stmt, source_group)
|
||||||
|
rework_count_stmt = select(func.count()).select_from(rework_stmt.subquery())
|
||||||
|
rework_record_count = int((await self.db.execute(rework_count_stmt)).scalar_one() or 0)
|
||||||
|
rework_rate = rework_record_count / total if total else 0.0
|
||||||
|
|
||||||
|
latency_stmt = select(FeedbackCenterRecord.created_at, FeedbackCenterRecord.reviewed_at).where(
|
||||||
|
and_(
|
||||||
|
FeedbackCenterRecord.tenant_id == int(tenant_id),
|
||||||
|
FeedbackCenterRecord.reviewed_at.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if project_id is not None:
|
||||||
|
latency_stmt = latency_stmt.where(FeedbackCenterRecord.project_id == int(project_id))
|
||||||
|
latency_stmt = _apply_source_group(latency_stmt, source_group)
|
||||||
|
latency_rows = (await self.db.execute(latency_stmt)).all()
|
||||||
|
latencies: list[float] = []
|
||||||
|
for created_at, reviewed_at in latency_rows:
|
||||||
|
if not created_at or not reviewed_at:
|
||||||
|
continue
|
||||||
|
delta = (reviewed_at - created_at).total_seconds() / 60
|
||||||
|
if delta >= 0:
|
||||||
|
latencies.append(delta)
|
||||||
|
avg_review_latency_minutes = sum(latencies) / len(latencies) if latencies else 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tenant_id": int(tenant_id),
|
||||||
|
"project_id": int(project_id) if project_id is not None else None,
|
||||||
|
"source_group": source_group,
|
||||||
|
"total": total,
|
||||||
|
"backlog_count": backlog_count,
|
||||||
|
"pending_count": pending_count,
|
||||||
|
"reviewed_count": reviewed_count,
|
||||||
|
"accepted_count": accepted_count,
|
||||||
|
"rejected_count": rejected_count,
|
||||||
|
"accepted_rate": accepted_rate,
|
||||||
|
"avg_confidence": avg_confidence,
|
||||||
|
"by_status": by_status,
|
||||||
|
"by_source_type": by_source_type,
|
||||||
|
"top_reasons": top_reasons,
|
||||||
|
"metrics": {
|
||||||
|
"accepted_rate_reviewed": accepted_rate_reviewed,
|
||||||
|
"manual_override_rate": manual_override_rate,
|
||||||
|
"rework_rate": rework_rate,
|
||||||
|
"auto_assigned_rate": auto_assigned_rate,
|
||||||
|
"avg_review_latency_minutes": avg_review_latency_minutes,
|
||||||
|
"total_task_breakdown_count": task_breakdown_count,
|
||||||
|
"total_task_override_count": task_override_count,
|
||||||
|
"total_qa_feedback_count": qa_feedback_count,
|
||||||
|
"total_signal_feedback_count": signal_feedback_count,
|
||||||
|
"total_revision_count": revision_count,
|
||||||
|
},
|
||||||
|
"trend": trend,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def list_records(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
project_id: Optional[int] = None,
|
||||||
|
source_type: Optional[str] = None,
|
||||||
|
review_status: Optional[str] = None,
|
||||||
|
field_name: Optional[str] = None,
|
||||||
|
reason_code: Optional[str] = None,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
limit = max(1, min(int(limit), 100))
|
||||||
|
offset = max(0, int(offset))
|
||||||
|
stmt = self._base_stmt(tenant_id=tenant_id, project_id=project_id)
|
||||||
|
stmt = self._apply_filters(
|
||||||
|
stmt,
|
||||||
|
source_type=source_type,
|
||||||
|
review_status=review_status,
|
||||||
|
field_name=field_name,
|
||||||
|
reason_code=reason_code,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
total_stmt = select(func.count()).select_from(stmt.subquery())
|
||||||
|
total = int((await self.db.execute(total_stmt)).scalar_one() or 0)
|
||||||
|
|
||||||
|
stmt = stmt.options(selectinload(FeedbackCenterRecord.revisions))
|
||||||
|
stmt = stmt.order_by(FeedbackCenterRecord.created_at.desc().nullslast(), FeedbackCenterRecord.id.desc())
|
||||||
|
rows = (await self.db.execute(stmt.offset(offset).limit(limit))).scalars().all()
|
||||||
|
items = [self._serialize_record(row, include_revisions=False) for row in rows]
|
||||||
|
return {
|
||||||
|
"items": items,
|
||||||
|
"total": total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
"has_more": offset + len(items) < total,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_detail(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
record_id: int,
|
||||||
|
project_id: Optional[int] = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
stmt = (
|
||||||
|
select(FeedbackCenterRecord)
|
||||||
|
.options(selectinload(FeedbackCenterRecord.revisions))
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
FeedbackCenterRecord.tenant_id == int(tenant_id),
|
||||||
|
FeedbackCenterRecord.id == int(record_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if project_id is not None:
|
||||||
|
stmt = stmt.where(FeedbackCenterRecord.project_id == int(project_id))
|
||||||
|
record = (await self.db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if not record:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"record": self._serialize_record(record, include_revisions=True),
|
||||||
|
"revisions": [self._serialize_revision(revision) for revision in record.revisions or []],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def review(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
record_id: int,
|
||||||
|
reviewer_id: int,
|
||||||
|
review_status: str,
|
||||||
|
reason_code: str | None = None,
|
||||||
|
reason_text: str | None = None,
|
||||||
|
reason_candidates: list[dict[str, Any]] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
stmt = (
|
||||||
|
select(FeedbackCenterRecord)
|
||||||
|
.options(selectinload(FeedbackCenterRecord.revisions))
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
FeedbackCenterRecord.tenant_id == int(tenant_id),
|
||||||
|
FeedbackCenterRecord.id == int(record_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
record = (await self.db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if not record:
|
||||||
|
raise ValueError("feedback record not found")
|
||||||
|
|
||||||
|
previous_snapshot = self._serialize_record(record, include_revisions=False)
|
||||||
|
record.review_status = str(review_status)
|
||||||
|
record.reason_code = str(reason_code).strip()[:64] if reason_code else record.reason_code
|
||||||
|
record.reason_text = str(reason_text).strip()[:4000] if reason_text else record.reason_text
|
||||||
|
if reason_candidates is not None:
|
||||||
|
record.reason_candidates = reason_candidates
|
||||||
|
elif not record.reason_candidates:
|
||||||
|
record.reason_candidates = build_reason_candidates(
|
||||||
|
source_type=record.source_type,
|
||||||
|
field_name=record.field_name,
|
||||||
|
before_value=record.before_value,
|
||||||
|
after_value=record.after_value,
|
||||||
|
)
|
||||||
|
record.reviewed_by = int(reviewer_id)
|
||||||
|
record.reviewed_at = now_cn_naive()
|
||||||
|
record.updated_at = record.reviewed_at
|
||||||
|
await self.db.flush()
|
||||||
|
|
||||||
|
current_snapshot = self._serialize_record(record, include_revisions=False)
|
||||||
|
self.db.add(
|
||||||
|
FeedbackCenterRecordRevision(
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
record_id=int(record.id),
|
||||||
|
before_snapshot=previous_snapshot,
|
||||||
|
after_snapshot=current_snapshot,
|
||||||
|
reviewed_by=int(reviewer_id),
|
||||||
|
reviewed_at=record.reviewed_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(record)
|
||||||
|
refreshed = await self.get_detail(tenant_id=tenant_id, record_id=record.id)
|
||||||
|
if refreshed is None:
|
||||||
|
raise ValueError("feedback record not found after review")
|
||||||
|
return refreshed["record"]
|
||||||
|
|
||||||
|
async def ingest_payloads(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
payloads: list[dict[str, Any]],
|
||||||
|
commit: bool = False,
|
||||||
|
) -> list[FeedbackCenterRecord]:
|
||||||
|
records: list[FeedbackCenterRecord] = []
|
||||||
|
for payload in payloads:
|
||||||
|
normalized = self._normalize_payload(payload)
|
||||||
|
existing = await self._find_existing_record(normalized)
|
||||||
|
if existing is None:
|
||||||
|
record = FeedbackCenterRecord(**normalized)
|
||||||
|
self.db.add(record)
|
||||||
|
records.append(record)
|
||||||
|
continue
|
||||||
|
|
||||||
|
previous_snapshot = self._serialize_record(existing, include_revisions=False)
|
||||||
|
changed = self._merge_existing_record(existing, normalized)
|
||||||
|
if changed:
|
||||||
|
current_snapshot = self._serialize_record(existing, include_revisions=False)
|
||||||
|
self.db.add(
|
||||||
|
FeedbackCenterRecordRevision(
|
||||||
|
tenant_id=int(existing.tenant_id),
|
||||||
|
record_id=int(existing.id),
|
||||||
|
before_snapshot=previous_snapshot,
|
||||||
|
after_snapshot=current_snapshot,
|
||||||
|
reviewed_by=normalized.get("reviewed_by"),
|
||||||
|
reviewed_at=normalized.get("reviewed_at") or now_cn_naive(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
records.append(existing)
|
||||||
|
if commit:
|
||||||
|
await self.db.commit()
|
||||||
|
for record in records:
|
||||||
|
await self.db.refresh(record)
|
||||||
|
else:
|
||||||
|
await self.db.flush()
|
||||||
|
return records
|
||||||
|
|
||||||
|
async def ingest_task_breakdown(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
task: Any,
|
||||||
|
project_title: str | None = None,
|
||||||
|
source_context: dict[str, Any] | None = None,
|
||||||
|
commit: bool = False,
|
||||||
|
) -> FeedbackCenterRecord | None:
|
||||||
|
payload = map_task_breakdown_to_record_payload(
|
||||||
|
task=task,
|
||||||
|
project_title=project_title,
|
||||||
|
source_context=source_context,
|
||||||
|
)
|
||||||
|
records = await self.ingest_payloads(payloads=[payload], commit=commit)
|
||||||
|
return records[0] if records else None
|
||||||
|
|
||||||
|
async def ingest_task_overrides(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
task: Any,
|
||||||
|
commit: bool = False,
|
||||||
|
) -> list[FeedbackCenterRecord]:
|
||||||
|
payloads = map_task_override_to_record_payloads(task=task)
|
||||||
|
if not payloads:
|
||||||
|
return []
|
||||||
|
return await self.ingest_payloads(payloads=payloads, commit=commit)
|
||||||
|
|
||||||
|
async def ingest_qa_feedback(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
qa_feedback: Any,
|
||||||
|
qa_record: Any,
|
||||||
|
commit: bool = False,
|
||||||
|
) -> FeedbackCenterRecord | None:
|
||||||
|
payload = map_qa_feedback_to_record_payload(qa_feedback=qa_feedback, qa_record=qa_record)
|
||||||
|
records = await self.ingest_payloads(payloads=[payload], commit=commit)
|
||||||
|
return records[0] if records else None
|
||||||
|
|
||||||
|
async def ingest_signal_feedback(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
signal_feedback: Any,
|
||||||
|
signal: Any,
|
||||||
|
commit: bool = False,
|
||||||
|
) -> FeedbackCenterRecord | None:
|
||||||
|
payload = map_signal_feedback_to_record_payload(signal_feedback=signal_feedback, signal=signal)
|
||||||
|
records = await self.ingest_payloads(payloads=[payload], commit=commit)
|
||||||
|
return records[0] if records else None
|
||||||
|
|
||||||
|
async def get_signal_feedback_stats(self, *, tenant_id: int) -> dict[str, Any]:
|
||||||
|
stmt = select(FeedbackCenterRecord).where(
|
||||||
|
and_(
|
||||||
|
FeedbackCenterRecord.tenant_id == int(tenant_id),
|
||||||
|
FeedbackCenterRecord.source_type == "signal_feedback",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows = (await self.db.execute(stmt)).scalars().all()
|
||||||
|
total = len(rows)
|
||||||
|
if total == 0:
|
||||||
|
return {
|
||||||
|
"total_feedbacks": 0,
|
||||||
|
"accuracy": 0.0,
|
||||||
|
"true_positives": 0,
|
||||||
|
"false_positives": 0,
|
||||||
|
"true_negatives": 0,
|
||||||
|
"false_negatives": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _predicted_actual(row: FeedbackCenterRecord) -> tuple[bool, bool]:
|
||||||
|
before = row.before_value if isinstance(row.before_value, dict) else {}
|
||||||
|
after = row.after_value if isinstance(row.after_value, dict) else {}
|
||||||
|
predicted = bool(before.get("ai_predicted_strategic") if isinstance(before, dict) else False)
|
||||||
|
actual = bool(after.get("was_strategic") if isinstance(after, dict) else False)
|
||||||
|
return predicted, actual
|
||||||
|
|
||||||
|
correct = 0
|
||||||
|
tp = fp = tn = fn = 0
|
||||||
|
for row in rows:
|
||||||
|
predicted, actual = _predicted_actual(row)
|
||||||
|
if predicted == actual:
|
||||||
|
correct += 1
|
||||||
|
if predicted and actual:
|
||||||
|
tp += 1
|
||||||
|
elif predicted and not actual:
|
||||||
|
fp += 1
|
||||||
|
elif not predicted and not actual:
|
||||||
|
tn += 1
|
||||||
|
elif not predicted and actual:
|
||||||
|
fn += 1
|
||||||
|
return {
|
||||||
|
"total_feedbacks": total,
|
||||||
|
"accuracy": correct / total if total else 0.0,
|
||||||
|
"true_positives": tp,
|
||||||
|
"false_positives": fp,
|
||||||
|
"true_negatives": tn,
|
||||||
|
"false_negatives": fn,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _serialize_record(self, record: FeedbackCenterRecord, *, include_revisions: bool) -> dict[str, Any]:
|
||||||
|
payload = _record_snapshot(record)
|
||||||
|
if include_revisions:
|
||||||
|
payload["revisions"] = [self._serialize_revision(revision) for revision in getattr(record, "revisions", [])]
|
||||||
|
else:
|
||||||
|
payload["revisions"] = []
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def _serialize_revision(self, revision: FeedbackCenterRecordRevision) -> dict[str, Any]:
|
||||||
|
return _revision_snapshot(revision)
|
||||||
|
|
||||||
|
def _normalize_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
normalized = dict(payload)
|
||||||
|
normalized["tenant_id"] = int(normalized.get("tenant_id") or 1)
|
||||||
|
normalized["project_id"] = int(normalized["project_id"]) if normalized.get("project_id") not in {None, ""} else None
|
||||||
|
normalized["source_id"] = int(normalized.get("source_id") or 0)
|
||||||
|
normalized["object_id"] = int(normalized.get("object_id") or 0)
|
||||||
|
normalized["source_type"] = str(normalized.get("source_type") or "").strip()[:32]
|
||||||
|
normalized["object_type"] = str(normalized.get("object_type") or "").strip()[:32]
|
||||||
|
normalized["field_name"] = str(normalized.get("field_name") or "").strip()[:128]
|
||||||
|
normalized["source_title"] = str(normalized.get("source_title") or "").strip()[:500] or None
|
||||||
|
normalized["object_title"] = str(normalized.get("object_title") or "").strip()[:500] or None
|
||||||
|
normalized["before_value"] = _normalize_jsonable(normalized.get("before_value"))
|
||||||
|
normalized["after_value"] = _normalize_jsonable(normalized.get("after_value"))
|
||||||
|
normalized["ai_snapshot"] = _normalize_jsonable(normalized.get("ai_snapshot"))
|
||||||
|
ai_confidence = normalized.get("ai_confidence")
|
||||||
|
normalized["ai_confidence"] = float(ai_confidence) if ai_confidence not in {None, ""} else None
|
||||||
|
reason_candidates = normalized.get("reason_candidates")
|
||||||
|
normalized["reason_candidates"] = _normalize_jsonable(reason_candidates or [])
|
||||||
|
normalized["reason_code"] = str(normalized.get("reason_code") or "").strip()[:64] or None
|
||||||
|
normalized["reason_text"] = str(normalized.get("reason_text") or "").strip()[:4000] or None
|
||||||
|
normalized["review_status"] = str(normalized.get("review_status") or "pending").strip()[:16]
|
||||||
|
reviewed_by = normalized.get("reviewed_by")
|
||||||
|
normalized["reviewed_by"] = int(reviewed_by) if reviewed_by not in {None, ""} else None
|
||||||
|
normalized["reviewed_at"] = normalized.get("reviewed_at") or None
|
||||||
|
normalized["auto_assigned"] = bool(normalized.get("auto_assigned", False))
|
||||||
|
normalized.pop("provider", None)
|
||||||
|
normalized.pop("model_name", None)
|
||||||
|
normalized.pop("prompt_version", None)
|
||||||
|
normalized.pop("schema_version", None)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["FeedbackCenterService"]
|
||||||
|
|
@ -2,7 +2,9 @@ fastapi>=0.115.0
|
||||||
uvicorn[standard]>=0.30.0
|
uvicorn[standard]>=0.30.0
|
||||||
sqlalchemy>=2.0.30
|
sqlalchemy>=2.0.30
|
||||||
alembic>=1.13.2
|
alembic>=1.13.2
|
||||||
|
greenlet>=3.0.0
|
||||||
asyncpg>=0.29.0
|
asyncpg>=0.29.0
|
||||||
|
aiosqlite>=0.20.0
|
||||||
psycopg[binary]>=3.2.0
|
psycopg[binary]>=3.2.0
|
||||||
pgvector>=0.3.5
|
pgvector>=0.3.5
|
||||||
python-dotenv>=1.0.1
|
python-dotenv>=1.0.1
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
from app.config.ai_prompt_registry import (
|
from app.config.ai_prompt_registry import (
|
||||||
PROMPT_REGISTRY_MAP,
|
PROMPT_REGISTRY_MAP,
|
||||||
|
PROMPT_FAMILY_ALLOWED,
|
||||||
get_prompt_registry,
|
get_prompt_registry,
|
||||||
get_prompt_registry_entry,
|
get_prompt_registry_entry,
|
||||||
iter_prompt_registry_keys,
|
iter_prompt_registry_keys,
|
||||||
)
|
)
|
||||||
|
from app.config.ai_prompts import (
|
||||||
|
PROMPT_DECOMPOSE_TASKS,
|
||||||
|
PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION,
|
||||||
|
PROMPT_FEISHU_REQUIREMENT_ANALYSIS,
|
||||||
|
PROMPT_TASK_BREAKDOWN,
|
||||||
|
)
|
||||||
from app.config.ai_prompts_catalog import __all__ as PROMPT_CATALOG_EXPORTS
|
from app.config.ai_prompts_catalog import __all__ as PROMPT_CATALOG_EXPORTS
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -18,6 +25,10 @@ def test_prompt_registry_covers_all_catalog_exports() -> None:
|
||||||
entry = get_prompt_registry_entry(key)
|
entry = get_prompt_registry_entry(key)
|
||||||
assert entry is not None
|
assert entry is not None
|
||||||
assert entry["prompt_key"] == key
|
assert entry["prompt_key"] == key
|
||||||
|
assert entry["prompt_family"] in PROMPT_FAMILY_ALLOWED
|
||||||
|
assert entry["responsibility_scope"]
|
||||||
|
assert entry["priority"] in {"high", "medium", "low"}
|
||||||
|
assert entry["audience"]
|
||||||
|
|
||||||
|
|
||||||
def test_prompt_registry_returns_detached_copies() -> None:
|
def test_prompt_registry_returns_detached_copies() -> None:
|
||||||
|
|
@ -30,18 +41,22 @@ def test_prompt_registry_returns_detached_copies() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_high_frequency_registry_entries_have_complete_metadata() -> None:
|
def test_high_frequency_registry_entries_have_complete_metadata() -> None:
|
||||||
required_keys = (
|
expected_families = {
|
||||||
"PROMPT_SIGNAL_PLANNER",
|
"PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION": "classification",
|
||||||
"PROMPT_GENERATE_CHARTER",
|
"PROMPT_FEISHU_LEVEL_CLASSIFICATION": "classification",
|
||||||
"PROMPT_DECOMPOSE_TASKS",
|
"PROMPT_FEISHU_SIGNAL_EXTRACTION": "extraction",
|
||||||
"PROMPT_PROJECT_STATUS_QA",
|
"PROMPT_FEISHU_TASK_ORCHESTRATION": "planning",
|
||||||
"PROMPT_MEMBER_WORKLOAD_QA",
|
"PROMPT_SIGNAL_PLANNER": "planning",
|
||||||
"PROMPT_AI_PMO_QA",
|
"PROMPT_GENERATE_CHARTER": "planning",
|
||||||
"PROMPT_EXTERNAL_TREND_QA",
|
"PROMPT_DECOMPOSE_TASKS": "planning",
|
||||||
"PROMPT_INDUSTRY_TREND_QA",
|
"PROMPT_PROJECT_STATUS_QA": "response",
|
||||||
)
|
"PROMPT_MEMBER_WORKLOAD_QA": "response",
|
||||||
|
"PROMPT_AI_PMO_QA": "response",
|
||||||
|
"PROMPT_EXTERNAL_TREND_QA": "response",
|
||||||
|
"PROMPT_INDUSTRY_TREND_QA": "response",
|
||||||
|
}
|
||||||
|
|
||||||
for prompt_key in required_keys:
|
for prompt_key, family in expected_families.items():
|
||||||
entry = get_prompt_registry_entry(prompt_key)
|
entry = get_prompt_registry_entry(prompt_key)
|
||||||
assert entry is not None
|
assert entry is not None
|
||||||
assert entry["module"]
|
assert entry["module"]
|
||||||
|
|
@ -50,9 +65,21 @@ def test_high_frequency_registry_entries_have_complete_metadata() -> None:
|
||||||
assert entry["output_contract"]
|
assert entry["output_contract"]
|
||||||
assert isinstance(entry["call_sites"], tuple)
|
assert isinstance(entry["call_sites"], tuple)
|
||||||
assert entry["call_sites"], prompt_key
|
assert entry["call_sites"], prompt_key
|
||||||
|
assert entry["prompt_family"] == family
|
||||||
|
assert entry["responsibility_scope"]
|
||||||
|
|
||||||
signal_planner_entry = get_prompt_registry_entry("PROMPT_SIGNAL_PLANNER")
|
signal_planner_entry = get_prompt_registry_entry("PROMPT_SIGNAL_PLANNER")
|
||||||
assert signal_planner_entry is not None
|
assert signal_planner_entry is not None
|
||||||
assert "charter_json" in signal_planner_entry["input_contract"]
|
assert "charter_json" in signal_planner_entry["input_contract"]
|
||||||
assert any("mixin_prompt.py" in site for site in signal_planner_entry["call_sites"])
|
assert any("mixin_prompt.py" in site for site in signal_planner_entry["call_sites"])
|
||||||
assert any("mixin_analyze.py" in site for site in signal_planner_entry["call_sites"])
|
assert any("mixin_analyze.py" in site for site in signal_planner_entry["call_sites"])
|
||||||
|
assert "规划" in signal_planner_entry["responsibility_scope"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_deprecated_prompt_aliases_point_to_canonical_prompts() -> None:
|
||||||
|
registry_keys = set(iter_prompt_registry_keys())
|
||||||
|
|
||||||
|
assert "PROMPT_FEISHU_REQUIREMENT_ANALYSIS" not in registry_keys
|
||||||
|
assert "PROMPT_TASK_BREAKDOWN" not in registry_keys
|
||||||
|
assert PROMPT_FEISHU_REQUIREMENT_ANALYSIS == PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION
|
||||||
|
assert PROMPT_TASK_BREAKDOWN == PROMPT_DECOMPOSE_TASKS
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,353 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy import JSON
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
os.environ["DATABASE_URL"] = "postgresql+asyncpg://tester:tester@localhost:5432/pmflow_test"
|
||||||
|
|
||||||
|
from app.api.ai_steward_api import routes_feedback_center
|
||||||
|
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.models.base import Base
|
||||||
|
from app.models.feedback_center import FeedbackCenterRecord, FeedbackCenterRecordRevision
|
||||||
|
import app.services.feedback_center.service as feedback_center_service_module
|
||||||
|
|
||||||
|
|
||||||
|
def _use_sqlite_json_types() -> None:
|
||||||
|
for table in (FeedbackCenterRecord.__table__, FeedbackCenterRecordRevision.__table__):
|
||||||
|
for column in table.columns:
|
||||||
|
if column.name in {"before_value", "after_value", "ai_snapshot", "reason_candidates", "before_snapshot", "after_snapshot"}:
|
||||||
|
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=[
|
||||||
|
FeedbackCenterRecord.__table__,
|
||||||
|
FeedbackCenterRecordRevision.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
async with maker() as session:
|
||||||
|
yield session
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_app(db_session):
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(routes_feedback_center.router)
|
||||||
|
|
||||||
|
async def _fake_get_db():
|
||||||
|
yield db_session
|
||||||
|
|
||||||
|
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=9001,
|
||||||
|
tenant_id=1,
|
||||||
|
name="管理员",
|
||||||
|
username="admin",
|
||||||
|
role="ADMIN",
|
||||||
|
)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_feedback_center_summary_list_detail_and_catalog(db_session) -> None:
|
||||||
|
db_session.add_all(
|
||||||
|
[
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="task_breakdown",
|
||||||
|
source_id=101,
|
||||||
|
object_type="task",
|
||||||
|
object_id=201,
|
||||||
|
field_name="task_object",
|
||||||
|
source_title="任务拆解",
|
||||||
|
object_title="任务拆解",
|
||||||
|
before_value=None,
|
||||||
|
after_value={"title": "任务拆解"},
|
||||||
|
ai_confidence=0.91,
|
||||||
|
reason_candidates=[{"code": "manual_override", "label": "人工覆盖"}],
|
||||||
|
reason_code="manual_override",
|
||||||
|
review_status="pending",
|
||||||
|
auto_assigned=False,
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="qa_feedback",
|
||||||
|
source_id=102,
|
||||||
|
object_type="qa_record",
|
||||||
|
object_id=202,
|
||||||
|
field_name="qa_feedback",
|
||||||
|
source_title="Q&A",
|
||||||
|
object_title="Q&A",
|
||||||
|
before_value={"answer": "旧答案"},
|
||||||
|
after_value={"rating": "negative", "correct_answer": "新答案"},
|
||||||
|
ai_confidence=None,
|
||||||
|
reason_candidates=[{"code": "answer_incorrect", "label": "答案不正确"}],
|
||||||
|
reason_code="answer_incorrect",
|
||||||
|
review_status="reviewed",
|
||||||
|
auto_assigned=False,
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=22,
|
||||||
|
source_type="signal_feedback",
|
||||||
|
source_id=103,
|
||||||
|
object_type="signal",
|
||||||
|
object_id=203,
|
||||||
|
field_name="strategic_classification",
|
||||||
|
source_title="上线异常告警",
|
||||||
|
object_title="上线异常告警",
|
||||||
|
before_value={"ai_predicted_strategic": True},
|
||||||
|
after_value={"was_strategic": False},
|
||||||
|
ai_confidence=0.8,
|
||||||
|
reason_candidates=[{"code": "strategic_judgement_mismatch", "label": "战略判断偏差"}],
|
||||||
|
reason_code="strategic_judgement_mismatch",
|
||||||
|
review_status="accepted",
|
||||||
|
auto_assigned=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
app = _build_app(db_session)
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
summary_resp = await client.get("/ai-steward/feedback-center/summary", params={"project_id": 11})
|
||||||
|
list_resp = await client.get("/ai-steward/feedback-center/items", params={"project_id": 11, "limit": 20})
|
||||||
|
detail_resp = await client.get("/ai-steward/feedback-center/items/1", params={"project_id": 11})
|
||||||
|
catalog_resp = await client.get(
|
||||||
|
"/ai-steward/feedback-center/reason-catalog",
|
||||||
|
params={"source_type": "task_override", "field_name": "priority"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert summary_resp.status_code == 200
|
||||||
|
summary_payload = summary_resp.json()
|
||||||
|
assert summary_payload["code"] == 0
|
||||||
|
assert summary_payload["data"]["total"] == 2
|
||||||
|
assert summary_payload["data"]["by_status"]["pending"] == 1
|
||||||
|
assert summary_payload["data"]["by_status"]["reviewed"] == 1
|
||||||
|
|
||||||
|
assert list_resp.status_code == 200
|
||||||
|
list_payload = list_resp.json()
|
||||||
|
assert list_payload["code"] == 0
|
||||||
|
assert len(list_payload["data"]["items"]) == 2
|
||||||
|
assert all(item["project_id"] == 11 for item in list_payload["data"]["items"])
|
||||||
|
|
||||||
|
assert detail_resp.status_code == 200
|
||||||
|
detail_payload = detail_resp.json()
|
||||||
|
assert detail_payload["code"] == 0
|
||||||
|
assert detail_payload["data"]["record"]["id"] == 1
|
||||||
|
assert detail_payload["data"]["record"]["source_type"] == "task_breakdown"
|
||||||
|
assert detail_payload["data"]["revisions"] == []
|
||||||
|
|
||||||
|
assert catalog_resp.status_code == 200
|
||||||
|
catalog_payload = catalog_resp.json()
|
||||||
|
assert catalog_payload["code"] == 0
|
||||||
|
codes = [item["code"] for item in catalog_payload["data"]["items"]]
|
||||||
|
assert "priority_misalignment" in codes
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_feedback_center_summary_honors_window_and_scope_query_params(db_session, monkeypatch) -> None:
|
||||||
|
fixed_now = datetime(2025, 1, 15, 12, 0, 0)
|
||||||
|
monkeypatch.setattr(feedback_center_service_module, "now_cn_naive", lambda: fixed_now)
|
||||||
|
|
||||||
|
db_session.add_all(
|
||||||
|
[
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="task_breakdown",
|
||||||
|
source_id=801,
|
||||||
|
object_type="task",
|
||||||
|
object_id=901,
|
||||||
|
field_name="task_breakdown",
|
||||||
|
source_title="任务拆解",
|
||||||
|
object_title="任务拆解",
|
||||||
|
review_status="accepted",
|
||||||
|
auto_assigned=True,
|
||||||
|
created_at=datetime(2025, 1, 15, 8, 0, 0),
|
||||||
|
reviewed_at=datetime(2025, 1, 15, 8, 30, 0),
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="task_override",
|
||||||
|
source_id=802,
|
||||||
|
object_type="task",
|
||||||
|
object_id=901,
|
||||||
|
field_name="priority",
|
||||||
|
source_title="优先级修正",
|
||||||
|
object_title="优先级修正",
|
||||||
|
review_status="reviewed",
|
||||||
|
created_at=datetime(2025, 1, 14, 8, 0, 0),
|
||||||
|
reviewed_at=datetime(2025, 1, 14, 8, 50, 0),
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="qa_feedback",
|
||||||
|
source_id=803,
|
||||||
|
object_type="qa_record",
|
||||||
|
object_id=902,
|
||||||
|
field_name="qa_feedback",
|
||||||
|
source_title="QA 反馈",
|
||||||
|
object_title="QA 反馈",
|
||||||
|
review_status="accepted",
|
||||||
|
created_at=datetime(2025, 1, 13, 8, 0, 0),
|
||||||
|
reviewed_at=datetime(2025, 1, 13, 8, 20, 0),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
app = _build_app(db_session)
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.get(
|
||||||
|
"/ai-steward/feedback-center/summary",
|
||||||
|
params={"project_id": 11, "window_days": 30, "source_group": "task"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["code"] == 0
|
||||||
|
assert payload["data"]["source_group"] == "task"
|
||||||
|
assert payload["data"]["trend"]["window_days"] == 30
|
||||||
|
assert payload["data"]["trend"]["current"]["total_records"] == 2
|
||||||
|
assert payload["data"]["trend"]["current"]["task_breakdown_count"] == 1
|
||||||
|
assert payload["data"]["trend"]["current"]["task_override_count"] == 1
|
||||||
|
assert payload["data"]["trend"]["current"]["qa_feedback_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_feedback_center_summary_accepts_scope_params(db_session) -> None:
|
||||||
|
db_session.add_all(
|
||||||
|
[
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="task_breakdown",
|
||||||
|
source_id=201,
|
||||||
|
object_type="task",
|
||||||
|
object_id=301,
|
||||||
|
field_name="task_object",
|
||||||
|
review_status="pending",
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="qa_feedback",
|
||||||
|
source_id=202,
|
||||||
|
object_type="qa_record",
|
||||||
|
object_id=302,
|
||||||
|
field_name="qa_feedback",
|
||||||
|
review_status="accepted",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
app = _build_app(db_session)
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
scoped_resp = await client.get(
|
||||||
|
"/ai-steward/feedback-center/summary",
|
||||||
|
params={"project_id": 11, "window_days": 30, "source_group": "qa"},
|
||||||
|
)
|
||||||
|
invalid_window = await client.get(
|
||||||
|
"/ai-steward/feedback-center/summary",
|
||||||
|
params={"project_id": 11, "window_days": 14},
|
||||||
|
)
|
||||||
|
invalid_group = await client.get(
|
||||||
|
"/ai-steward/feedback-center/summary",
|
||||||
|
params={"project_id": 11, "source_group": "unknown"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert scoped_resp.status_code == 200
|
||||||
|
scoped_payload = scoped_resp.json()
|
||||||
|
assert scoped_payload["code"] == 0
|
||||||
|
assert scoped_payload["data"]["trend"]["window_days"] == 30
|
||||||
|
assert scoped_payload["data"]["by_source_type"] == {"qa_feedback": 1}
|
||||||
|
|
||||||
|
assert invalid_window.status_code == 400
|
||||||
|
assert invalid_group.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_feedback_center_review_updates_record_and_revision(db_session) -> None:
|
||||||
|
db_session.add(
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="task_override",
|
||||||
|
source_id=500,
|
||||||
|
object_type="task",
|
||||||
|
object_id=600,
|
||||||
|
field_name="priority",
|
||||||
|
source_title="优先级修正",
|
||||||
|
object_title="优先级修正",
|
||||||
|
before_value="P2",
|
||||||
|
after_value="P1",
|
||||||
|
ai_confidence=0.62,
|
||||||
|
reason_candidates=[{"code": "priority_misalignment", "label": "优先级错配"}],
|
||||||
|
reason_code="priority_misalignment",
|
||||||
|
review_status="pending",
|
||||||
|
auto_assigned=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
app = _build_app(db_session)
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/ai-steward/feedback-center/items/1/review",
|
||||||
|
json={
|
||||||
|
"review_status": "accepted",
|
||||||
|
"reason_code": "priority_misalignment",
|
||||||
|
"reason_text": "优先级确认需要上调",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["code"] == 0
|
||||||
|
assert payload["data"]["review_status"] == "accepted"
|
||||||
|
assert payload["data"]["reason_code"] == "priority_misalignment"
|
||||||
|
assert payload["data"]["reviewed_by"] == 9001
|
||||||
|
|
||||||
|
refreshed = await db_session.get(FeedbackCenterRecord, 1)
|
||||||
|
assert refreshed is not None
|
||||||
|
assert refreshed.review_status == "accepted"
|
||||||
|
assert refreshed.reason_text == "优先级确认需要上调"
|
||||||
|
|
||||||
|
revisions = (
|
||||||
|
await db_session.execute(
|
||||||
|
FeedbackCenterRecordRevision.__table__.select().where(FeedbackCenterRecordRevision.record_id == 1)
|
||||||
|
)
|
||||||
|
).mappings().all()
|
||||||
|
assert len(revisions) == 1
|
||||||
|
assert revisions[0]["before_snapshot"]["review_status"] == "pending"
|
||||||
|
assert revisions[0]["after_snapshot"]["review_status"] == "accepted"
|
||||||
|
|
@ -0,0 +1,787 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from sqlalchemy import 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.feedback_center import FeedbackCenterRecord, FeedbackCenterRecordRevision
|
||||||
|
from app.services.feedback_center.mappers import (
|
||||||
|
map_qa_feedback_to_record_payload,
|
||||||
|
map_signal_feedback_to_record_payload,
|
||||||
|
map_task_override_to_record_payloads,
|
||||||
|
)
|
||||||
|
from app.services.feedback_center.reason_catalog import build_reason_candidates
|
||||||
|
import app.services.feedback_center.service as feedback_center_service_module
|
||||||
|
from app.services.feedback_center.service import FeedbackCenterService
|
||||||
|
|
||||||
|
|
||||||
|
@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=[
|
||||||
|
FeedbackCenterRecord.__table__,
|
||||||
|
FeedbackCenterRecordRevision.__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_summary_list_and_detail_respect_tenant_and_project_scope(db_session) -> None:
|
||||||
|
db_session.add_all(
|
||||||
|
[
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="qa_feedback",
|
||||||
|
source_id=101,
|
||||||
|
object_type="qa_record",
|
||||||
|
object_id=1,
|
||||||
|
field_name="qa_feedback",
|
||||||
|
before_value={"answer": "A"},
|
||||||
|
after_value={"rating": "negative"},
|
||||||
|
review_status="pending",
|
||||||
|
reason_code="answer_incorrect",
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="signal_feedback",
|
||||||
|
source_id=102,
|
||||||
|
object_type="signal",
|
||||||
|
object_id=2,
|
||||||
|
field_name="strategic_classification",
|
||||||
|
before_value={"predicted": True},
|
||||||
|
after_value={"actual": False},
|
||||||
|
review_status="reviewed",
|
||||||
|
reason_code="answer_incorrect",
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=22,
|
||||||
|
source_type="task_override",
|
||||||
|
source_id=103,
|
||||||
|
object_type="task",
|
||||||
|
object_id=3,
|
||||||
|
field_name="estimated_hours",
|
||||||
|
before_value=8,
|
||||||
|
after_value=13,
|
||||||
|
review_status="accepted",
|
||||||
|
reason_code="effort_estimation_bias",
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=2,
|
||||||
|
project_id=11,
|
||||||
|
source_type="qa_feedback",
|
||||||
|
source_id=201,
|
||||||
|
object_type="qa_record",
|
||||||
|
object_id=9,
|
||||||
|
field_name="qa_feedback",
|
||||||
|
before_value={"answer": "B"},
|
||||||
|
after_value={"rating": "negative"},
|
||||||
|
review_status="pending",
|
||||||
|
reason_code="answer_incorrect",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
service = FeedbackCenterService(db_session)
|
||||||
|
summary = await service.summary(tenant_id=1, project_id=11)
|
||||||
|
records = await service.list_records(tenant_id=1, project_id=11, limit=50, offset=0)
|
||||||
|
|
||||||
|
assert summary["total"] == 2
|
||||||
|
assert summary["by_status"]["pending"] == 1
|
||||||
|
assert summary["by_status"]["reviewed"] == 1
|
||||||
|
assert summary["by_source_type"]["qa_feedback"] == 1
|
||||||
|
assert summary["by_source_type"]["signal_feedback"] == 1
|
||||||
|
top_reason_code = summary["top_reasons"][0].get("reason_code") or summary["top_reasons"][0].get("code")
|
||||||
|
assert top_reason_code == "answer_incorrect"
|
||||||
|
assert summary["top_reasons"][0]["count"] == 2
|
||||||
|
assert records["total"] == 2
|
||||||
|
assert len(records["items"]) == 2
|
||||||
|
assert all(item["tenant_id"] == 1 for item in records["items"])
|
||||||
|
assert all(item["project_id"] == 11 for item in records["items"])
|
||||||
|
|
||||||
|
detail = await service.get_detail(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
record_id=records["items"][0]["id"],
|
||||||
|
)
|
||||||
|
assert detail is not None
|
||||||
|
assert detail["record"]["project_id"] == 11
|
||||||
|
assert detail["revisions"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_review_updates_record_and_appends_revision_in_single_flow(db_session) -> None:
|
||||||
|
record = FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=31,
|
||||||
|
source_type="task_override",
|
||||||
|
source_id=501,
|
||||||
|
object_type="task",
|
||||||
|
object_id=88,
|
||||||
|
field_name="priority",
|
||||||
|
before_value="P2",
|
||||||
|
after_value="P0",
|
||||||
|
reason_code="manual_override",
|
||||||
|
reason_text="初始记录",
|
||||||
|
review_status="pending",
|
||||||
|
)
|
||||||
|
db_session.add(record)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
service = FeedbackCenterService(db_session)
|
||||||
|
reviewed = await service.review(
|
||||||
|
tenant_id=1,
|
||||||
|
record_id=record.id,
|
||||||
|
reviewer_id=9001,
|
||||||
|
review_status="accepted",
|
||||||
|
reason_code="priority_misalignment",
|
||||||
|
reason_text="已确认优先级应提升",
|
||||||
|
reason_candidates=[{"code": "priority_misalignment", "label": "优先级偏差"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert reviewed["id"] == record.id
|
||||||
|
assert reviewed["review_status"] == "accepted"
|
||||||
|
assert reviewed["reason_code"] == "priority_misalignment"
|
||||||
|
assert reviewed["reviewed_by"] == 9001
|
||||||
|
assert reviewed["reviewed_at"] is not None
|
||||||
|
|
||||||
|
revisions = (
|
||||||
|
await db_session.execute(
|
||||||
|
select(FeedbackCenterRecordRevision).where(
|
||||||
|
FeedbackCenterRecordRevision.tenant_id == 1,
|
||||||
|
FeedbackCenterRecordRevision.record_id == record.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
assert len(revisions) == 1
|
||||||
|
assert revisions[0].before_snapshot["review_status"] == "pending"
|
||||||
|
assert revisions[0].after_snapshot["review_status"] == "accepted"
|
||||||
|
assert revisions[0].reviewed_by == 9001
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ingest_payloads_upsert_same_identity_and_append_revision(db_session) -> None:
|
||||||
|
service = FeedbackCenterService(db_session)
|
||||||
|
initial = await service.ingest_payloads(
|
||||||
|
payloads=[
|
||||||
|
{
|
||||||
|
"tenant_id": 1,
|
||||||
|
"project_id": 44,
|
||||||
|
"source_type": "task_override",
|
||||||
|
"source_id": 808,
|
||||||
|
"object_type": "task",
|
||||||
|
"object_id": 909,
|
||||||
|
"field_name": "priority",
|
||||||
|
"source_title": "优先级调整",
|
||||||
|
"object_title": "优先级调整",
|
||||||
|
"before_value": "P2",
|
||||||
|
"after_value": "P1",
|
||||||
|
"ai_snapshot": {"priority": "P2"},
|
||||||
|
"ai_confidence": 0.61,
|
||||||
|
"reason_candidates": [{"code": "priority_misalignment", "label": "优先级错配"}],
|
||||||
|
"reason_code": "priority_misalignment",
|
||||||
|
"review_status": "pending",
|
||||||
|
"reviewed_by": None,
|
||||||
|
"reviewed_at": None,
|
||||||
|
"auto_assigned": False,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
assert len(initial) == 1
|
||||||
|
record_id = initial[0].id
|
||||||
|
|
||||||
|
updated = await service.ingest_payloads(
|
||||||
|
payloads=[
|
||||||
|
{
|
||||||
|
"tenant_id": 1,
|
||||||
|
"project_id": 44,
|
||||||
|
"source_type": "task_override",
|
||||||
|
"source_id": 808,
|
||||||
|
"object_type": "task",
|
||||||
|
"object_id": 909,
|
||||||
|
"field_name": "priority",
|
||||||
|
"source_title": "优先级调整(人工确认)",
|
||||||
|
"object_title": "优先级调整(人工确认)",
|
||||||
|
"before_value": "P2",
|
||||||
|
"after_value": "P0",
|
||||||
|
"ai_snapshot": {"priority": "P2"},
|
||||||
|
"ai_confidence": 0.72,
|
||||||
|
"reason_candidates": [{"code": "priority_misalignment", "label": "优先级错配"}],
|
||||||
|
"reason_code": "priority_misalignment",
|
||||||
|
"review_status": "accepted",
|
||||||
|
"reviewed_by": 42,
|
||||||
|
"reviewed_at": None,
|
||||||
|
"auto_assigned": False,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(updated) == 1
|
||||||
|
assert updated[0].id == record_id
|
||||||
|
|
||||||
|
detail = await service.get_detail(tenant_id=1, record_id=record_id)
|
||||||
|
assert detail is not None
|
||||||
|
assert detail["record"]["after_value"] == "P0"
|
||||||
|
assert detail["record"]["review_status"] == "accepted"
|
||||||
|
assert len(detail["revisions"]) == 1
|
||||||
|
assert detail["revisions"][0]["before_snapshot"]["after_value"] == "P1"
|
||||||
|
assert detail["revisions"][0]["after_snapshot"]["after_value"] == "P0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reason_catalog_is_deterministic_for_field_diff() -> None:
|
||||||
|
first = build_reason_candidates(
|
||||||
|
source_type="task_override",
|
||||||
|
field_name="estimated_hours",
|
||||||
|
before_value=5,
|
||||||
|
after_value=13,
|
||||||
|
)
|
||||||
|
second = build_reason_candidates(
|
||||||
|
source_type="task_override",
|
||||||
|
field_name="estimated_hours",
|
||||||
|
before_value=5,
|
||||||
|
after_value=13,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first == second
|
||||||
|
assert first[0]["code"] == "effort_estimation_bias"
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_mappers_generate_unified_payloads() -> None:
|
||||||
|
qa_payload = map_qa_feedback_to_record_payload(
|
||||||
|
qa_feedback=SimpleNamespace(
|
||||||
|
id=1,
|
||||||
|
tenant_id=1,
|
||||||
|
qa_id=7,
|
||||||
|
rating="negative",
|
||||||
|
helpfulness="low",
|
||||||
|
error_types=["hallucination"],
|
||||||
|
correct_answer="正确答案",
|
||||||
|
submitted_by=100,
|
||||||
|
created_at=None,
|
||||||
|
),
|
||||||
|
qa_record=SimpleNamespace(
|
||||||
|
project_id=99,
|
||||||
|
question="问题",
|
||||||
|
answer="旧答案",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert qa_payload["source_type"] == "qa_feedback"
|
||||||
|
assert qa_payload["project_id"] == 99
|
||||||
|
assert qa_payload["object_id"] == 7
|
||||||
|
|
||||||
|
signal_payload = map_signal_feedback_to_record_payload(
|
||||||
|
signal_feedback=SimpleNamespace(
|
||||||
|
id=2,
|
||||||
|
tenant_id=1,
|
||||||
|
signal_id=33,
|
||||||
|
was_strategic=False,
|
||||||
|
ai_predicted_strategic=True,
|
||||||
|
ai_confidence=0.81,
|
||||||
|
reason="噪音信号",
|
||||||
|
user_id="u-1",
|
||||||
|
created_at=None,
|
||||||
|
),
|
||||||
|
signal=SimpleNamespace(
|
||||||
|
matched_project_id=55,
|
||||||
|
title="上线异常告警",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert signal_payload["source_type"] == "signal_feedback"
|
||||||
|
assert signal_payload["project_id"] == 55
|
||||||
|
assert signal_payload["field_name"] == "strategic_classification"
|
||||||
|
|
||||||
|
task_payloads = map_task_override_to_record_payloads(
|
||||||
|
task=SimpleNamespace(
|
||||||
|
id=66,
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=77,
|
||||||
|
title="实现回放",
|
||||||
|
ai_breakdown_payload={"estimated_hours": 5, "priority": "P2"},
|
||||||
|
manual_overrides={"estimated_hours": 8},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert len(task_payloads) == 1
|
||||||
|
assert task_payloads[0]["source_type"] == "task_override"
|
||||||
|
assert task_payloads[0]["before_value"] == 5
|
||||||
|
assert task_payloads[0]["after_value"] == 8
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_summary_metrics_cover_learning_kpis(db_session) -> None:
|
||||||
|
base_time = datetime(2025, 1, 1, 10, 0, 0)
|
||||||
|
db_session.add_all(
|
||||||
|
[
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="task_breakdown",
|
||||||
|
source_id=201,
|
||||||
|
object_type="task",
|
||||||
|
object_id=1,
|
||||||
|
field_name="task_breakdown",
|
||||||
|
review_status="accepted",
|
||||||
|
auto_assigned=True,
|
||||||
|
created_at=base_time,
|
||||||
|
reviewed_at=base_time + timedelta(minutes=60),
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="task_breakdown",
|
||||||
|
source_id=202,
|
||||||
|
object_type="task",
|
||||||
|
object_id=2,
|
||||||
|
field_name="task_breakdown",
|
||||||
|
review_status="rejected",
|
||||||
|
auto_assigned=False,
|
||||||
|
created_at=base_time + timedelta(minutes=10),
|
||||||
|
reviewed_at=base_time + timedelta(minutes=40),
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="task_override",
|
||||||
|
source_id=301,
|
||||||
|
object_type="task",
|
||||||
|
object_id=1,
|
||||||
|
field_name="priority",
|
||||||
|
review_status="accepted",
|
||||||
|
created_at=base_time + timedelta(minutes=5),
|
||||||
|
reviewed_at=base_time + timedelta(minutes=20),
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="task_override",
|
||||||
|
source_id=302,
|
||||||
|
object_type="task",
|
||||||
|
object_id=1,
|
||||||
|
field_name="estimated_hours",
|
||||||
|
review_status="pending",
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="qa_feedback",
|
||||||
|
source_id=401,
|
||||||
|
object_type="qa_record",
|
||||||
|
object_id=7,
|
||||||
|
field_name="qa_feedback",
|
||||||
|
review_status="pending",
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="signal_feedback",
|
||||||
|
source_id=501,
|
||||||
|
object_type="signal",
|
||||||
|
object_id=9,
|
||||||
|
field_name="strategic_classification",
|
||||||
|
review_status="reviewed",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
target_record = (
|
||||||
|
await db_session.execute(
|
||||||
|
select(FeedbackCenterRecord).where(
|
||||||
|
FeedbackCenterRecord.tenant_id == 1,
|
||||||
|
FeedbackCenterRecord.project_id == 11,
|
||||||
|
FeedbackCenterRecord.source_type == "task_override",
|
||||||
|
FeedbackCenterRecord.field_name == "priority",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
db_session.add_all(
|
||||||
|
[
|
||||||
|
FeedbackCenterRecordRevision(
|
||||||
|
tenant_id=1,
|
||||||
|
record_id=target_record.id,
|
||||||
|
before_snapshot={"review_status": "pending"},
|
||||||
|
after_snapshot={"review_status": "reviewed"},
|
||||||
|
reviewed_by=9001,
|
||||||
|
reviewed_at=base_time + timedelta(minutes=25),
|
||||||
|
),
|
||||||
|
FeedbackCenterRecordRevision(
|
||||||
|
tenant_id=1,
|
||||||
|
record_id=target_record.id,
|
||||||
|
before_snapshot={"review_status": "reviewed"},
|
||||||
|
after_snapshot={"review_status": "accepted"},
|
||||||
|
reviewed_by=9001,
|
||||||
|
reviewed_at=base_time + timedelta(minutes=30),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
summary = await FeedbackCenterService(db_session).summary(tenant_id=1, project_id=11)
|
||||||
|
metrics = summary["metrics"]
|
||||||
|
|
||||||
|
assert metrics["accepted_rate_reviewed"] == pytest.approx(2 / 3)
|
||||||
|
assert metrics["manual_override_rate"] == pytest.approx(0.5)
|
||||||
|
assert metrics["auto_assigned_rate"] == pytest.approx(0.5)
|
||||||
|
assert metrics["rework_rate"] == pytest.approx(1 / 6)
|
||||||
|
assert metrics["avg_review_latency_minutes"] == pytest.approx(35.0)
|
||||||
|
assert metrics["total_task_breakdown_count"] == 2
|
||||||
|
assert metrics["total_task_override_count"] == 2
|
||||||
|
assert metrics["total_qa_feedback_count"] == 1
|
||||||
|
assert metrics["total_signal_feedback_count"] == 1
|
||||||
|
assert metrics["total_revision_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_summary_trend_cover_daily_windows_and_deltas(db_session, monkeypatch) -> None:
|
||||||
|
fixed_now = datetime(2025, 1, 15, 12, 0, 0)
|
||||||
|
monkeypatch.setattr(feedback_center_service_module, "now_cn_naive", lambda: fixed_now)
|
||||||
|
|
||||||
|
records = [
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="task_breakdown",
|
||||||
|
source_id=601,
|
||||||
|
object_type="task",
|
||||||
|
object_id=100,
|
||||||
|
field_name="task_breakdown",
|
||||||
|
review_status="accepted",
|
||||||
|
auto_assigned=True,
|
||||||
|
created_at=datetime(2025, 1, 15, 8, 0, 0),
|
||||||
|
reviewed_at=datetime(2025, 1, 15, 8, 30, 0),
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="task_breakdown",
|
||||||
|
source_id=602,
|
||||||
|
object_type="task",
|
||||||
|
object_id=101,
|
||||||
|
field_name="task_breakdown",
|
||||||
|
review_status="rejected",
|
||||||
|
auto_assigned=False,
|
||||||
|
created_at=datetime(2025, 1, 14, 8, 0, 0),
|
||||||
|
reviewed_at=datetime(2025, 1, 14, 8, 40, 0),
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="task_override",
|
||||||
|
source_id=603,
|
||||||
|
object_type="task",
|
||||||
|
object_id=100,
|
||||||
|
field_name="priority",
|
||||||
|
review_status="pending",
|
||||||
|
created_at=datetime(2025, 1, 14, 9, 0, 0),
|
||||||
|
reviewed_at=datetime(2025, 1, 14, 9, 50, 0),
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="qa_feedback",
|
||||||
|
source_id=604,
|
||||||
|
object_type="qa_record",
|
||||||
|
object_id=401,
|
||||||
|
field_name="qa_feedback",
|
||||||
|
review_status="reviewed",
|
||||||
|
created_at=datetime(2025, 1, 13, 9, 0, 0),
|
||||||
|
reviewed_at=datetime(2025, 1, 13, 9, 20, 0),
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="signal_feedback",
|
||||||
|
source_id=605,
|
||||||
|
object_type="signal",
|
||||||
|
object_id=501,
|
||||||
|
field_name="strategic_classification",
|
||||||
|
review_status="pending",
|
||||||
|
created_at=datetime(2025, 1, 12, 9, 0, 0),
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="task_breakdown",
|
||||||
|
source_id=606,
|
||||||
|
object_type="task",
|
||||||
|
object_id=200,
|
||||||
|
field_name="task_breakdown",
|
||||||
|
review_status="accepted",
|
||||||
|
auto_assigned=False,
|
||||||
|
created_at=datetime(2025, 1, 8, 8, 0, 0),
|
||||||
|
reviewed_at=datetime(2025, 1, 8, 8, 20, 0),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
db_session.add_all(records)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
target_record = (
|
||||||
|
await db_session.execute(
|
||||||
|
select(FeedbackCenterRecord).where(
|
||||||
|
FeedbackCenterRecord.tenant_id == 1,
|
||||||
|
FeedbackCenterRecord.project_id == 11,
|
||||||
|
FeedbackCenterRecord.source_type == "task_override",
|
||||||
|
FeedbackCenterRecord.field_name == "priority",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
db_session.add_all(
|
||||||
|
[
|
||||||
|
FeedbackCenterRecordRevision(
|
||||||
|
tenant_id=1,
|
||||||
|
record_id=target_record.id,
|
||||||
|
before_snapshot={"review_status": "pending"},
|
||||||
|
after_snapshot={"review_status": "reviewed"},
|
||||||
|
reviewed_by=9001,
|
||||||
|
reviewed_at=datetime(2025, 1, 14, 10, 0, 0),
|
||||||
|
),
|
||||||
|
FeedbackCenterRecordRevision(
|
||||||
|
tenant_id=1,
|
||||||
|
record_id=target_record.id,
|
||||||
|
before_snapshot={"review_status": "reviewed"},
|
||||||
|
after_snapshot={"review_status": "accepted"},
|
||||||
|
reviewed_by=9001,
|
||||||
|
reviewed_at=datetime(2025, 1, 14, 10, 10, 0),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
summary = await FeedbackCenterService(db_session).summary(tenant_id=1, project_id=11)
|
||||||
|
trend = summary["trend"]
|
||||||
|
|
||||||
|
assert trend["window_days"] == 7
|
||||||
|
assert trend["current_window_start"] == datetime(2025, 1, 9).date()
|
||||||
|
assert trend["previous_window_start"] == datetime(2025, 1, 2).date()
|
||||||
|
assert len(trend["daily"]) == 7
|
||||||
|
|
||||||
|
jan14 = next(item for item in trend["daily"] if item["date"] == "2025-01-14")
|
||||||
|
assert jan14["total_records"] == 2
|
||||||
|
assert jan14["task_breakdown_count"] == 1
|
||||||
|
assert jan14["task_override_count"] == 1
|
||||||
|
assert jan14["rework_count"] == 1
|
||||||
|
assert jan14["avg_review_latency_minutes"] == pytest.approx(45.0)
|
||||||
|
|
||||||
|
current = trend["current"]
|
||||||
|
previous = trend["previous"]
|
||||||
|
deltas = trend["deltas"]
|
||||||
|
|
||||||
|
assert current["total_records"] == 5
|
||||||
|
assert current["accepted_rate_reviewed"] == pytest.approx(0.5)
|
||||||
|
assert current["manual_override_rate"] == pytest.approx(0.5)
|
||||||
|
assert current["auto_assigned_rate"] == pytest.approx(0.5)
|
||||||
|
assert current["rework_rate"] == pytest.approx(1 / 5)
|
||||||
|
assert current["avg_review_latency_minutes"] == pytest.approx(35.0)
|
||||||
|
assert current["revision_count"] == 2
|
||||||
|
|
||||||
|
assert previous["total_records"] == 1
|
||||||
|
assert previous["accepted_rate_reviewed"] == pytest.approx(1.0)
|
||||||
|
assert previous["manual_override_rate"] == pytest.approx(0.0)
|
||||||
|
assert previous["auto_assigned_rate"] == pytest.approx(0.0)
|
||||||
|
assert previous["rework_rate"] == pytest.approx(0.0)
|
||||||
|
assert previous["avg_review_latency_minutes"] == pytest.approx(20.0)
|
||||||
|
|
||||||
|
assert deltas["total_records_delta"] == 4
|
||||||
|
assert deltas["accepted_rate_reviewed_delta"] == pytest.approx(-0.5)
|
||||||
|
assert deltas["manual_override_rate_delta"] == pytest.approx(0.5)
|
||||||
|
assert deltas["auto_assigned_rate_delta"] == pytest.approx(0.5)
|
||||||
|
assert deltas["rework_rate_delta"] == pytest.approx(1 / 5)
|
||||||
|
assert deltas["avg_review_latency_minutes_delta"] == pytest.approx(15.0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_summary_trend_respects_source_group_scope(db_session, monkeypatch) -> None:
|
||||||
|
fixed_now = datetime(2025, 1, 15, 12, 0, 0)
|
||||||
|
monkeypatch.setattr(feedback_center_service_module, "now_cn_naive", lambda: fixed_now)
|
||||||
|
|
||||||
|
records = [
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="qa_feedback",
|
||||||
|
source_id=701,
|
||||||
|
object_type="qa_record",
|
||||||
|
object_id=301,
|
||||||
|
field_name="qa_feedback",
|
||||||
|
review_status="accepted",
|
||||||
|
created_at=datetime(2025, 1, 14, 9, 0, 0),
|
||||||
|
reviewed_at=datetime(2025, 1, 14, 9, 20, 0),
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="signal_feedback",
|
||||||
|
source_id=702,
|
||||||
|
object_type="signal",
|
||||||
|
object_id=401,
|
||||||
|
field_name="strategic_classification",
|
||||||
|
review_status="pending",
|
||||||
|
created_at=datetime(2025, 1, 14, 10, 0, 0),
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="task_breakdown",
|
||||||
|
source_id=703,
|
||||||
|
object_type="task",
|
||||||
|
object_id=501,
|
||||||
|
field_name="task_breakdown",
|
||||||
|
review_status="accepted",
|
||||||
|
auto_assigned=True,
|
||||||
|
created_at=datetime(2025, 1, 13, 8, 0, 0),
|
||||||
|
reviewed_at=datetime(2025, 1, 13, 8, 25, 0),
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="task_override",
|
||||||
|
source_id=704,
|
||||||
|
object_type="task",
|
||||||
|
object_id=501,
|
||||||
|
field_name="priority",
|
||||||
|
review_status="reviewed",
|
||||||
|
created_at=datetime(2025, 1, 13, 9, 0, 0),
|
||||||
|
reviewed_at=datetime(2025, 1, 13, 9, 30, 0),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
db_session.add_all(records)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
service = FeedbackCenterService(db_session)
|
||||||
|
summary_qa = await service.summary(tenant_id=1, project_id=11, window_days=30, source_group="qa")
|
||||||
|
|
||||||
|
assert summary_qa["total"] == 1
|
||||||
|
assert summary_qa["by_source_type"] == {"qa_feedback": 1}
|
||||||
|
assert summary_qa["metrics"]["total_qa_feedback_count"] == 1
|
||||||
|
assert summary_qa["metrics"]["total_task_breakdown_count"] == 0
|
||||||
|
assert summary_qa["metrics"]["total_task_override_count"] == 0
|
||||||
|
assert summary_qa["metrics"]["total_signal_feedback_count"] == 0
|
||||||
|
|
||||||
|
trend = summary_qa["trend"]
|
||||||
|
assert trend["window_days"] == 30
|
||||||
|
assert len(trend["daily"]) == 30
|
||||||
|
jan14 = next(item for item in trend["daily"] if item["date"] == "2025-01-14")
|
||||||
|
assert jan14["total_records"] == 1
|
||||||
|
assert jan14["qa_feedback_count"] == 1
|
||||||
|
assert jan14["task_breakdown_count"] == 0
|
||||||
|
|
||||||
|
summary_task = await service.summary(tenant_id=1, project_id=11, window_days=7, source_group="task")
|
||||||
|
assert summary_task["total"] == 2
|
||||||
|
assert summary_task["by_source_type"]["task_breakdown"] == 1
|
||||||
|
assert summary_task["by_source_type"]["task_override"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_summary_supports_30_day_task_scope(db_session, monkeypatch) -> None:
|
||||||
|
fixed_now = datetime(2025, 1, 15, 12, 0, 0)
|
||||||
|
monkeypatch.setattr(feedback_center_service_module, "now_cn_naive", lambda: fixed_now)
|
||||||
|
|
||||||
|
db_session.add_all(
|
||||||
|
[
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="task_breakdown",
|
||||||
|
source_id=701,
|
||||||
|
object_type="task",
|
||||||
|
object_id=300,
|
||||||
|
field_name="task_breakdown",
|
||||||
|
review_status="accepted",
|
||||||
|
auto_assigned=True,
|
||||||
|
created_at=datetime(2025, 1, 15, 8, 0, 0),
|
||||||
|
reviewed_at=datetime(2025, 1, 15, 8, 30, 0),
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="task_breakdown",
|
||||||
|
source_id=702,
|
||||||
|
object_type="task",
|
||||||
|
object_id=301,
|
||||||
|
field_name="task_breakdown",
|
||||||
|
review_status="rejected",
|
||||||
|
auto_assigned=False,
|
||||||
|
created_at=datetime(2025, 1, 14, 8, 0, 0),
|
||||||
|
reviewed_at=datetime(2025, 1, 14, 8, 40, 0),
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="task_override",
|
||||||
|
source_id=703,
|
||||||
|
object_type="task",
|
||||||
|
object_id=300,
|
||||||
|
field_name="priority",
|
||||||
|
review_status="reviewed",
|
||||||
|
created_at=datetime(2025, 1, 14, 9, 0, 0),
|
||||||
|
reviewed_at=datetime(2025, 1, 14, 9, 50, 0),
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="qa_feedback",
|
||||||
|
source_id=704,
|
||||||
|
object_type="qa_record",
|
||||||
|
object_id=401,
|
||||||
|
field_name="qa_feedback",
|
||||||
|
review_status="accepted",
|
||||||
|
created_at=datetime(2025, 1, 13, 9, 0, 0),
|
||||||
|
reviewed_at=datetime(2025, 1, 13, 9, 20, 0),
|
||||||
|
),
|
||||||
|
FeedbackCenterRecord(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
source_type="signal_feedback",
|
||||||
|
source_id=705,
|
||||||
|
object_type="signal",
|
||||||
|
object_id=501,
|
||||||
|
field_name="strategic_classification",
|
||||||
|
review_status="pending",
|
||||||
|
created_at=datetime(2025, 1, 12, 9, 0, 0),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
summary = await FeedbackCenterService(db_session).summary(
|
||||||
|
tenant_id=1,
|
||||||
|
project_id=11,
|
||||||
|
window_days=30,
|
||||||
|
source_group="task",
|
||||||
|
)
|
||||||
|
assert summary["source_group"] == "task"
|
||||||
|
trend = summary["trend"]
|
||||||
|
|
||||||
|
assert trend["window_days"] == 30
|
||||||
|
assert len(trend["daily"]) == 30
|
||||||
|
assert trend["current"]["total_records"] == 3
|
||||||
|
assert trend["current"]["task_breakdown_count"] == 2
|
||||||
|
assert trend["current"]["task_override_count"] == 1
|
||||||
|
assert trend["current"]["qa_feedback_count"] == 0
|
||||||
|
assert trend["current"]["signal_feedback_count"] == 0
|
||||||
|
assert trend["current"]["accepted_rate_reviewed"] == pytest.approx(0.5)
|
||||||
|
assert trend["current"]["manual_override_rate"] == pytest.approx(0.5)
|
||||||
|
assert trend["current"]["auto_assigned_rate"] == pytest.approx(0.5)
|
||||||
|
assert trend["previous"]["total_records"] == 0
|
||||||
|
assert summary["by_source_type"] == {"task_breakdown": 2, "task_override": 1}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.config.ai_prompts import build_default_role_prompt_bundle
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_role_prompt_bundle_uses_pmflow_pmo_voice() -> None:
|
||||||
|
bundle = build_default_role_prompt_bundle()
|
||||||
|
roles = {str(item.get("role_key") or ""): item for item in bundle["roles"]}
|
||||||
|
|
||||||
|
dispatcher = roles.get("pm_dispatcher", {})
|
||||||
|
assert "PMFlow 的 PMO 协作中枢" in str(dispatcher.get("system_prompt") or "")
|
||||||
|
assert "查重、复用/合并、立项评审" in str(dispatcher.get("system_prompt") or "")
|
||||||
|
assert "MVP" in str(dispatcher.get("system_prompt") or "")
|
||||||
|
|
||||||
|
architect = roles.get("tech_architect", {})
|
||||||
|
assert "可行性、依赖、风险边界" in str(architect.get("system_prompt") or "")
|
||||||
|
assert "最小可行步骤" in str(architect.get("system_prompt") or "")
|
||||||
|
|
||||||
|
coach = roles.get("delivery_coach", {})
|
||||||
|
assert "节奏、排期、负责人、推进动作" in str(coach.get("system_prompt") or "")
|
||||||
|
assert "不超过 3 条" in str(coach.get("system_prompt") or "")
|
||||||
|
|
@ -0,0 +1,278 @@
|
||||||
|
import { apiRequest } from "./base";
|
||||||
|
|
||||||
|
type ApiEnvelope<T> = { code: number; message: string; data: T };
|
||||||
|
|
||||||
|
function unwrap<T>(raw: ApiEnvelope<T> | T): T {
|
||||||
|
if (raw && typeof raw === "object" && "code" in (raw as ApiEnvelope<T>) && "data" in (raw as ApiEnvelope<T>)) {
|
||||||
|
return (raw as ApiEnvelope<T>).data;
|
||||||
|
}
|
||||||
|
return raw as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FeedbackCenterSourceType = "task_breakdown" | "task_override" | "qa_feedback" | "signal_feedback";
|
||||||
|
export type FeedbackCenterReviewStatus = "pending" | "reviewed" | "accepted" | "rejected";
|
||||||
|
export type FeedbackCenterSection = "overview" | "metrics" | "trend" | "task" | "qa" | "signal";
|
||||||
|
export type FeedbackCenterTrendWindowDays = 7 | 30;
|
||||||
|
export type FeedbackCenterSourceGroup = "all" | "task" | "qa" | "signal";
|
||||||
|
|
||||||
|
export interface FeedbackReasonCandidate {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
description?: string | null;
|
||||||
|
confidence?: number | null;
|
||||||
|
source_type?: string | null;
|
||||||
|
field_name?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackCenterRecordRevision {
|
||||||
|
id: number;
|
||||||
|
record_id: number;
|
||||||
|
before_snapshot: unknown;
|
||||||
|
after_snapshot: unknown;
|
||||||
|
reviewed_by?: number | null;
|
||||||
|
reviewed_at?: string | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackCenterRecord {
|
||||||
|
id: number;
|
||||||
|
tenant_id: number;
|
||||||
|
project_id?: number | null;
|
||||||
|
source_type: FeedbackCenterSourceType | string;
|
||||||
|
source_id: number;
|
||||||
|
object_type: string;
|
||||||
|
object_id: number;
|
||||||
|
field_name: string;
|
||||||
|
source_title?: string | null;
|
||||||
|
object_title?: string | null;
|
||||||
|
before_value?: unknown;
|
||||||
|
after_value?: unknown;
|
||||||
|
ai_snapshot?: unknown;
|
||||||
|
ai_confidence?: number | null;
|
||||||
|
reason_candidates: FeedbackReasonCandidate[];
|
||||||
|
reason_code?: string | null;
|
||||||
|
reason_text?: string | null;
|
||||||
|
review_status: FeedbackCenterReviewStatus | string;
|
||||||
|
reviewed_by?: number | null;
|
||||||
|
reviewed_at?: string | null;
|
||||||
|
auto_assigned: boolean;
|
||||||
|
created_at?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
revisions?: FeedbackCenterRecordRevision[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackCenterSummaryReason {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackCenterSummaryMetrics {
|
||||||
|
accepted_rate_reviewed: number;
|
||||||
|
manual_override_rate: number;
|
||||||
|
rework_rate: number;
|
||||||
|
auto_assigned_rate: number;
|
||||||
|
avg_review_latency_minutes: number;
|
||||||
|
total_task_breakdown_count: number;
|
||||||
|
total_task_override_count: number;
|
||||||
|
total_qa_feedback_count: number;
|
||||||
|
total_signal_feedback_count: number;
|
||||||
|
total_revision_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackCenterTrendDaily {
|
||||||
|
date: string;
|
||||||
|
total_records: number;
|
||||||
|
accepted_count: number;
|
||||||
|
rejected_count: number;
|
||||||
|
reviewed_count: number;
|
||||||
|
pending_count: number;
|
||||||
|
task_breakdown_count: number;
|
||||||
|
task_override_count: number;
|
||||||
|
qa_feedback_count: number;
|
||||||
|
signal_feedback_count: number;
|
||||||
|
auto_assigned_count: number;
|
||||||
|
override_count: number;
|
||||||
|
rework_count: number;
|
||||||
|
accepted_rate_reviewed: number;
|
||||||
|
manual_override_rate: number;
|
||||||
|
rework_rate: number;
|
||||||
|
auto_assigned_rate: number;
|
||||||
|
avg_review_latency_minutes: number;
|
||||||
|
revision_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackCenterTrendWindow {
|
||||||
|
total_records: number;
|
||||||
|
accepted_count: number;
|
||||||
|
rejected_count: number;
|
||||||
|
reviewed_count: number;
|
||||||
|
pending_count: number;
|
||||||
|
task_breakdown_count: number;
|
||||||
|
task_override_count: number;
|
||||||
|
qa_feedback_count: number;
|
||||||
|
signal_feedback_count: number;
|
||||||
|
auto_assigned_count: number;
|
||||||
|
override_count: number;
|
||||||
|
rework_count: number;
|
||||||
|
accepted_rate_reviewed: number;
|
||||||
|
manual_override_rate: number;
|
||||||
|
rework_rate: number;
|
||||||
|
auto_assigned_rate: number;
|
||||||
|
avg_review_latency_minutes: number;
|
||||||
|
revision_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackCenterTrendDelta {
|
||||||
|
total_records_delta: number;
|
||||||
|
accepted_rate_reviewed_delta: number;
|
||||||
|
manual_override_rate_delta: number;
|
||||||
|
rework_rate_delta: number;
|
||||||
|
auto_assigned_rate_delta: number;
|
||||||
|
avg_review_latency_minutes_delta: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackCenterTrend {
|
||||||
|
window_days: number;
|
||||||
|
current_window_start: string;
|
||||||
|
previous_window_start: string;
|
||||||
|
daily: FeedbackCenterTrendDaily[];
|
||||||
|
current: FeedbackCenterTrendWindow;
|
||||||
|
previous: FeedbackCenterTrendWindow;
|
||||||
|
deltas: FeedbackCenterTrendDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackCenterSummary {
|
||||||
|
tenant_id: number;
|
||||||
|
project_id?: number | null;
|
||||||
|
source_group: FeedbackCenterSourceGroup | string;
|
||||||
|
total: number;
|
||||||
|
backlog_count: number;
|
||||||
|
pending_count: number;
|
||||||
|
reviewed_count: number;
|
||||||
|
accepted_count: number;
|
||||||
|
rejected_count: number;
|
||||||
|
accepted_rate: number;
|
||||||
|
avg_confidence: number;
|
||||||
|
by_status: Record<string, number>;
|
||||||
|
by_source_type: Record<string, number>;
|
||||||
|
top_reasons: FeedbackCenterSummaryReason[];
|
||||||
|
metrics: FeedbackCenterSummaryMetrics;
|
||||||
|
trend: FeedbackCenterTrend;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackCenterSummaryParams {
|
||||||
|
project_id?: number | string;
|
||||||
|
window_days?: FeedbackCenterTrendWindowDays;
|
||||||
|
source_group?: FeedbackCenterSourceGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackCenterListResponse {
|
||||||
|
items: FeedbackCenterRecord[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
has_more: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackCenterDetailResponse {
|
||||||
|
record: FeedbackCenterRecord;
|
||||||
|
revisions: FeedbackCenterRecordRevision[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackCenterReasonCatalogResponse {
|
||||||
|
items: FeedbackReasonCandidate[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackCenterListParams {
|
||||||
|
project_id?: number | string;
|
||||||
|
source_type?: string;
|
||||||
|
review_status?: string;
|
||||||
|
field_name?: string;
|
||||||
|
reason_code?: string;
|
||||||
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackCenterReviewPayload {
|
||||||
|
review_status: Exclude<FeedbackCenterReviewStatus, "pending">;
|
||||||
|
reason_code?: string;
|
||||||
|
reason_text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toQuery(params: Record<string, string | number | undefined | null>): string {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value === undefined || value === null || value === "") return;
|
||||||
|
query.set(key, String(value));
|
||||||
|
});
|
||||||
|
const text = query.toString();
|
||||||
|
return text ? `?${text}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFeedbackCenterRecord(value: unknown): value is FeedbackCenterRecord {
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
const raw = value as Partial<FeedbackCenterRecord>;
|
||||||
|
return typeof raw.id === "number" && typeof raw.source_type === "string" && typeof raw.review_status === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeListResponse(raw: FeedbackCenterListResponse): FeedbackCenterListResponse {
|
||||||
|
const safeItems = Array.isArray(raw.items) ? raw.items.filter(isFeedbackCenterRecord) : [];
|
||||||
|
return {
|
||||||
|
items: safeItems,
|
||||||
|
total: Number(raw.total || safeItems.length),
|
||||||
|
limit: Number(raw.limit || 40),
|
||||||
|
offset: Number(raw.offset || 0),
|
||||||
|
has_more: Boolean(raw.has_more),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const feedbackCenterApi = {
|
||||||
|
async getSummary(params: FeedbackCenterSummaryParams = {}): Promise<FeedbackCenterSummary> {
|
||||||
|
const raw = await apiRequest<ApiEnvelope<FeedbackCenterSummary>>(
|
||||||
|
`/ai-steward/feedback-center/summary${toQuery({
|
||||||
|
project_id: params.project_id,
|
||||||
|
window_days: params.window_days,
|
||||||
|
source_group: params.source_group,
|
||||||
|
})}`
|
||||||
|
);
|
||||||
|
return unwrap(raw);
|
||||||
|
},
|
||||||
|
async listItems(params: FeedbackCenterListParams = {}): Promise<FeedbackCenterListResponse> {
|
||||||
|
const queryParams: Record<string, string | number | undefined | null> = {
|
||||||
|
project_id: params.project_id,
|
||||||
|
source_type: params.source_type,
|
||||||
|
review_status: params.review_status,
|
||||||
|
field_name: params.field_name,
|
||||||
|
reason_code: params.reason_code,
|
||||||
|
search: params.search,
|
||||||
|
limit: params.limit,
|
||||||
|
offset: params.offset,
|
||||||
|
};
|
||||||
|
const raw = await apiRequest<ApiEnvelope<FeedbackCenterListResponse>>(
|
||||||
|
`/ai-steward/feedback-center/items${toQuery(queryParams)}`
|
||||||
|
);
|
||||||
|
return normalizeListResponse(unwrap(raw));
|
||||||
|
},
|
||||||
|
async getDetail(recordId: number, projectId?: number | string): Promise<FeedbackCenterDetailResponse> {
|
||||||
|
const raw = await apiRequest<ApiEnvelope<FeedbackCenterDetailResponse>>(
|
||||||
|
`/ai-steward/feedback-center/items/${recordId}${toQuery({ project_id: projectId })}`
|
||||||
|
);
|
||||||
|
return unwrap(raw);
|
||||||
|
},
|
||||||
|
async reviewItem(recordId: number, payload: FeedbackCenterReviewPayload): Promise<FeedbackCenterRecord> {
|
||||||
|
const raw = await apiRequest<ApiEnvelope<FeedbackCenterRecord>>(`/ai-steward/feedback-center/items/${recordId}/review`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
return unwrap(raw);
|
||||||
|
},
|
||||||
|
async getReasonCatalog(sourceType?: string, fieldName?: string): Promise<FeedbackCenterReasonCatalogResponse> {
|
||||||
|
const raw = await apiRequest<ApiEnvelope<FeedbackCenterReasonCatalogResponse>>(
|
||||||
|
`/ai-steward/feedback-center/reason-catalog${toQuery({ source_type: sourceType, field_name: fieldName })}`
|
||||||
|
);
|
||||||
|
return unwrap(raw);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { feedbackCenterApi } from "@/api/feedbackCenter";
|
||||||
|
import type { SSTCTask } from "@/api/sstc";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
|
interface FeedbackSummaryCardProps {
|
||||||
|
projectId: string;
|
||||||
|
compact?: boolean;
|
||||||
|
tasks?: SSTCTask[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAIBreakdown(task: SSTCTask): boolean {
|
||||||
|
return !!task.ai_breakdown_payload && typeof task.ai_breakdown_payload === "object";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOverrideFields(task: SSTCTask): string[] {
|
||||||
|
const overrides = task.manual_overrides;
|
||||||
|
if (!overrides || typeof overrides !== "object") return [];
|
||||||
|
return Object.keys(overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedbackSummaryCard({ projectId, compact = false, tasks = [] }: FeedbackSummaryCardProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const numericProjectId = Number(projectId);
|
||||||
|
const localSummary = useMemo(() => {
|
||||||
|
const aiTasks = tasks.filter((task) => hasAIBreakdown(task));
|
||||||
|
if (!aiTasks.length) {
|
||||||
|
return {
|
||||||
|
pending: 0,
|
||||||
|
acceptedRate: 0,
|
||||||
|
topReasons: [] as Array<{ label: string; count: number }>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let accepted = 0;
|
||||||
|
let rejected = 0;
|
||||||
|
let pending = 0;
|
||||||
|
const reasonCountMap = new Map<string, number>();
|
||||||
|
for (const task of aiTasks) {
|
||||||
|
const overrides = getOverrideFields(task);
|
||||||
|
if (overrides.length > 0) {
|
||||||
|
rejected += 1;
|
||||||
|
overrides.forEach((field) => {
|
||||||
|
reasonCountMap.set(field, Number(reasonCountMap.get(field) || 0) + 1);
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (task.auto_assigned) {
|
||||||
|
accepted += 1;
|
||||||
|
} else {
|
||||||
|
pending += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const reviewed = accepted + rejected;
|
||||||
|
return {
|
||||||
|
pending,
|
||||||
|
acceptedRate: reviewed > 0 ? accepted / reviewed : 0,
|
||||||
|
topReasons: Array.from(reasonCountMap.entries())
|
||||||
|
.map(([label, count]) => ({ label, count }))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 3),
|
||||||
|
};
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
|
const summaryQuery = useQuery({
|
||||||
|
queryKey: ["feedback-center", "summary", numericProjectId],
|
||||||
|
queryFn: () => feedbackCenterApi.getSummary({ project_id: numericProjectId }),
|
||||||
|
enabled: Number.isFinite(numericProjectId) && numericProjectId > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pendingCount = localSummary.pending > 0 ? localSummary.pending : summaryQuery.data?.backlog_count ?? 0;
|
||||||
|
const acceptedRate = localSummary.acceptedRate > 0 ? localSummary.acceptedRate : Number(summaryQuery.data?.accepted_rate || 0);
|
||||||
|
const topReasons = localSummary.topReasons.length
|
||||||
|
? localSummary.topReasons
|
||||||
|
: (summaryQuery.data?.top_reasons || []).map((item) => ({ label: item.label, count: item.count }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="rounded-2xl border border-slate-200/80 bg-[linear-gradient(135deg,rgba(248,250,252,0.96),rgba(255,255,255,0.98))] shadow-[0_10px_30px_rgba(15,23,42,0.06)]">
|
||||||
|
<CardContent className={compact ? "p-4" : "p-5"}>
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Learning Signal</p>
|
||||||
|
<h3 className={`${compact ? "text-base" : "text-lg"} mt-1 font-semibold text-slate-900`}>统一反馈中心摘要</h3>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
这里不做复核,只告诉你当前自动化链路里哪些地方最常被人工纠正。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||||
|
onClick={() => navigate(`/ai-steward?tab=feedback-center§ion=task&project_id=${numericProjectId}`)}
|
||||||
|
>
|
||||||
|
打开反馈中心
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white px-4 py-3">
|
||||||
|
<p className="text-xs uppercase tracking-[0.16em] text-slate-400">待复核</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold text-slate-900">{pendingCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white px-4 py-3">
|
||||||
|
<p className="text-xs uppercase tracking-[0.16em] text-slate-400">采纳率</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold text-slate-900">{Math.round(acceptedRate * 100)}%</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white px-4 py-3">
|
||||||
|
<p className="text-xs uppercase tracking-[0.16em] text-slate-400">高频原因</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{topReasons.slice(0, 3).map((item) => (
|
||||||
|
<Badge key={item.label} variant="outline" className="border-slate-200 bg-slate-50 text-[10px] text-slate-600">
|
||||||
|
{item.label} · {item.count}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{topReasons.length === 0 && <span className="text-xs text-slate-500">暂无</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import type { User } from "@/api/users";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { FeedbackSummaryCard } from "@/components/sstc/FeedbackSummaryCard";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -26,6 +27,7 @@ interface PatchTaskStatusMutationLike {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SSTCExecutionBoardPanelProps {
|
interface SSTCExecutionBoardPanelProps {
|
||||||
|
projectId: string;
|
||||||
stageLocked: boolean;
|
stageLocked: boolean;
|
||||||
projectUsers: User[];
|
projectUsers: User[];
|
||||||
optimizeFeedback: string;
|
optimizeFeedback: string;
|
||||||
|
|
@ -62,6 +64,7 @@ function getOverrideFields(task: SSTCTask): string[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SSTCExecutionBoardPanel({
|
export function SSTCExecutionBoardPanel({
|
||||||
|
projectId,
|
||||||
stageLocked,
|
stageLocked,
|
||||||
projectUsers,
|
projectUsers,
|
||||||
optimizeFeedback,
|
optimizeFeedback,
|
||||||
|
|
@ -78,6 +81,10 @@ export function SSTCExecutionBoardPanel({
|
||||||
);
|
);
|
||||||
const detailBreakdown = detailTask ? getTaskBreakdownObject(detailTask) : null;
|
const detailBreakdown = detailTask ? getTaskBreakdownObject(detailTask) : null;
|
||||||
const detailOverrides = detailTask ? getOverrideFields(detailTask) : [];
|
const detailOverrides = detailTask ? getOverrideFields(detailTask) : [];
|
||||||
|
const laneTasks = useMemo(
|
||||||
|
() => EXECUTION_LANES.flatMap((lane) => executionLanes[lane.key] || []),
|
||||||
|
[executionLanes]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -121,6 +128,9 @@ export function SSTCExecutionBoardPanel({
|
||||||
{optimizeFeedback}
|
{optimizeFeedback}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<div className="mb-3">
|
||||||
|
<FeedbackSummaryCard projectId={projectId} tasks={laneTasks} />
|
||||||
|
</div>
|
||||||
<div className="-mx-1 overflow-x-auto pb-2 [-webkit-overflow-scrolling:touch]">
|
<div className="-mx-1 overflow-x-auto pb-2 [-webkit-overflow-scrolling:touch]">
|
||||||
<div className="grid min-w-[920px] grid-cols-5 gap-3 px-1">
|
<div className="grid min-w-[920px] grid-cols-5 gap-3 px-1">
|
||||||
{EXECUTION_LANES.map((lane) => (
|
{EXECUTION_LANES.map((lane) => (
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import type {
|
||||||
SSTCTaskRecommendation,
|
SSTCTaskRecommendation,
|
||||||
} from "@/api/sstc";
|
} from "@/api/sstc";
|
||||||
import type { User } from "@/api/users";
|
import type { User } from "@/api/users";
|
||||||
|
import { FeedbackSummaryCard } from "@/components/sstc/FeedbackSummaryCard";
|
||||||
import { SSTCTaskCreateDialog } from "@/components/sstc/SSTCTaskCreateDialog";
|
import { SSTCTaskCreateDialog } from "@/components/sstc/SSTCTaskCreateDialog";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -247,6 +248,9 @@ export function SSTCTaskStagePanel({
|
||||||
{optimizeFeedback}
|
{optimizeFeedback}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="mb-3">
|
||||||
|
<FeedbackSummaryCard projectId={projectId} tasks={charterTasks} compact />
|
||||||
|
</div>
|
||||||
{selectedCount > 0 && (
|
{selectedCount > 0 && (
|
||||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2 rounded-xl bg-slate-50 px-3 py-2 text-xs text-slate-600">
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2 rounded-xl bg-slate-50 px-3 py-2 text-xs text-slate-600">
|
||||||
<span>已选择 {selectedCount} 项任务</span>
|
<span>已选择 {selectedCount} 项任务</span>
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@ export function SSTCWorkspace(props: SSTCWorkspaceViewProps) {
|
||||||
)}
|
)}
|
||||||
{props.activeStage === "execution" && (
|
{props.activeStage === "execution" && (
|
||||||
<SSTCExecutionBoardPanel
|
<SSTCExecutionBoardPanel
|
||||||
|
projectId={props.projectId}
|
||||||
stageLocked={orchestration.stageLocked}
|
stageLocked={orchestration.stageLocked}
|
||||||
projectUsers={orchestration.projectUsers}
|
projectUsers={orchestration.projectUsers}
|
||||||
optimizeFeedback={orchestration.optimizeFeedback}
|
optimizeFeedback={orchestration.optimizeFeedback}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate } 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 {
|
import {
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { QAHistoryPanel } from "@/components/ai-steward/QAHistoryPanel";
|
import { QAHistoryPanel } from "@/components/ai-steward/QAHistoryPanel";
|
||||||
|
import { parseUserRoles } from "@/api/users";
|
||||||
import {
|
import {
|
||||||
aiStewardApi,
|
aiStewardApi,
|
||||||
type LLMEngine,
|
type LLMEngine,
|
||||||
|
|
@ -21,11 +22,15 @@ import type {
|
||||||
Blocker,
|
Blocker,
|
||||||
CognitiveConflict,
|
CognitiveConflict,
|
||||||
DailyBriefing,
|
DailyBriefing,
|
||||||
|
FeedbackCenterSourceGroup,
|
||||||
|
FeedbackCenterSection,
|
||||||
|
FeedbackCenterTrendWindowDays,
|
||||||
EngineDataState,
|
EngineDataState,
|
||||||
EngineForm,
|
EngineForm,
|
||||||
EngineMessage,
|
EngineMessage,
|
||||||
EngineProviderPresets,
|
EngineProviderPresets,
|
||||||
ProjectFromTextResponse,
|
ProjectFromTextResponse,
|
||||||
|
AIStewardTab,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import FeatureOverviewCards from "./components/FeatureOverviewCards";
|
import FeatureOverviewCards from "./components/FeatureOverviewCards";
|
||||||
import BlockersTab from "./sections/BlockersTab";
|
import BlockersTab from "./sections/BlockersTab";
|
||||||
|
|
@ -33,12 +38,54 @@ import BriefingTab from "./sections/BriefingTab";
|
||||||
import ConflictsTab from "./sections/ConflictsTab";
|
import ConflictsTab from "./sections/ConflictsTab";
|
||||||
import CreateProjectTab from "./sections/CreateProjectTab";
|
import CreateProjectTab from "./sections/CreateProjectTab";
|
||||||
import EngineTab from "./sections/EngineTab";
|
import EngineTab from "./sections/EngineTab";
|
||||||
|
import FeedbackCenterTab from "./sections/FeedbackCenterTab";
|
||||||
import OverviewTab from "./sections/OverviewTab";
|
import OverviewTab from "./sections/OverviewTab";
|
||||||
import SignalsTab from "./sections/SignalsTab";
|
import SignalsTab from "./sections/SignalsTab";
|
||||||
|
|
||||||
|
const AIS_STEWARD_TABS: AIStewardTab[] = [
|
||||||
|
"overview",
|
||||||
|
"create",
|
||||||
|
"signals",
|
||||||
|
"conflicts",
|
||||||
|
"blockers",
|
||||||
|
"briefing",
|
||||||
|
"qa",
|
||||||
|
"feedback-center",
|
||||||
|
"engine",
|
||||||
|
];
|
||||||
|
|
||||||
|
const FEEDBACK_SECTIONS: FeedbackCenterSection[] = ["overview", "metrics", "trend", "task", "qa", "signal"];
|
||||||
|
|
||||||
|
function normalizeTab(value: string | null): AIStewardTab {
|
||||||
|
if (value && AIS_STEWARD_TABS.includes(value as AIStewardTab)) {
|
||||||
|
return value as AIStewardTab;
|
||||||
|
}
|
||||||
|
return "overview";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSection(value: string | null): FeedbackCenterSection {
|
||||||
|
if (value && FEEDBACK_SECTIONS.includes(value as FeedbackCenterSection)) {
|
||||||
|
return value as FeedbackCenterSection;
|
||||||
|
}
|
||||||
|
return "overview";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTrendWindow(value: string | null): FeedbackCenterTrendWindowDays {
|
||||||
|
return value === "30" ? 30 : 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTrendSourceGroup(value: string | null): FeedbackCenterSourceGroup {
|
||||||
|
if (value === "task" || value === "qa" || value === "signal") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return "all";
|
||||||
|
}
|
||||||
|
|
||||||
export default function AIStewardPage() {
|
export default function AIStewardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const canViewFeedbackCenter = parseUserRoles(user?.role).some((role) => role === "ADMIN" || role === "BOSS");
|
||||||
|
|
||||||
// Project creation state
|
// Project creation state
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
|
|
@ -47,7 +94,16 @@ export default function AIStewardPage() {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// AI Steward features state
|
// AI Steward features state
|
||||||
const [activeTab, setActiveTab] = useState("overview");
|
const [activeTab, setActiveTab] = useState<AIStewardTab>(() => normalizeTab(searchParams.get("tab")));
|
||||||
|
const [feedbackSection, setFeedbackSection] = useState<FeedbackCenterSection>(() =>
|
||||||
|
normalizeSection(searchParams.get("section"))
|
||||||
|
);
|
||||||
|
const [trendWindowDays, setTrendWindowDays] = useState<FeedbackCenterTrendWindowDays>(() =>
|
||||||
|
normalizeTrendWindow(searchParams.get("window"))
|
||||||
|
);
|
||||||
|
const [trendSourceGroup, setTrendSourceGroup] = useState<FeedbackCenterSourceGroup>(() =>
|
||||||
|
normalizeTrendSourceGroup(searchParams.get("scope"))
|
||||||
|
);
|
||||||
const [conflicts, setConflicts] = useState<CognitiveConflict[]>([]);
|
const [conflicts, setConflicts] = useState<CognitiveConflict[]>([]);
|
||||||
const [blockers, setBlockers] = useState<Blocker[]>([]);
|
const [blockers, setBlockers] = useState<Blocker[]>([]);
|
||||||
const [briefing, setBriefing] = useState<DailyBriefing | null>(null);
|
const [briefing, setBriefing] = useState<DailyBriefing | null>(null);
|
||||||
|
|
@ -422,8 +478,68 @@ export default function AIStewardPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const syncUrlParams = (
|
||||||
|
tab: AIStewardTab,
|
||||||
|
section: FeedbackCenterSection,
|
||||||
|
nextWindowDays: FeedbackCenterTrendWindowDays = trendWindowDays,
|
||||||
|
nextSourceGroup: FeedbackCenterSourceGroup = trendSourceGroup
|
||||||
|
) => {
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
next.set("tab", tab);
|
||||||
|
next.set("section", section);
|
||||||
|
next.set("window", String(nextWindowDays));
|
||||||
|
next.set("scope", nextSourceGroup);
|
||||||
|
setSearchParams(next, { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTabChange = (value: string) => {
|
||||||
|
const nextTab = normalizeTab(value);
|
||||||
|
if (nextTab === "feedback-center" && !canViewFeedbackCenter) {
|
||||||
|
setActiveTab("overview");
|
||||||
|
syncUrlParams("overview", "overview");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActiveTab(nextTab);
|
||||||
|
syncUrlParams(nextTab, feedbackSection);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFeedbackSectionChange = (nextSection: FeedbackCenterSection) => {
|
||||||
|
setFeedbackSection(nextSection);
|
||||||
|
syncUrlParams(activeTab, nextSection);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTrendWindowDaysChange = (nextWindowDays: FeedbackCenterTrendWindowDays) => {
|
||||||
|
setTrendWindowDays(nextWindowDays);
|
||||||
|
syncUrlParams(activeTab, feedbackSection, nextWindowDays, trendSourceGroup);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTrendSourceGroupChange = (nextSourceGroup: FeedbackCenterSourceGroup) => {
|
||||||
|
setTrendSourceGroup(nextSourceGroup);
|
||||||
|
syncUrlParams(activeTab, feedbackSection, trendWindowDays, nextSourceGroup);
|
||||||
|
};
|
||||||
|
|
||||||
// ==================== Initial Load ====================
|
// ==================== Initial Load ====================
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const urlTab = normalizeTab(searchParams.get("tab"));
|
||||||
|
const urlSection = normalizeSection(searchParams.get("section"));
|
||||||
|
const safeTab = urlTab === "feedback-center" && !canViewFeedbackCenter ? "overview" : urlTab;
|
||||||
|
if (safeTab !== activeTab) {
|
||||||
|
setActiveTab(safeTab);
|
||||||
|
}
|
||||||
|
if (urlSection !== feedbackSection) {
|
||||||
|
setFeedbackSection(urlSection);
|
||||||
|
}
|
||||||
|
const urlWindow = normalizeTrendWindow(searchParams.get("window"));
|
||||||
|
if (urlWindow !== trendWindowDays) {
|
||||||
|
setTrendWindowDays(urlWindow);
|
||||||
|
}
|
||||||
|
const urlSourceGroup = normalizeTrendSourceGroup(searchParams.get("scope"));
|
||||||
|
if (urlSourceGroup !== trendSourceGroup) {
|
||||||
|
setTrendSourceGroup(urlSourceGroup);
|
||||||
|
}
|
||||||
|
}, [searchParams, activeTab, feedbackSection, trendWindowDays, trendSourceGroup, canViewFeedbackCenter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === "conflicts") loadConflicts();
|
if (activeTab === "conflicts") loadConflicts();
|
||||||
if (activeTab === "blockers") loadBlockers();
|
if (activeTab === "blockers") loadBlockers();
|
||||||
|
|
@ -452,12 +568,12 @@ export default function AIStewardPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FeatureOverviewCards onSelectTab={(tab) => setActiveTab(tab)} />
|
<FeatureOverviewCards onSelectTab={(tab) => handleTabChange(tab)} />
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||||
<TabsList className="flex w-full min-w-0 flex-nowrap gap-1 overflow-x-auto rounded-lg p-1 [-webkit-overflow-scrolling:touch]">
|
<TabsList className="flex w-full min-w-0 flex-nowrap gap-1 overflow-x-auto rounded-lg p-1 [-webkit-overflow-scrolling:touch]">
|
||||||
<TabsTrigger value="overview" className="shrink-0 whitespace-nowrap">总览</TabsTrigger>
|
<TabsTrigger value="overview" className="shrink-0 whitespace-nowrap">总览</TabsTrigger>
|
||||||
<TabsTrigger value="create" className="shrink-0 whitespace-nowrap">零配置创建</TabsTrigger>
|
<TabsTrigger value="create" className="shrink-0 whitespace-nowrap">零配置创建</TabsTrigger>
|
||||||
|
|
@ -466,6 +582,9 @@ export default function AIStewardPage() {
|
||||||
<TabsTrigger value="blockers" className="shrink-0 whitespace-nowrap">阻塞检测</TabsTrigger>
|
<TabsTrigger value="blockers" className="shrink-0 whitespace-nowrap">阻塞检测</TabsTrigger>
|
||||||
<TabsTrigger value="briefing" className="shrink-0 whitespace-nowrap">每日简报</TabsTrigger>
|
<TabsTrigger value="briefing" className="shrink-0 whitespace-nowrap">每日简报</TabsTrigger>
|
||||||
<TabsTrigger value="qa" className="shrink-0 whitespace-nowrap">问答记录</TabsTrigger>
|
<TabsTrigger value="qa" className="shrink-0 whitespace-nowrap">问答记录</TabsTrigger>
|
||||||
|
{canViewFeedbackCenter && (
|
||||||
|
<TabsTrigger value="feedback-center" className="shrink-0 whitespace-nowrap">反馈中心</TabsTrigger>
|
||||||
|
)}
|
||||||
<TabsTrigger value="engine" className="shrink-0 whitespace-nowrap">引擎配置</TabsTrigger>
|
<TabsTrigger value="engine" className="shrink-0 whitespace-nowrap">引擎配置</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
|
@ -511,6 +630,18 @@ export default function AIStewardPage() {
|
||||||
<QAHistoryPanel />
|
<QAHistoryPanel />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{canViewFeedbackCenter && (
|
||||||
|
<FeedbackCenterTab
|
||||||
|
section={feedbackSection}
|
||||||
|
projectId={Number(searchParams.get("project_id") || "0") || null}
|
||||||
|
onSectionChange={handleFeedbackSectionChange}
|
||||||
|
trendWindowDays={trendWindowDays}
|
||||||
|
trendSourceGroup={trendSourceGroup}
|
||||||
|
onTrendWindowDaysChange={handleTrendWindowDaysChange}
|
||||||
|
onTrendSourceGroupChange={handleTrendSourceGroupChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<EngineTab
|
<EngineTab
|
||||||
featureLoading={featureLoading}
|
featureLoading={featureLoading}
|
||||||
engineData={engineData}
|
engineData={engineData}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
import type { FeedbackCenterRecord, FeedbackCenterRecordRevision } from "@/api/feedbackCenter";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
interface FeedbackDetailProps {
|
||||||
|
record: FeedbackCenterRecord | null;
|
||||||
|
revisions: FeedbackCenterRecordRevision[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatJson(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return "—";
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffCard(title: string, value: unknown, tone: "before" | "after") {
|
||||||
|
const toneClass =
|
||||||
|
tone === "before"
|
||||||
|
? "border-rose-100 bg-rose-50/70 text-rose-900"
|
||||||
|
: "border-emerald-100 bg-emerald-50/70 text-emerald-900";
|
||||||
|
return (
|
||||||
|
<div className={`rounded-2xl border p-4 ${toneClass}`}>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] opacity-70">{title}</p>
|
||||||
|
<pre className="mt-3 overflow-x-auto whitespace-pre-wrap break-words text-xs leading-6">{formatJson(value)}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedbackDetail({ record, revisions }: FeedbackDetailProps) {
|
||||||
|
if (!record) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50/80 p-5 text-sm text-slate-500">
|
||||||
|
从左侧选择一条记录,这里会展示 AI 原始结果、人工修正 diff 和修订历史。
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Diff View</p>
|
||||||
|
<h3 className="mt-1 text-lg font-semibold text-slate-900">
|
||||||
|
{record.object_title || record.source_title || `记录 #${record.id}`}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
{record.source_type} · 字段 {record.field_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge className="border-slate-200 bg-slate-100 text-slate-700">状态 {record.review_status}</Badge>
|
||||||
|
{record.auto_assigned && (
|
||||||
|
<Badge className="border-emerald-200 bg-emerald-50 text-emerald-700">AI 自动指派</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-400">Meta</p>
|
||||||
|
<div className="mt-3 grid gap-3 text-sm text-slate-700 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">项目</p>
|
||||||
|
<p>{record.project_id ?? "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">对象</p>
|
||||||
|
<p>{record.object_type} #{record.object_id}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">原因码</p>
|
||||||
|
<p>{record.reason_code || "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">置信度</p>
|
||||||
|
<p>{record.ai_confidence !== null && record.ai_confidence !== undefined ? `${Math.round(Number(record.ai_confidence) * 100)}%` : "—"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-400">AI Snapshot</p>
|
||||||
|
<pre className="mt-3 max-h-[210px] overflow-auto whitespace-pre-wrap break-words text-xs leading-6 text-slate-700">
|
||||||
|
{formatJson(record.ai_snapshot)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
|
{diffCard("AI Before", record.before_value, "before")}
|
||||||
|
{diffCard("Human After", record.after_value, "after")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-400">Revisions</p>
|
||||||
|
<span className="text-xs text-slate-500">{revisions.length} 条</span>
|
||||||
|
</div>
|
||||||
|
{!revisions.length && <p className="mt-3 text-sm text-slate-500">暂无修订历史。</p>}
|
||||||
|
{!!revisions.length && (
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{revisions.map((revision) => (
|
||||||
|
<div key={revision.id} className="rounded-xl border border-slate-200 bg-white p-3">
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
修订 #{revision.id}
|
||||||
|
{revision.reviewed_at ? ` · ${revision.reviewed_at}` : ""}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 grid gap-3 md:grid-cols-2">
|
||||||
|
<pre className="overflow-x-auto whitespace-pre-wrap break-words rounded-lg bg-slate-50 p-2 text-[11px] leading-5 text-slate-700">
|
||||||
|
{formatJson(revision.before_snapshot)}
|
||||||
|
</pre>
|
||||||
|
<pre className="overflow-x-auto whitespace-pre-wrap break-words rounded-lg bg-slate-50 p-2 text-[11px] leading-5 text-slate-700">
|
||||||
|
{formatJson(revision.after_snapshot)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
import type { FeedbackCenterRecord } from "@/api/feedbackCenter";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
interface FeedbackListProps {
|
||||||
|
items: FeedbackCenterRecord[];
|
||||||
|
selectedId?: number | null;
|
||||||
|
search: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
sourceType: string;
|
||||||
|
reviewStatus: string;
|
||||||
|
onSourceTypeChange: (value: string) => void;
|
||||||
|
onReviewStatusChange: (value: string) => void;
|
||||||
|
onSelect: (item: FeedbackCenterRecord) => void;
|
||||||
|
total: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(status: string): string {
|
||||||
|
if (status === "accepted") return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||||
|
if (status === "rejected") return "border-rose-200 bg-rose-50 text-rose-700";
|
||||||
|
if (status === "reviewed") return "border-sky-200 bg-sky-50 text-sky-700";
|
||||||
|
return "border-amber-200 bg-amber-50 text-amber-700";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceLabel(sourceType: string): string {
|
||||||
|
if (sourceType === "task_breakdown" || sourceType === "task_override") return "任务";
|
||||||
|
if (sourceType === "qa_feedback") return "QA";
|
||||||
|
if (sourceType === "signal_feedback") return "信号";
|
||||||
|
return sourceType || "未知";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedbackList({
|
||||||
|
items,
|
||||||
|
selectedId,
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
|
sourceType,
|
||||||
|
reviewStatus,
|
||||||
|
onSourceTypeChange,
|
||||||
|
onReviewStatusChange,
|
||||||
|
onSelect,
|
||||||
|
total,
|
||||||
|
isLoading,
|
||||||
|
}: FeedbackListProps) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<div className="border-b border-slate-100 p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Queue</p>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900">待复核记录</h3>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => onSearchChange(event.target.value)}
|
||||||
|
placeholder="搜索任务标题、问题、信号..."
|
||||||
|
className="border-slate-200"
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<select
|
||||||
|
value={sourceType}
|
||||||
|
onChange={(event) => onSourceTypeChange(event.target.value)}
|
||||||
|
className="h-10 rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700"
|
||||||
|
>
|
||||||
|
<option value="">全部来源</option>
|
||||||
|
<option value="task_breakdown">任务拆解</option>
|
||||||
|
<option value="task_override">任务覆盖</option>
|
||||||
|
<option value="qa_feedback">QA 反馈</option>
|
||||||
|
<option value="signal_feedback">信号反馈</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={reviewStatus}
|
||||||
|
onChange={(event) => onReviewStatusChange(event.target.value)}
|
||||||
|
className="h-10 rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700"
|
||||||
|
>
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="pending">待复核</option>
|
||||||
|
<option value="reviewed">已复核</option>
|
||||||
|
<option value="accepted">已采纳</option>
|
||||||
|
<option value="rejected">已拒绝</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500">共 {total} 条记录</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[720px] space-y-2 overflow-y-auto p-3">
|
||||||
|
{isLoading && <p className="px-2 py-4 text-sm text-slate-500">加载中...</p>}
|
||||||
|
{!isLoading && !items.length && <p className="px-2 py-6 text-sm text-slate-500">当前筛选下还没有反馈记录。</p>}
|
||||||
|
{items.map((item) => {
|
||||||
|
const selected = selectedId === item.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
className={`w-full rounded-xl border p-3 text-left transition ${
|
||||||
|
selected
|
||||||
|
? "border-slate-900 bg-slate-900 text-white shadow-lg"
|
||||||
|
: "border-slate-200 bg-slate-50/80 text-slate-800 hover:border-slate-300 hover:bg-white"
|
||||||
|
}`}
|
||||||
|
onClick={() => onSelect(item)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className={`line-clamp-2 text-sm font-semibold ${selected ? "text-white" : "text-slate-900"}`}>
|
||||||
|
{item.object_title || item.source_title || `${sourceLabel(item.source_type)} #${item.object_id}`}
|
||||||
|
</p>
|
||||||
|
<p className={`mt-1 text-xs ${selected ? "text-slate-200" : "text-slate-500"}`}>
|
||||||
|
{sourceLabel(item.source_type)} · 字段 {item.field_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge className={selected ? "border-white/20 bg-white/10 text-white" : statusTone(item.review_status)}>
|
||||||
|
{item.review_status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className={`mt-3 flex flex-wrap gap-2 text-[11px] ${selected ? "text-slate-200" : "text-slate-500"}`}>
|
||||||
|
<span>对象 ID {item.object_id}</span>
|
||||||
|
<span>来源 ID {item.source_id}</span>
|
||||||
|
{item.auto_assigned && <span>自动指派</span>}
|
||||||
|
{item.ai_confidence !== null && item.ai_confidence !== undefined && (
|
||||||
|
<span>置信度 {Math.round(Number(item.ai_confidence) * 100)}%</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,268 @@
|
||||||
|
import type { FeedbackCenterSummary } from "@/api/feedbackCenter";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
|
interface MetricsPanelProps {
|
||||||
|
summary?: FeedbackCenterSummary | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_METRICS = {
|
||||||
|
accepted_rate_reviewed: 0,
|
||||||
|
manual_override_rate: 0,
|
||||||
|
rework_rate: 0,
|
||||||
|
auto_assigned_rate: 0,
|
||||||
|
avg_review_latency_minutes: 0,
|
||||||
|
total_task_breakdown_count: 0,
|
||||||
|
total_task_override_count: 0,
|
||||||
|
total_qa_feedback_count: 0,
|
||||||
|
total_signal_feedback_count: 0,
|
||||||
|
total_revision_count: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
|
task_breakdown: "任务拆解",
|
||||||
|
task_override: "任务覆盖",
|
||||||
|
qa_feedback: "QA 反馈",
|
||||||
|
signal_feedback: "信号反馈",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
pending: "待复核",
|
||||||
|
reviewed: "已复核",
|
||||||
|
accepted: "已采纳",
|
||||||
|
rejected: "已拒绝",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatPercent(value: number): string {
|
||||||
|
return `${Math.round(value * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMinutes(value: number): string {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return "0m";
|
||||||
|
if (value < 60) return `${Math.round(value)}m`;
|
||||||
|
const hours = Math.floor(value / 60);
|
||||||
|
const minutes = Math.round(value % 60);
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatGridCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
helper,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
helper: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">{title}</p>
|
||||||
|
<p className="mt-3 text-2xl font-semibold text-slate-900">{value}</p>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">{helper}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressRows({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
tone = "slate",
|
||||||
|
totalLabel,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
items: Array<{ label: string; count: number; percent: number }>;
|
||||||
|
tone?: "slate" | "emerald" | "amber" | "rose" | "sky";
|
||||||
|
totalLabel?: string;
|
||||||
|
}) {
|
||||||
|
const filledClass = {
|
||||||
|
slate: "bg-slate-900",
|
||||||
|
emerald: "bg-emerald-500",
|
||||||
|
amber: "bg-amber-500",
|
||||||
|
rose: "bg-rose-500",
|
||||||
|
sky: "bg-sky-500",
|
||||||
|
}[tone];
|
||||||
|
const emptyClass = {
|
||||||
|
slate: "bg-slate-200",
|
||||||
|
emerald: "bg-emerald-100",
|
||||||
|
amber: "bg-amber-100",
|
||||||
|
rose: "bg-rose-100",
|
||||||
|
sky: "bg-sky-100",
|
||||||
|
}[tone];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="rounded-2xl border-slate-200 bg-white shadow-sm">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">{title}</p>
|
||||||
|
{totalLabel && <p className="mt-1 text-sm text-slate-500">{totalLabel}</p>}
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="border-slate-200 bg-slate-50 text-slate-600">
|
||||||
|
{items.length} 项
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
{items.length > 0 ? (
|
||||||
|
items.map((item) => {
|
||||||
|
const filled = item.percent <= 0 ? 0 : Math.max(1, Math.min(10, Math.round(item.percent * 10)));
|
||||||
|
return (
|
||||||
|
<div key={item.label} className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span className="font-medium text-slate-700">{item.label}</span>
|
||||||
|
<span className="text-slate-500">{item.count}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-10 gap-1">
|
||||||
|
{Array.from({ length: 10 }).map((_, index) => (
|
||||||
|
<span
|
||||||
|
key={`${item.label}-${index}`}
|
||||||
|
className={`h-2 rounded-full ${index < filled ? filledClass : emptyClass}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500">{formatPercent(item.percent)}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-500">暂无可展示的分布数据。</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedbackMetricsPanel({ summary }: MetricsPanelProps) {
|
||||||
|
const metrics = summary?.metrics ?? EMPTY_METRICS;
|
||||||
|
const total = summary?.total ?? 0;
|
||||||
|
const bySourceType = summary?.by_source_type || {};
|
||||||
|
const byStatus = summary?.by_status || {};
|
||||||
|
const sourceTotal = Object.values(bySourceType).reduce((acc, value) => acc + value, 0) || 0;
|
||||||
|
const statusTotal = Object.values(byStatus).reduce((acc, value) => acc + value, 0) || 0;
|
||||||
|
|
||||||
|
const sourceRows = Object.entries(bySourceType)
|
||||||
|
.map(([label, count]) => ({
|
||||||
|
label: SOURCE_LABELS[label] || label,
|
||||||
|
count,
|
||||||
|
percent: sourceTotal > 0 ? count / sourceTotal : 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
|
const statusRows = Object.entries(byStatus)
|
||||||
|
.map(([label, count]) => ({
|
||||||
|
label: STATUS_LABELS[label] || label,
|
||||||
|
count,
|
||||||
|
percent: statusTotal > 0 ? count / statusTotal : 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
|
const reviewDecisionTotal = (summary?.accepted_count || 0) + (summary?.rejected_count || 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<StatGridCard
|
||||||
|
title="采纳率"
|
||||||
|
value={formatPercent(metrics.accepted_rate_reviewed)}
|
||||||
|
helper={`已决策样本 ${reviewDecisionTotal} 条中进入学习池的比例`}
|
||||||
|
/>
|
||||||
|
<StatGridCard
|
||||||
|
title="人工覆盖率"
|
||||||
|
value={formatPercent(metrics.manual_override_rate)}
|
||||||
|
helper={`任务覆盖样本 ${metrics.total_task_override_count} 条 / 任务拆解 ${metrics.total_task_breakdown_count} 条`}
|
||||||
|
/>
|
||||||
|
<StatGridCard
|
||||||
|
title="返工率"
|
||||||
|
value={formatPercent(metrics.rework_rate)}
|
||||||
|
helper={`有二次修订的样本占比,累计修订历史 ${metrics.total_revision_count} 条`}
|
||||||
|
/>
|
||||||
|
<StatGridCard
|
||||||
|
title="自动指派率"
|
||||||
|
value={formatPercent(metrics.auto_assigned_rate)}
|
||||||
|
helper={`任务拆解中被自动落负责人/自动完成的比例`}
|
||||||
|
/>
|
||||||
|
<StatGridCard
|
||||||
|
title="平均复核耗时"
|
||||||
|
value={formatMinutes(metrics.avg_review_latency_minutes)}
|
||||||
|
helper="从样本入库到人工复核的平均耗时"
|
||||||
|
/>
|
||||||
|
<StatGridCard title="样本总量" value={`${total}`} helper="当前反馈中心累计可学习样本数" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-2">
|
||||||
|
<Card className="rounded-2xl border-slate-200 bg-white shadow-sm">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Learning Health</p>
|
||||||
|
<h3 className="mt-2 text-lg font-semibold text-slate-900">学习健康度</h3>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">把关键采纳指标压成一组可扫读的条形信号,方便快速判断自动化是否在变好。</p>
|
||||||
|
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="rounded-2xl border border-slate-100 bg-slate-50 p-4">
|
||||||
|
<p className="text-sm font-medium text-slate-700">采纳率</p>
|
||||||
|
<div className="mt-3 grid grid-cols-10 gap-1">
|
||||||
|
{Array.from({ length: 10 }).map((_, index) => (
|
||||||
|
<span
|
||||||
|
key={`accept-${index}`}
|
||||||
|
className={`h-2 rounded-full ${index < Math.max(1, Math.round(metrics.accepted_rate_reviewed * 10)) ? "bg-emerald-500" : "bg-emerald-100"}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-slate-500">{formatPercent(metrics.accepted_rate_reviewed)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-100 bg-slate-50 p-4">
|
||||||
|
<p className="text-sm font-medium text-slate-700">人工覆盖</p>
|
||||||
|
<div className="mt-3 grid grid-cols-10 gap-1">
|
||||||
|
{Array.from({ length: 10 }).map((_, index) => (
|
||||||
|
<span
|
||||||
|
key={`override-${index}`}
|
||||||
|
className={`h-2 rounded-full ${index < Math.max(1, Math.round(metrics.manual_override_rate * 10)) ? "bg-amber-500" : "bg-amber-100"}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-slate-500">{formatPercent(metrics.manual_override_rate)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-100 bg-slate-50 p-4">
|
||||||
|
<p className="text-sm font-medium text-slate-700">返工</p>
|
||||||
|
<div className="mt-3 grid grid-cols-10 gap-1">
|
||||||
|
{Array.from({ length: 10 }).map((_, index) => (
|
||||||
|
<span
|
||||||
|
key={`rework-${index}`}
|
||||||
|
className={`h-2 rounded-full ${index < Math.max(1, Math.round(metrics.rework_rate * 10)) ? "bg-rose-500" : "bg-rose-100"}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-slate-500">{formatPercent(metrics.rework_rate)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-100 bg-slate-50 p-4">
|
||||||
|
<p className="text-sm font-medium text-slate-700">自动指派</p>
|
||||||
|
<div className="mt-3 grid grid-cols-10 gap-1">
|
||||||
|
{Array.from({ length: 10 }).map((_, index) => (
|
||||||
|
<span
|
||||||
|
key={`auto-${index}`}
|
||||||
|
className={`h-2 rounded-full ${index < Math.max(1, Math.round(metrics.auto_assigned_rate * 10)) ? "bg-sky-500" : "bg-sky-100"}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-slate-500">{formatPercent(metrics.auto_assigned_rate)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<ProgressRows
|
||||||
|
title="样本来源构成"
|
||||||
|
totalLabel={`总计 ${sourceTotal} 条`}
|
||||||
|
items={sourceRows}
|
||||||
|
tone="slate"
|
||||||
|
/>
|
||||||
|
<ProgressRows
|
||||||
|
title="复核状态分布"
|
||||||
|
totalLabel={`总计 ${statusTotal} 条`}
|
||||||
|
items={statusRows}
|
||||||
|
tone="emerald"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import type {
|
||||||
|
FeedbackCenterRecord,
|
||||||
|
FeedbackCenterReviewStatus,
|
||||||
|
FeedbackReasonCandidate,
|
||||||
|
} from "@/api/feedbackCenter";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
interface ReasonPickerProps {
|
||||||
|
record: FeedbackCenterRecord | null;
|
||||||
|
catalog: FeedbackReasonCandidate[];
|
||||||
|
isPending: boolean;
|
||||||
|
onSubmit: (payload: {
|
||||||
|
review_status: Exclude<FeedbackCenterReviewStatus, "pending">;
|
||||||
|
reason_code?: string;
|
||||||
|
reason_text?: string;
|
||||||
|
}) => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReasonPicker({ record, catalog, isPending, onSubmit }: ReasonPickerProps) {
|
||||||
|
const [selectedReason, setSelectedReason] = useState<string>("");
|
||||||
|
const [reasonText, setReasonText] = useState("");
|
||||||
|
const [targetStatus, setTargetStatus] = useState<Exclude<FeedbackCenterReviewStatus, "pending">>("accepted");
|
||||||
|
|
||||||
|
const candidates = useMemo(() => {
|
||||||
|
if (!record) return [];
|
||||||
|
const base = Array.isArray(record.reason_candidates) ? record.reason_candidates : [];
|
||||||
|
const merged = [...base];
|
||||||
|
catalog.forEach((item) => {
|
||||||
|
if (merged.some((candidate) => candidate.code === item.code)) return;
|
||||||
|
merged.push(item);
|
||||||
|
});
|
||||||
|
return merged;
|
||||||
|
}, [catalog, record]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedReason(record?.reason_code || "");
|
||||||
|
setReasonText(record?.reason_text || "");
|
||||||
|
if (record?.review_status === "reviewed" || record?.review_status === "rejected") {
|
||||||
|
setTargetStatus(record.review_status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTargetStatus("accepted");
|
||||||
|
}, [record?.id, record?.reason_code, record?.reason_text, record?.review_status]);
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50/80 p-5 text-sm text-slate-500">
|
||||||
|
选择左侧记录后,这里会显示候选原因与学习确认操作。
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Review Action</p>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900">确认学习原因</h3>
|
||||||
|
<p className="text-sm text-slate-500">AI 已经先给出候选原因,人只需要确认、修正,或拒绝纳入学习。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(["reviewed", "accepted", "rejected"] as const).map((status) => (
|
||||||
|
<Button
|
||||||
|
key={status}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={targetStatus === status ? "default" : "outline"}
|
||||||
|
className={targetStatus === status ? "bg-slate-900 text-white hover:bg-slate-800" : ""}
|
||||||
|
onClick={() => setTargetStatus(status)}
|
||||||
|
>
|
||||||
|
{status === "reviewed" ? "仅复核" : status === "accepted" ? "确认学习" : "拒绝样本"}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-slate-700">候选原因</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{candidates.map((candidate) => {
|
||||||
|
const active = selectedReason === candidate.code;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={candidate.code}
|
||||||
|
type="button"
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs transition ${
|
||||||
|
active
|
||||||
|
? "border-slate-900 bg-slate-900 text-white"
|
||||||
|
: "border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300 hover:bg-slate-100"
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedReason(candidate.code)}
|
||||||
|
>
|
||||||
|
{candidate.label}
|
||||||
|
{candidate.confidence !== undefined && candidate.confidence !== null && (
|
||||||
|
<span className={`ml-1 ${active ? "text-slate-200" : "text-slate-400"}`}>
|
||||||
|
{Math.round(Number(candidate.confidence) * 100)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{!candidates.length && (
|
||||||
|
<Badge variant="outline" className="border-amber-200 bg-amber-50 text-amber-700">
|
||||||
|
当前没有自动候选,请手工补充原因
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-slate-700">补充备注</p>
|
||||||
|
<Textarea
|
||||||
|
value={reasonText}
|
||||||
|
onChange={(event) => setReasonText(event.target.value)}
|
||||||
|
placeholder="例如:依赖链路还没锁定,所以负责人不该提前自动落库。"
|
||||||
|
className="min-h-[110px] resize-none border-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full bg-blue-600 text-white hover:bg-blue-700"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() =>
|
||||||
|
Promise.resolve(
|
||||||
|
onSubmit({
|
||||||
|
review_status: targetStatus,
|
||||||
|
reason_code: selectedReason || undefined,
|
||||||
|
reason_text: reasonText.trim() || undefined,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isPending ? "提交中..." : targetStatus === "accepted" ? "确认进入学习池" : targetStatus === "rejected" ? "拒绝该样本" : "保存复核结果"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,382 @@
|
||||||
|
import type {
|
||||||
|
FeedbackCenterSourceGroup,
|
||||||
|
FeedbackCenterSummary,
|
||||||
|
FeedbackCenterTrendWindowDays,
|
||||||
|
} from "@/api/feedbackCenter";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
|
interface TrendPanelProps {
|
||||||
|
summary?: FeedbackCenterSummary | null;
|
||||||
|
isLoading?: boolean;
|
||||||
|
windowDays: FeedbackCenterTrendWindowDays;
|
||||||
|
sourceGroup: FeedbackCenterSourceGroup;
|
||||||
|
onWindowDaysChange: (value: FeedbackCenterTrendWindowDays) => void;
|
||||||
|
onSourceGroupChange: (value: FeedbackCenterSourceGroup) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
accepted: "bg-emerald-500",
|
||||||
|
rejected: "bg-rose-500",
|
||||||
|
reviewed: "bg-sky-500",
|
||||||
|
pending: "bg-amber-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
const WINDOW_OPTIONS: FeedbackCenterTrendWindowDays[] = [7, 30];
|
||||||
|
const SOURCE_GROUP_OPTIONS: FeedbackCenterSourceGroup[] = ["all", "task", "qa", "signal"];
|
||||||
|
|
||||||
|
const SOURCE_GROUP_LABELS: Record<FeedbackCenterSourceGroup, string> = {
|
||||||
|
all: "全部",
|
||||||
|
task: "任务",
|
||||||
|
qa: "QA",
|
||||||
|
signal: "信号",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatPercent(value: number): string {
|
||||||
|
return `${Math.round(value * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDelta(value: number): string {
|
||||||
|
const sign = value > 0 ? "+" : "";
|
||||||
|
return `${sign}${Math.round(value * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMinutes(value: number): string {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return "0m";
|
||||||
|
if (value < 60) return `${Math.round(value)}m`;
|
||||||
|
const hours = Math.floor(value / 60);
|
||||||
|
const minutes = Math.round(value % 60);
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayLabel(value: string): string {
|
||||||
|
const date = new Date(`${value}T00:00:00`);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit" }).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeLabel(value: FeedbackCenterSourceGroup): string {
|
||||||
|
if (value === "task") return "任务(拆解 + 覆盖)";
|
||||||
|
if (value === "qa") return "QA";
|
||||||
|
if (value === "signal") return "信号";
|
||||||
|
return "全部来源";
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
previous,
|
||||||
|
deltaLabel,
|
||||||
|
deltaValue,
|
||||||
|
trendDirection = "up",
|
||||||
|
helper,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
previous: string;
|
||||||
|
deltaLabel: string;
|
||||||
|
deltaValue: number;
|
||||||
|
trendDirection?: "up" | "down";
|
||||||
|
helper: string;
|
||||||
|
}) {
|
||||||
|
const positive = deltaValue >= 0;
|
||||||
|
const positiveIsGood = trendDirection === "up";
|
||||||
|
const goodTone = positiveIsGood ? "border-emerald-200 bg-emerald-50 text-emerald-700" : "border-rose-200 bg-rose-50 text-rose-700";
|
||||||
|
const badTone = positiveIsGood ? "border-rose-200 bg-rose-50 text-rose-700" : "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">{title}</p>
|
||||||
|
<p className="mt-3 text-2xl font-semibold text-slate-900">{value}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className={positive ? goodTone : badTone}>
|
||||||
|
{deltaLabel}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">上期 {previous}</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">{helper}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleGroup<T extends string | number>({
|
||||||
|
title,
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
renderLabel,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
options: T[];
|
||||||
|
value: T;
|
||||||
|
onChange: (next: T) => void;
|
||||||
|
renderLabel: (value: T) => string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">{title}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{options.map((option) => {
|
||||||
|
const active = option === value;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={String(option)}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={active ? "default" : "outline"}
|
||||||
|
className={active ? "bg-slate-900 text-white hover:bg-slate-800" : "border-slate-200 bg-white text-slate-700 hover:bg-slate-50"}
|
||||||
|
onClick={() => onChange(option)}
|
||||||
|
>
|
||||||
|
{renderLabel(option)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedbackTrendPanel({
|
||||||
|
summary,
|
||||||
|
isLoading = false,
|
||||||
|
windowDays,
|
||||||
|
sourceGroup,
|
||||||
|
onWindowDaysChange,
|
||||||
|
onSourceGroupChange,
|
||||||
|
}: TrendPanelProps) {
|
||||||
|
const trend = summary?.trend;
|
||||||
|
|
||||||
|
if (isLoading && !trend) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50/80 p-5 text-sm text-slate-500">
|
||||||
|
趋势数据加载中...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trend) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50/80 p-5 text-sm text-slate-500">
|
||||||
|
当前还没有趋势数据,先累计几天反馈后,这里会显示日度变化和前后窗口对比。
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const daily = trend.daily || [];
|
||||||
|
const maxTotal = Math.max(...daily.map((item) => item.total_records), 1);
|
||||||
|
const maxRate = Math.max(
|
||||||
|
...daily.flatMap((item) => [item.manual_override_rate, item.rework_rate, item.auto_assigned_rate, item.accepted_rate_reviewed]),
|
||||||
|
0.01
|
||||||
|
);
|
||||||
|
const dailyGridClass = windowDays === 30 ? "grid min-w-[1200px] grid-cols-[repeat(30,minmax(0,1fr))]" : "grid grid-cols-7";
|
||||||
|
const sparklineGridClass = windowDays === 30 ? "grid min-w-[1200px] grid-cols-[repeat(30,minmax(0,1fr))]" : "grid grid-cols-7";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Trend Scope</p>
|
||||||
|
<h3 className="mt-1 text-lg font-semibold text-slate-900">趋势分析范围</h3>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
这里的筛选只影响趋势页,不会改动总览、指标页或任务复核列表。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline" className="border-slate-200 bg-slate-50 text-slate-600">
|
||||||
|
当前 {windowDays} 天 · {scopeLabel(sourceGroup)}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="border-slate-200 bg-slate-50 text-slate-600">
|
||||||
|
{trend.window_days} 天窗口
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-4 xl:grid-cols-[1fr_1fr]">
|
||||||
|
<ToggleGroup
|
||||||
|
title="时间窗口"
|
||||||
|
options={WINDOW_OPTIONS}
|
||||||
|
value={windowDays}
|
||||||
|
onChange={onWindowDaysChange}
|
||||||
|
renderLabel={(value) => `${value} 天`}
|
||||||
|
/>
|
||||||
|
<ToggleGroup
|
||||||
|
title="来源范围"
|
||||||
|
options={SOURCE_GROUP_OPTIONS}
|
||||||
|
value={sourceGroup}
|
||||||
|
onChange={onSourceGroupChange}
|
||||||
|
renderLabel={(value) => SOURCE_GROUP_LABELS[value]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<MetricCard
|
||||||
|
title="样本量"
|
||||||
|
value={`${trend.current.total_records}`}
|
||||||
|
previous={`${trend.previous.total_records}`}
|
||||||
|
deltaLabel={`${trend.deltas.total_records_delta > 0 ? "+" : ""}${trend.deltas.total_records_delta}`}
|
||||||
|
deltaValue={trend.deltas.total_records_delta}
|
||||||
|
helper={`最近 ${trend.window_days} 天累计样本数`}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="采纳率"
|
||||||
|
value={formatPercent(trend.current.accepted_rate_reviewed)}
|
||||||
|
previous={formatPercent(trend.previous.accepted_rate_reviewed)}
|
||||||
|
deltaLabel={formatDelta(trend.deltas.accepted_rate_reviewed_delta)}
|
||||||
|
deltaValue={trend.deltas.accepted_rate_reviewed_delta}
|
||||||
|
helper="已决策样本的进入学习池比例"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="人工覆盖率"
|
||||||
|
value={formatPercent(trend.current.manual_override_rate)}
|
||||||
|
previous={formatPercent(trend.previous.manual_override_rate)}
|
||||||
|
deltaLabel={formatDelta(trend.deltas.manual_override_rate_delta)}
|
||||||
|
deltaValue={trend.deltas.manual_override_rate_delta}
|
||||||
|
helper="任务拆解后被人工改动的比例"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="返工率"
|
||||||
|
value={formatPercent(trend.current.rework_rate)}
|
||||||
|
previous={formatPercent(trend.previous.rework_rate)}
|
||||||
|
deltaLabel={formatDelta(trend.deltas.rework_rate_delta)}
|
||||||
|
deltaValue={trend.deltas.rework_rate_delta}
|
||||||
|
helper="存在二次修订的样本占比"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="自动指派率"
|
||||||
|
value={formatPercent(trend.current.auto_assigned_rate)}
|
||||||
|
previous={formatPercent(trend.previous.auto_assigned_rate)}
|
||||||
|
deltaLabel={formatDelta(trend.deltas.auto_assigned_rate_delta)}
|
||||||
|
deltaValue={trend.deltas.auto_assigned_rate_delta}
|
||||||
|
helper="任务拆解直接落负责人比例"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="平均复核耗时"
|
||||||
|
value={formatMinutes(trend.current.avg_review_latency_minutes)}
|
||||||
|
previous={formatMinutes(trend.previous.avg_review_latency_minutes)}
|
||||||
|
deltaLabel={`${trend.deltas.avg_review_latency_minutes_delta > 0 ? "+" : ""}${Math.round(trend.deltas.avg_review_latency_minutes_delta)}m`}
|
||||||
|
deltaValue={trend.deltas.avg_review_latency_minutes_delta}
|
||||||
|
trendDirection="down"
|
||||||
|
helper="样本从生成到复核的平均耗时"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[1.4fr_0.9fr]">
|
||||||
|
<Card className="rounded-2xl border-slate-200 bg-white shadow-sm">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Daily Volume</p>
|
||||||
|
<h3 className="mt-1 text-lg font-semibold text-slate-900">最近 {trend.window_days} 天样本量与状态分布</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Object.entries(STATUS_COLORS).map(([status, color]) => (
|
||||||
|
<span key={status} className="flex items-center gap-1 text-xs text-slate-500">
|
||||||
|
<span className={`h-2.5 w-2.5 rounded-full ${color}`} />
|
||||||
|
{status === "accepted" ? "采纳" : status === "rejected" ? "拒绝" : status === "reviewed" ? "复核" : "待复核"}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 overflow-x-auto rounded-2xl bg-slate-50/80 p-4">
|
||||||
|
<div className={`gap-3 ${dailyGridClass}`}>
|
||||||
|
{daily.map((item) => {
|
||||||
|
const totalHeight = Math.max((item.total_records / maxTotal) * 100, item.total_records > 0 ? 8 : 2);
|
||||||
|
const segments = [
|
||||||
|
{ key: "accepted", count: item.accepted_count },
|
||||||
|
{ key: "rejected", count: item.rejected_count },
|
||||||
|
{ key: "reviewed", count: item.reviewed_count },
|
||||||
|
{ key: "pending", count: item.pending_count },
|
||||||
|
].filter((segment) => segment.count > 0);
|
||||||
|
return (
|
||||||
|
<div key={item.date} className="flex flex-col items-center justify-end gap-2">
|
||||||
|
<div className="flex h-44 w-full items-end justify-center">
|
||||||
|
<div
|
||||||
|
className="flex w-full max-w-[42px] flex-col overflow-hidden rounded-t-2xl border border-slate-200 bg-white shadow-sm"
|
||||||
|
style={{ height: `${Math.max(totalHeight, 4)}%` }}
|
||||||
|
>
|
||||||
|
{segments.map((segment) => {
|
||||||
|
const segmentHeight = Math.max((segment.count / Math.max(item.total_records, 1)) * 100, segment.count > 0 ? 12 : 0);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${item.date}-${segment.key}`}
|
||||||
|
className={`${STATUS_COLORS[segment.key]} flex items-center justify-center text-[10px] font-semibold text-white`}
|
||||||
|
style={{ height: `${segmentHeight}%` }}
|
||||||
|
>
|
||||||
|
{segment.count > 1 ? segment.count : ""}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs font-medium text-slate-700">{dayLabel(item.date)}</p>
|
||||||
|
<p className="text-[10px] text-slate-500">{item.total_records} 条</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl border-slate-200 bg-white shadow-sm">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Rate Sparklines</p>
|
||||||
|
<h3 className="mt-1 text-lg font-semibold text-slate-900">关键比率走势</h3>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
每一行都展示最近 {trend.window_days} 天的节奏变化,方便快速看出自动化是不是在稳定下来。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-5 space-y-5">
|
||||||
|
{[
|
||||||
|
{ label: "采纳率", key: "accepted_rate_reviewed", color: "bg-emerald-500", value: trend.current.accepted_rate_reviewed, delta: trend.deltas.accepted_rate_reviewed_delta },
|
||||||
|
{ label: "人工覆盖率", key: "manual_override_rate", color: "bg-amber-500", value: trend.current.manual_override_rate, delta: trend.deltas.manual_override_rate_delta },
|
||||||
|
{ label: "返工率", key: "rework_rate", color: "bg-rose-500", value: trend.current.rework_rate, delta: trend.deltas.rework_rate_delta },
|
||||||
|
{ label: "自动指派率", key: "auto_assigned_rate", color: "bg-sky-500", value: trend.current.auto_assigned_rate, delta: trend.deltas.auto_assigned_rate_delta },
|
||||||
|
].map((metric) => (
|
||||||
|
<div key={metric.key} className="rounded-2xl border border-slate-100 bg-slate-50 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700">{metric.label}</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
当前 {formatPercent(metric.value)} · {metric.delta > 0 ? "上升" : metric.delta < 0 ? "下降" : "持平"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={metric.delta >= 0 ? "border-emerald-200 bg-emerald-50 text-emerald-700" : "border-rose-200 bg-rose-50 text-rose-700"}
|
||||||
|
>
|
||||||
|
{metric.delta > 0 ? "+" : ""}
|
||||||
|
{Math.round(metric.delta * 100)}%
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className={`mt-3 gap-2 overflow-x-auto ${sparklineGridClass}`}>
|
||||||
|
{daily.map((item) => {
|
||||||
|
const rate = item[metric.key as keyof typeof item] as number;
|
||||||
|
const height = Math.max((rate / maxRate) * 100, rate > 0 ? 16 : 4);
|
||||||
|
return (
|
||||||
|
<div key={`${metric.key}-${item.date}`} className="flex flex-col items-center gap-2">
|
||||||
|
<div className="flex h-20 w-full items-end justify-center rounded-xl bg-white px-1 py-1 shadow-sm">
|
||||||
|
<span className={`w-full rounded-lg ${metric.color}`} style={{ height: `${height}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-slate-500">{dayLabel(item.date)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,306 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
feedbackCenterApi,
|
||||||
|
type FeedbackCenterSourceGroup,
|
||||||
|
type FeedbackCenterRecord,
|
||||||
|
type FeedbackCenterSection,
|
||||||
|
type FeedbackCenterTrendWindowDays,
|
||||||
|
} from "@/api/feedbackCenter";
|
||||||
|
import { FeedbackDetail } from "@/pages/ai-steward/components/feedback-center/FeedbackDetail";
|
||||||
|
import { FeedbackList } from "@/pages/ai-steward/components/feedback-center/FeedbackList";
|
||||||
|
import { FeedbackMetricsPanel } from "@/pages/ai-steward/components/feedback-center/MetricsPanel";
|
||||||
|
import { FeedbackTrendPanel } from "@/pages/ai-steward/components/feedback-center/TrendPanel";
|
||||||
|
import { ReasonPicker } from "@/pages/ai-steward/components/feedback-center/ReasonPicker";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { useToast } from "@/components/ui/toast";
|
||||||
|
|
||||||
|
interface FeedbackCenterTabProps {
|
||||||
|
section: FeedbackCenterSection;
|
||||||
|
onSectionChange: (section: FeedbackCenterSection) => void;
|
||||||
|
projectId?: number | string | null;
|
||||||
|
trendWindowDays: FeedbackCenterTrendWindowDays;
|
||||||
|
trendSourceGroup: FeedbackCenterSourceGroup;
|
||||||
|
onTrendWindowDaysChange: (value: FeedbackCenterTrendWindowDays) => void;
|
||||||
|
onTrendSourceGroupChange: (value: FeedbackCenterSourceGroup) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesSection(record: FeedbackCenterRecord, section: FeedbackCenterSection): boolean {
|
||||||
|
if (section === "metrics" || section === "trend") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (section === "task") {
|
||||||
|
return record.source_type === "task_breakdown" || record.source_type === "task_override";
|
||||||
|
}
|
||||||
|
if (section === "qa") {
|
||||||
|
return record.source_type === "qa_feedback";
|
||||||
|
}
|
||||||
|
if (section === "signal") {
|
||||||
|
return record.source_type === "signal_feedback";
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceTypeForSection(section: FeedbackCenterSection): string {
|
||||||
|
if (section === "task") return "task_override";
|
||||||
|
if (section === "qa") return "qa_feedback";
|
||||||
|
if (section === "signal") return "signal_feedback";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeedbackCenterTab({
|
||||||
|
section,
|
||||||
|
onSectionChange,
|
||||||
|
projectId,
|
||||||
|
trendWindowDays,
|
||||||
|
trendSourceGroup,
|
||||||
|
onTrendWindowDaysChange,
|
||||||
|
onTrendSourceGroupChange,
|
||||||
|
}: FeedbackCenterTabProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [sourceType, setSourceType] = useState("");
|
||||||
|
const [reviewStatus, setReviewStatus] = useState("");
|
||||||
|
|
||||||
|
const isAnalyticsSection = section === "metrics" || section === "trend";
|
||||||
|
const effectiveSourceType = sourceType || sourceTypeForSection(section);
|
||||||
|
|
||||||
|
const summaryQuery = useQuery({
|
||||||
|
queryKey: ["feedback-center", "summary", projectId ?? "all"],
|
||||||
|
queryFn: () => feedbackCenterApi.getSummary({ project_id: projectId ?? undefined }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const trendQuery = useQuery({
|
||||||
|
queryKey: ["feedback-center", "trend-summary", projectId ?? "all", trendWindowDays, trendSourceGroup],
|
||||||
|
queryFn: () =>
|
||||||
|
feedbackCenterApi.getSummary({
|
||||||
|
project_id: projectId ?? undefined,
|
||||||
|
window_days: trendWindowDays,
|
||||||
|
source_group: trendSourceGroup,
|
||||||
|
}),
|
||||||
|
enabled: section === "trend",
|
||||||
|
});
|
||||||
|
|
||||||
|
const listQuery = useQuery({
|
||||||
|
queryKey: ["feedback-center", "items", projectId ?? "all", effectiveSourceType, reviewStatus, search],
|
||||||
|
queryFn: () =>
|
||||||
|
feedbackCenterApi.listItems({
|
||||||
|
project_id: projectId ?? undefined,
|
||||||
|
source_type: effectiveSourceType || undefined,
|
||||||
|
review_status: reviewStatus || undefined,
|
||||||
|
search: search.trim() || undefined,
|
||||||
|
limit: 40,
|
||||||
|
offset: 0,
|
||||||
|
}),
|
||||||
|
enabled: !isAnalyticsSection,
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayItems = useMemo(
|
||||||
|
() => (listQuery.data?.items || []).filter((item) => matchesSection(item, section)),
|
||||||
|
[listQuery.data?.items, section]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedRecord = useMemo<FeedbackCenterRecord | null>(() => {
|
||||||
|
if (isAnalyticsSection) return null;
|
||||||
|
if (!displayItems.length) return null;
|
||||||
|
if (selectedId === null) return displayItems[0] || null;
|
||||||
|
return displayItems.find((item) => item.id === selectedId) || displayItems[0] || null;
|
||||||
|
}, [displayItems, selectedId, isAnalyticsSection]);
|
||||||
|
|
||||||
|
const detailQuery = useQuery({
|
||||||
|
queryKey: ["feedback-center", "detail", selectedRecord?.id ?? "none", projectId ?? "all"],
|
||||||
|
queryFn: () => feedbackCenterApi.getDetail(selectedRecord!.id, projectId ?? undefined),
|
||||||
|
enabled: !!selectedRecord?.id && !isAnalyticsSection,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reasonCatalogQuery = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"feedback-center",
|
||||||
|
"reason-catalog",
|
||||||
|
selectedRecord?.source_type ?? "none",
|
||||||
|
selectedRecord?.field_name ?? "none",
|
||||||
|
],
|
||||||
|
queryFn: () =>
|
||||||
|
feedbackCenterApi.getReasonCatalog(selectedRecord?.source_type, selectedRecord?.field_name),
|
||||||
|
enabled: !!selectedRecord && !isAnalyticsSection,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reviewMutation = useMutation({
|
||||||
|
mutationFn: (payload: { review_status: "reviewed" | "accepted" | "rejected"; reason_code?: string; reason_text?: string }) =>
|
||||||
|
feedbackCenterApi.reviewItem(selectedRecord!.id, payload),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast({ title: "反馈记录已更新", variant: "success" });
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["feedback-center"] }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: error instanceof Error ? error.message : "更新反馈记录失败",
|
||||||
|
variant: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedId(null);
|
||||||
|
}, [effectiveSourceType, reviewStatus, search, projectId, section]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSourceType("");
|
||||||
|
}, [section]);
|
||||||
|
|
||||||
|
const summary = summaryQuery.data;
|
||||||
|
const detail = detailQuery.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsContent value="feedback-center" className="mt-6">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<Card className="rounded-2xl border-slate-200 bg-white/95 shadow-sm">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-slate-400">Backlog</p>
|
||||||
|
<p className="mt-3 text-3xl font-semibold text-slate-900">{summary?.backlog_count ?? 0}</p>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">待复核 + 已复核未入样本</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="rounded-2xl border-slate-200 bg-white/95 shadow-sm">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-slate-400">Accepted</p>
|
||||||
|
<p className="mt-3 text-3xl font-semibold text-slate-900">
|
||||||
|
{Math.round(Number(summary?.accepted_rate || 0) * 100)}%
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">已进入学习池的样本占比</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="rounded-2xl border-slate-200 bg-white/95 shadow-sm">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-slate-400">Confidence</p>
|
||||||
|
<p className="mt-3 text-3xl font-semibold text-slate-900">
|
||||||
|
{Math.round(Number(summary?.avg_confidence || 0) * 100)}%
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">当前反馈样本平均 AI 置信度</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="rounded-2xl border-slate-200 bg-white/95 shadow-sm">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-slate-400">Top Reason</p>
|
||||||
|
<p className="mt-3 text-lg font-semibold text-slate-900">
|
||||||
|
{summary?.top_reasons?.[0]?.label || "暂无"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
|
{summary?.top_reasons?.[0] ? `${summary.top_reasons[0].count} 次` : "等待样本累计"}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="rounded-3xl border border-slate-200/80 bg-[linear-gradient(135deg,rgba(255,255,255,0.98),rgba(248,250,252,0.98))] shadow-[0_18px_50px_rgba(15,23,42,0.08)]">
|
||||||
|
<CardContent className="p-4 sm:p-6">
|
||||||
|
<div className="mb-5 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Feedback Center</p>
|
||||||
|
<h2 className="mt-1 text-2xl font-semibold text-slate-900">统一反馈学习中心</h2>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm text-slate-500">
|
||||||
|
把任务、QA、信号三类人工修正收口成可学习样本,当前只做复核与归因,不在这里直接改业务对象。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs value={section} onValueChange={(value) => onSectionChange(value as FeedbackCenterSection)}>
|
||||||
|
<TabsList className="flex w-full flex-nowrap gap-1 overflow-x-auto rounded-xl bg-slate-100 p-1">
|
||||||
|
<TabsTrigger value="trend" className="shrink-0 whitespace-nowrap">趋势页</TabsTrigger>
|
||||||
|
<TabsTrigger value="metrics" className="shrink-0 whitespace-nowrap">指标页</TabsTrigger>
|
||||||
|
<TabsTrigger value="overview" className="shrink-0 whitespace-nowrap">总览</TabsTrigger>
|
||||||
|
<TabsTrigger value="task" className="shrink-0 whitespace-nowrap">任务反馈</TabsTrigger>
|
||||||
|
<TabsTrigger value="qa" className="shrink-0 whitespace-nowrap">QA 反馈</TabsTrigger>
|
||||||
|
<TabsTrigger value="signal" className="shrink-0 whitespace-nowrap">信号反馈</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="trend" className="mt-5">
|
||||||
|
<FeedbackTrendPanel
|
||||||
|
summary={trendQuery.data || null}
|
||||||
|
isLoading={trendQuery.isLoading}
|
||||||
|
windowDays={trendWindowDays}
|
||||||
|
sourceGroup={trendSourceGroup}
|
||||||
|
onWindowDaysChange={onTrendWindowDaysChange}
|
||||||
|
onSourceGroupChange={onTrendSourceGroupChange}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="metrics" className="mt-5">
|
||||||
|
<FeedbackMetricsPanel summary={summary} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="overview" className="mt-5">
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[1.3fr_1fr]">
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-400">By Source</p>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{Object.entries(summary?.by_source_type || {}).map(([key, value]) => (
|
||||||
|
<div key={key} className="flex items-center justify-between rounded-xl bg-slate-50 px-4 py-3">
|
||||||
|
<span className="text-sm text-slate-600">{key}</span>
|
||||||
|
<span className="text-sm font-semibold text-slate-900">{value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!Object.keys(summary?.by_source_type || {}).length && (
|
||||||
|
<p className="text-sm text-slate-500">还没有可展示的来源分布。</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-400">Top Reasons</p>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{(summary?.top_reasons || []).map((item) => (
|
||||||
|
<div key={item.code} className="rounded-xl bg-slate-50 px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium text-slate-800">{item.label}</span>
|
||||||
|
<span className="text-xs text-slate-500">{item.count} 次</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">{item.code}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!summary?.top_reasons?.length && <p className="text-sm text-slate-500">原因榜单会随着样本增加自动出现。</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{(["task", "qa", "signal"] as const).map((pane) => (
|
||||||
|
<TabsContent key={pane} value={pane} className="mt-5">
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[320px_minmax(0,1fr)_320px]">
|
||||||
|
<FeedbackList
|
||||||
|
items={displayItems}
|
||||||
|
selectedId={selectedRecord?.id ?? null}
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
sourceType={sourceType}
|
||||||
|
reviewStatus={reviewStatus}
|
||||||
|
onSourceTypeChange={setSourceType}
|
||||||
|
onReviewStatusChange={setReviewStatus}
|
||||||
|
onSelect={(item) => setSelectedId(item.id)}
|
||||||
|
total={displayItems.length}
|
||||||
|
isLoading={listQuery.isLoading}
|
||||||
|
/>
|
||||||
|
<FeedbackDetail record={detail?.record || selectedRecord} revisions={detail?.revisions || []} />
|
||||||
|
<ReasonPicker
|
||||||
|
record={detail?.record || selectedRecord}
|
||||||
|
catalog={reasonCatalogQuery.data?.items || []}
|
||||||
|
isPending={reviewMutation.isPending}
|
||||||
|
onSubmit={async (payload) => {
|
||||||
|
await reviewMutation.mutateAsync(payload);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,17 @@
|
||||||
import type { LLMEngine, LLMEngineListResponse } from "@/api/aiSteward";
|
import type { LLMEngine, LLMEngineListResponse } from "@/api/aiSteward";
|
||||||
|
export type { FeedbackCenterTrendWindowDays, FeedbackCenterSourceGroup } from "@/api/feedbackCenter";
|
||||||
|
|
||||||
|
export type FeedbackCenterSection = "overview" | "metrics" | "trend" | "task" | "qa" | "signal";
|
||||||
|
export type AIStewardTab =
|
||||||
|
| "overview"
|
||||||
|
| "create"
|
||||||
|
| "signals"
|
||||||
|
| "conflicts"
|
||||||
|
| "blockers"
|
||||||
|
| "briefing"
|
||||||
|
| "qa"
|
||||||
|
| "feedback-center"
|
||||||
|
| "engine";
|
||||||
|
|
||||||
export interface ProjectFromTextResponse {
|
export interface ProjectFromTextResponse {
|
||||||
project: { id: string; name: string; title: string; description: string; status: string };
|
project: { id: string; name: string; title: string; description: string; status: string };
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue