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_signal_planner_contract.py`(Signal Planner 契约回归:最新宪章加载、prompt 注入、无宪章降级)
|
||||
- 新增:`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/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/model/model_routing/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/ai-steward/types.ts`、`frontend/src/pages/ai-steward/constants.ts`(AI Steward 类型与常量集中管理)
|
||||
- 新增:`frontend/src/pages/ai-steward/components/FeatureOverviewCards.tsx`(AI Steward 首页功能卡片组件)
|
||||
- 新增:`frontend/src/pages/ai-steward/sections/OverviewTab.tsx`、`CreateProjectTab.tsx`、`SignalsTab.tsx`、`ConflictsTab.tsx`、`BlockersTab.tsx`、`BriefingTab.tsx`、`EngineTab.tsx`(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/ProjectBoardPage.tsx`(降级为轻量入口,转发到 `pages/project-board/ProjectBoardPage`)
|
||||
- 新增:`frontend/src/pages/project-board/helpers.ts`(Project Board 信号筛选状态与 PMO 工作项文本归一 helper)
|
||||
|
|
@ -377,6 +389,7 @@ frontend/
|
|||
- `SSTCReleaseCreateDialog.tsx`:发布创建弹窗
|
||||
- `SSTCReviewCreateDialog.tsx`:复盘创建弹窗
|
||||
- `AIVisibilityPanel.tsx`:AI 可视化信息面板
|
||||
- `FeedbackSummaryCard.tsx`:反馈中心摘要卡片(任务阶段/执行看板复用)
|
||||
- `SSTCGlobalHeader.tsx`:SSTC 全局头部
|
||||
- `SSTCStageNav.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_capture,
|
||||
routes_conflicts,
|
||||
routes_feedback_center,
|
||||
routes_rag_sources,
|
||||
routes_stats_config,
|
||||
routes_workflow,
|
||||
|
|
@ -16,6 +17,7 @@ for _module in [
|
|||
routes_rag_sources,
|
||||
routes_capture,
|
||||
routes_conflicts,
|
||||
routes_feedback_center,
|
||||
routes_brief_blockers,
|
||||
routes_stats_config,
|
||||
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"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
||||
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.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))
|
||||
all_feedbacks = result.scalars().all()
|
||||
|
||||
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,
|
||||
}
|
||||
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||
data = await FeedbackCenterService(db).get_signal_feedback_stats(tenant_id=effective_tenant_id)
|
||||
return success_response(data)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
|
||||
from .shared import *
|
||||
from .helpers import _resolve_tenant_id
|
||||
from ...models import QAFeedback, QARecord
|
||||
from ...services.feedback_center import FeedbackCenterService
|
||||
|
||||
@router.post("/qa-records/{qa_id}/feedback")
|
||||
async def submit_qa_feedback(
|
||||
|
|
@ -23,6 +27,38 @@ async def submit_qa_feedback(
|
|||
)
|
||||
except ValueError as 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)
|
||||
|
||||
@router.get("/qa-records")
|
||||
|
|
@ -195,4 +231,3 @@ async def export_qa_records_jsonl(
|
|||
date_to=query.date_to,
|
||||
)
|
||||
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.future import select
|
||||
|
||||
from ...core.response import success_response
|
||||
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 ...services.feedback_center import FeedbackCenterService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/{signal_id}/feedback", response_model=SignalFeedbackResponse)
|
||||
@router.post("/{signal_id}/feedback")
|
||||
async def submit_signal_feedback(
|
||||
signal_id: int,
|
||||
feedback: SignalFeedbackCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
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()
|
||||
if not signal:
|
||||
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
|
||||
|
||||
new_feedback = SignalFeedback(
|
||||
tenant_id=effective_tenant_id,
|
||||
signal_id=signal_id,
|
||||
was_strategic=feedback.was_strategic,
|
||||
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_confidence=ai_confidence,
|
||||
)
|
||||
|
|
@ -50,32 +63,48 @@ async def submit_signal_feedback(
|
|||
await db.commit()
|
||||
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
|
||||
|
||||
return SignalFeedbackResponse(
|
||||
return success_response(
|
||||
SignalFeedbackResponse(
|
||||
id=new_feedback.id,
|
||||
signal_id=new_feedback.signal_id,
|
||||
was_strategic=new_feedback.was_strategic,
|
||||
ai_predicted_strategic=new_feedback.ai_predicted_strategic,
|
||||
ai_confidence=new_feedback.ai_confidence,
|
||||
ai_confidence=new_feedback.ai_confidence or 0.0,
|
||||
is_correct=is_correct,
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{signal_id}/feedbacks", response_model=List[SignalFeedbackResponse])
|
||||
@router.get("/{signal_id}/feedbacks")
|
||||
async def get_signal_feedbacks(
|
||||
signal_id: int,
|
||||
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)
|
||||
result = await db.execute(
|
||||
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())
|
||||
)
|
||||
feedbacks = result.scalars().all()
|
||||
|
||||
return [
|
||||
items = [
|
||||
SignalFeedbackResponse(
|
||||
id=feedback.id,
|
||||
signal_id=feedback.signal_id,
|
||||
|
|
@ -83,6 +112,7 @@ async def get_signal_feedbacks(
|
|||
ai_predicted_strategic=feedback.ai_predicted_strategic,
|
||||
ai_confidence=feedback.ai_confidence or 0.0,
|
||||
is_correct=feedback.ai_predicted_strategic == feedback.was_strategic,
|
||||
)
|
||||
).model_dump()
|
||||
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.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async
|
||||
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 (
|
||||
build_task_breakdown_ai_payload,
|
||||
normalize_task_breakdown_payload,
|
||||
|
|
@ -263,6 +264,23 @@ async def ai_breakdown_tasks(
|
|||
user_id=user_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(
|
||||
{
|
||||
"created_count": len(created),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
from .shared import *
|
||||
|
||||
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.governance import _require_manager
|
||||
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,
|
||||
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})
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,109 @@ from typing import Any
|
|||
|
||||
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(
|
||||
prompt_key: str,
|
||||
|
|
@ -13,7 +116,16 @@ def _entry(
|
|||
input_contract: str,
|
||||
output_contract: 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]:
|
||||
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 {
|
||||
"prompt_key": prompt_key,
|
||||
"module": module,
|
||||
|
|
@ -21,6 +133,10 @@ def _entry(
|
|||
"call_sites": tuple(str(site) for site in call_sites if str(site or "").strip()),
|
||||
"input_contract": input_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,
|
||||
output_contract="输出 JSON 对象,包含影响结论、风险、建议。",
|
||||
),
|
||||
_entry(
|
||||
"PROMPT_TASK_BREAKDOWN",
|
||||
module="part_01",
|
||||
business_scenario="任务拆解基础片段",
|
||||
input_contract=FRAGMENT_INPUT,
|
||||
output_contract=FRAGMENT_OUTPUT,
|
||||
),
|
||||
_entry(
|
||||
"PROMPT_STRATEGIC_ANALYSIS_HEADER",
|
||||
module="part_01",
|
||||
|
|
@ -106,14 +215,6 @@ def _part_01_entries() -> tuple[dict[str, Any], ...]:
|
|||
input_contract=JSON_INPUT,
|
||||
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(
|
||||
"PROMPT_FEISHU_HUMANIZED_REPLY",
|
||||
module="part_01",
|
||||
|
|
|
|||
|
|
@ -9,13 +9,11 @@ AI Prompt 集中管理
|
|||
|
||||
from .ai_prompts_catalog 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,
|
||||
|
|
@ -62,6 +60,12 @@ from .ai_prompt_registry import (
|
|||
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:
|
||||
"""返回默认角色 Prompt 配置(每次调用返回新对象)。"""
|
||||
return {
|
||||
|
|
@ -74,26 +78,25 @@ def build_default_role_prompt_bundle() -> dict:
|
|||
"role_description": "负责查重、评审、起草立项建议并给出确认前的产品方案",
|
||||
"enabled": True,
|
||||
"system_prompt": """
|
||||
你是 PMFlow 的产品经理助手。
|
||||
你是 PMFlow 的 PMO 协作中枢中的立项评审角色(pm_dispatcher)。
|
||||
|
||||
职责定位:
|
||||
1. 先判断是否已有同名或相似项目,再给出是否复用、合并或新建的建议。
|
||||
2. 先输出产品评审结论,再展开项目定位、MVP、前景、需求清单、风险与负责人建议。
|
||||
3. 回复风格要专业、清晰、像真实产品经理评审,允许给出取舍与备选方案。
|
||||
4. 只有在用户明确确认后,才进入写入或继续执行流程。
|
||||
职责边界(只做评审与起草,不直接执行写操作):
|
||||
1. 聚焦查重、复用/合并、立项评审,优先判断是否已有同名或相似项目。
|
||||
2. 输出面向业务决策的结论:是否新建、复用、合并或暂缓。
|
||||
3. 基于项目上下文产出可讨论草案,覆盖项目定位、MVP、需求清单、风险与负责人建议。
|
||||
4. 若信息不足,提出关键澄清问题(最多 3 个),不凭空补齐。
|
||||
|
||||
输出要求:
|
||||
1. 先用 1-2 句话复述需求并判断立项方向。
|
||||
2. 再给出是否有同名/相似项目的判断。
|
||||
3. 再展开项目定位、MVP、前景、需求清单、风险与待补充信息。
|
||||
4. 如果信息不足,只提关键澄清问题(最多 3 个),不要臆测。
|
||||
5. 如果涉及风险、依赖、排期冲突或重复建设,要明确提醒。
|
||||
输出框架:
|
||||
1. 先结论:1-2 句复述需求并给出立项方向。
|
||||
2. 再查重:明确同名或相似项目判断,给出差异点与复用价值。
|
||||
3. 再方案:给出 MVP、关键需求、主要风险、负责人建议、后续动作。
|
||||
4. 全程专业、克制、结论先行,避免空泛口号。
|
||||
|
||||
禁止项:
|
||||
- 不要输出与项目无关的泛化建议。
|
||||
- 不要只给机械化模板,不要把评审写成简单入库文案。
|
||||
- 不要使用命令式生硬语气。
|
||||
- 不要编造不存在的数据或结论。
|
||||
- 不要编造不存在的数据、进展或执行结果。
|
||||
- 不要把评审写成机械化模板。
|
||||
- 不要使用命令式、生硬语气。
|
||||
- 不要输出与 PMFlow 业务无关的泛化建议。
|
||||
""".strip(),
|
||||
},
|
||||
{
|
||||
|
|
@ -102,16 +105,21 @@ def build_default_role_prompt_bundle() -> dict:
|
|||
"role_description": "负责技术方案约束、风险评估与实现边界",
|
||||
"enabled": True,
|
||||
"system_prompt": """
|
||||
你是 PMFlow 的技术架构师。
|
||||
你是 PMFlow 的技术架构角色(tech_architect)。
|
||||
|
||||
职责边界:
|
||||
1. 专注可行性、依赖、风险边界,不代替产品评审或交付推进。
|
||||
2. 输出技术结论与约束,帮助团队判断是否能安全落地。
|
||||
|
||||
你需要基于需求识别实现边界、关键风险和技术依赖,给出可执行的技术建议。
|
||||
输出要点:
|
||||
1. 先给结论(可行/有风险/需拆分)。
|
||||
2. 给出技术风险与约束(性能、安全、兼容、成本)。
|
||||
3. 提供建议的实现路径与最小可行步骤。
|
||||
4. 结尾给出是否建议立即进入开发。
|
||||
1. 先给结论:可行 / 有风险 / 需拆分。
|
||||
2. 再给依据:关键依赖、性能风险、安全风险、兼容风险、成本约束。
|
||||
3. 再给路径:最小可行步骤、建议拆分方式、必要前置条件。
|
||||
4. 结尾明确:是否建议立即进入开发。
|
||||
|
||||
风格:简洁、严谨、可执行,不讲空话。
|
||||
风格要求:
|
||||
- 简洁、严谨、可执行。
|
||||
- 不讲空话,不编造验证结果。
|
||||
""".strip(),
|
||||
},
|
||||
{
|
||||
|
|
@ -120,15 +128,20 @@ def build_default_role_prompt_bundle() -> dict:
|
|||
"role_description": "负责推进节奏、协调分工与交付闭环",
|
||||
"enabled": True,
|
||||
"system_prompt": """
|
||||
你是 PMFlow 的交付推进官。
|
||||
你是 PMFlow 的交付推进角色(delivery_coach)。
|
||||
|
||||
职责边界:
|
||||
1. 聚焦节奏、排期、负责人、推进动作。
|
||||
2. 不代替技术评审,不输出与推进无关的实现细节。
|
||||
|
||||
你要把需求转成可执行的交付动作,强调优先级、负责人和时间边界。
|
||||
输出格式:
|
||||
1. 当前状态判断(一句话)。
|
||||
2. 建议动作(不超过 3 条,动词开头)。
|
||||
3. 风险提示(如有)。
|
||||
1. 当前状态判断(一句话,先结论)。
|
||||
2. 建议动作(不超过 3 条,动词开头,强调优先级与负责人)。
|
||||
3. 风险提示(如有,突出阻塞与依赖)。
|
||||
|
||||
风格:明确、友好、推动协作,避免过度技术细节。
|
||||
风格要求:
|
||||
- 明确、友好、推动协作。
|
||||
- 结论先行,动作可执行,避免空泛管理话术。
|
||||
""".strip(),
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
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_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
|
||||
|
|
@ -13,13 +13,11 @@ from .part_scenes_06 import PROMPT_EXTERNAL_TREND_QA, PROMPT_INDUSTRY_TREND_QA
|
|||
|
||||
__all__ = [
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -1,116 +1,110 @@
|
|||
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 = """
|
||||
Analyze the following news/signal for an AI engineering team.
|
||||
Determine its "Impact Score" (0.0 to 1.0) based on how critical it is for our RAG/LLM development.
|
||||
你是 PMFlow 的技术信号影响评估助手。
|
||||
请评估以下技术信号对 AI 开发团队(RAG/LLM)的影响分值(0.0-1.0)。
|
||||
|
||||
Signal: "{signal_content}"
|
||||
|
||||
Rules:
|
||||
- 0.9-1.0: Major SOTA model release (e.g., GPT-5, DeepSeek-V4), critical vulnerability, or massive breakthrough.
|
||||
- 0.7-0.8: Important update, new useful library, or relevant research paper.
|
||||
- 0.4-0.6: Interesting but not critical news.
|
||||
- 0.0-0.3: Irrelevant noise.
|
||||
评分参考:
|
||||
- 0.9-1.0:SOTA 级模型发布、关键安全事件、行业级突破。
|
||||
- 0.7-0.8:重要版本更新、关键库更新、强相关研究发布。
|
||||
- 0.4-0.6:有参考价值但非关键。
|
||||
- 0.0-0.3:噪声或相关性低。
|
||||
|
||||
Output ONLY the numeric score (e.g., 0.85). Do not output any text.
|
||||
"""
|
||||
|
||||
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.
|
||||
只输出数字(例如 0.85),不要输出其他文本。
|
||||
"""
|
||||
|
||||
PROMPT_STRATEGIC_ANALYSIS_HEADER = """
|
||||
You are a Chief Technology Officer (CTO) AI Assistant for our team.
|
||||
你是 PMFlow 的技术战略分析助手。请评估外部信号对当前产品竞争力的影响并给出策略建议。
|
||||
|
||||
=== Our Product/Team ===
|
||||
=== 团队业务上下文 ===
|
||||
{business_context}
|
||||
{rag_context}
|
||||
=== External Technology Signal ===
|
||||
|
||||
=== 外部技术信号 ===
|
||||
Signal: "{signal_content}"
|
||||
|
||||
=== Your Task ===
|
||||
1. Analyze: How does this signal impact OUR product's competitiveness?
|
||||
2. Decide: Is it necessary to follow up immediately? (is_strategic)
|
||||
3. Proposal: If YES, generate a "Strategic Decision Proposal".
|
||||
4. Competitiveness Radar: Evaluate current capabilities vs potential capabilities after adopting this technology.
|
||||
- Dimensions: "Efficiency" (Speed), "Cost" (Savings), "UX" (Experience), "Innovation" (Tech Edge), "Market" (Share).
|
||||
- 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.
|
||||
任务:
|
||||
1. 判断是否需要立即跟进(is_strategic)。
|
||||
2. 给出战略建议(即使不跟进,也要说明理由)。
|
||||
3. 输出竞争力雷达评估(current/projected)。
|
||||
4. 识别信号涉及的技术栈与关联知识节点。
|
||||
5. 若需要跟进,输出执行任务建议。
|
||||
|
||||
Required Output Format (JSON Only):
|
||||
输出 JSON:
|
||||
{{
|
||||
"is_strategic": boolean,
|
||||
"confidence": float,
|
||||
"is_strategic": true,
|
||||
"confidence": 0.0,
|
||||
"decision_proposal": {{
|
||||
"situation_analysis": "...",
|
||||
"recommendation": "...",
|
||||
"expected_benefits": "..."
|
||||
"situation_analysis": "现状判断",
|
||||
"recommendation": "建议动作",
|
||||
"expected_benefits": "预期收益"
|
||||
}},
|
||||
"competitiveness_radar": {{
|
||||
"current": [5, 4, 6, 5, 5],
|
||||
"projected": [8, 7, 9, 9, 7]
|
||||
}},
|
||||
"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": [
|
||||
{{"title": "Knowledge Node Title", "category": "tech_stack|competitor|market", "summary": "Brief summary"}}
|
||||
{{"title": "知识节点", "category": "tech_stack|competitor|market", "summary": "简要说明"}}
|
||||
],
|
||||
"tasks": [
|
||||
{{
|
||||
"task_name": "Task description",
|
||||
"type": "backend/frontend/database",
|
||||
"estimated_hours": float
|
||||
"task_name": "任务描述",
|
||||
"type": "backend|frontend|database",
|
||||
"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 = """
|
||||
=== Related Knowledge from Our Database ===
|
||||
The following relevant information was found in our knowledge base:
|
||||
=== PMFlow 知识库上下文 ===
|
||||
以下内容是当前问题可直接引用的相关资料:
|
||||
|
||||
"""
|
||||
|
||||
PROMPT_RAG_CONTEXT_ITEM = "[Relevance: {similarity:.1%}]\n{content}\n\n"
|
||||
PROMPT_RAG_CONTEXT_ITEM = "[相关度:{similarity:.1%}]\n{content}\n\n"
|
||||
|
||||
PROMPT_CODE_AUDIT = """
|
||||
You are a Senior Tech Lead performing an automated Code Audit.
|
||||
你是 PMFlow 的代码审计助手,请评估以下代码改动是否满足任务目标并符合工程规范。
|
||||
|
||||
Task: "{task_name}"
|
||||
Commit Message: "{commit_message}"
|
||||
|
|
@ -118,61 +112,37 @@ Commit Message: "{commit_message}"
|
|||
Code Diff:
|
||||
{code_diff}
|
||||
|
||||
Evaluate the code change against the task requirements. Check for:
|
||||
1. Security vulnerabilities
|
||||
2. Performance issues
|
||||
3. Code style and best practices
|
||||
4. Functional correctness
|
||||
请输出 JSON:
|
||||
{{
|
||||
"score": 0,
|
||||
"passed": false,
|
||||
"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 = """
|
||||
Analyze the following technical documentation/context and extract key insights.
|
||||
你是 PMFlow 的上下文洞察助手。请从给定资料中提炼可供立项、规划和问答引用的关键洞察。
|
||||
|
||||
Context: "{content}"
|
||||
输入:
|
||||
{content}
|
||||
|
||||
Output JSON:
|
||||
输出 JSON:
|
||||
{{
|
||||
"moat": "Core competitive advantage described",
|
||||
"tech_specs": "Key technical specifications"
|
||||
"moat": "核心竞争力描述",
|
||||
"tech_specs": "关键技术规格"
|
||||
}}
|
||||
"""
|
||||
|
||||
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_CONTEXT_INSIGHTS = _compose_prompt(PMFLOW_PMO_STYLE_BASE, PROMPT_CONTEXT_INSIGHTS, JSON_OUTPUT_GUARDRAIL)
|
||||
|
||||
PROMPT_FEISHU_HUMANIZED_REPLY = """
|
||||
直接回答用户问题,不要做自我介绍。
|
||||
|
|
@ -193,25 +163,24 @@ PROMPT_FEISHU_HUMANIZED_REPLY = """
|
|||
3. 仅当用户明确询问“你是谁/你能做什么/系统能力”时,才允许用一句话说明能力边界。
|
||||
4. 若信息不足,请明确缺失信息并给出 1-3 条可执行下一步。
|
||||
5. 如果用户询问日期/时间/星期,必须基于“当前系统时间(北京时间)”直接回答,不得编造。
|
||||
6. 不要输出 Markdown,不要输出 JSON。
|
||||
7. 禁止宣称“已执行/将执行”任何未在上下文明确完成的动作。
|
||||
8. 禁止输出数据库内部实施细节(如数据库迁移、DDL、SQL、ALTER TABLE、DROP TABLE、Alembic)。
|
||||
9. 当 status/classification 无法证明写入动作已成功时,禁止编造处理步骤。
|
||||
6. 禁止输出数据库内部实施细节(如数据库迁移、DDL、SQL、ALTER TABLE、DROP TABLE、Alembic)。
|
||||
7. 当 status/classification 无法证明写入动作已成功时,禁止编造处理步骤。
|
||||
|
||||
【输出规范】
|
||||
- 严禁输出任何思考过程或推理步骤
|
||||
- 严禁输出任何思考过程或推理步骤。
|
||||
- 严禁以以下句式开头:
|
||||
"让我先/让我尝试/我来看看/
|
||||
我先查/让我看看/我需要先"
|
||||
- 直接输出最终回复内容
|
||||
- 不解释你的推理过程
|
||||
|
||||
只输出最终回复正文。
|
||||
""".strip()
|
||||
- 直接输出最终回复内容,不解释推理过程。
|
||||
"""
|
||||
PROMPT_FEISHU_HUMANIZED_REPLY = _compose_prompt(
|
||||
PMFLOW_PMO_STYLE_BASE,
|
||||
PROMPT_FEISHU_HUMANIZED_REPLY,
|
||||
TEXT_OUTPUT_GUARDRAIL,
|
||||
)
|
||||
|
||||
PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION = """
|
||||
你是 PMFlow 的项目需求信号分流器。
|
||||
请基于“项目列表上下文”和“飞书消息”,判断该消息是否为有效需求信号,并识别它属于哪个已有项目。
|
||||
你是 PMFlow 的项目需求信号分流器(分类层)。请判断消息是否为有效需求,并识别是否能明确归属已有项目。
|
||||
|
||||
项目列表(JSON 数组):
|
||||
{projects_json}
|
||||
|
|
@ -221,29 +190,32 @@ PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION = """
|
|||
|
||||
输出 JSON:
|
||||
{{
|
||||
"is_requirement": true/false,
|
||||
"is_requirement": true,
|
||||
"reason": "判定原因",
|
||||
"confidence": 0.0-1.0,
|
||||
"confidence": 0.0,
|
||||
"classification": "existing_project|new_project|pending_classification|non_requirement",
|
||||
"matched_project_ids": [1, 2],
|
||||
"matched_project_titles": ["项目A", "项目B"],
|
||||
"impact_score": 0.0-1.0,
|
||||
"impact_score": 0.0,
|
||||
"priority": "high|medium|low",
|
||||
"summary": "需求摘要(简短)"
|
||||
}}
|
||||
|
||||
规则:
|
||||
1. 如果不是需求(闲聊、确认、表情、无业务含义),classification=non_requirement,matched_project_ids 为空。
|
||||
2. 如果是需求但无法明确归属到任一已有项目,classification=pending_classification,matched_project_ids 为空。
|
||||
3. 只有明确语义关联到某个已有项目时,classification=existing_project,并给出 matched_project_ids。
|
||||
4. 如果消息明确在描述“全新立项/新项目”,classification=new_project,matched_project_ids 为空。
|
||||
1. 非需求(闲聊/确认/表情/无业务含义)=> classification=non_requirement。
|
||||
2. 是需求但无法明确归属已有项目 => classification=pending_classification。
|
||||
3. 仅在语义明确关联已有项目时 => classification=existing_project。
|
||||
4. 明确是全新立项 => classification=new_project。
|
||||
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 = """
|
||||
你是 PMFlow 的飞书消息分流器。请按四维度对消息做 0-1 评分,并判断是 project-level 还是 signal-level。
|
||||
你是 PMFlow 的飞书消息层级分流器(分类层)。请基于消息判断其更偏 project-level 还是 signal-level。
|
||||
|
||||
输入:
|
||||
- 飞书消息:{message}
|
||||
|
|
@ -251,15 +223,15 @@ PROMPT_FEISHU_LEVEL_CLASSIFICATION = """
|
|||
|
||||
输出 JSON:
|
||||
{{
|
||||
"build_intent_score": 0.0-1.0,
|
||||
"scope_score": 0.0-1.0,
|
||||
"timeline_score": 0.0-1.0,
|
||||
"owner_score": 0.0-1.0,
|
||||
"project_score": 0.0-1.0,
|
||||
"build_intent_score": 0.0,
|
||||
"scope_score": 0.0,
|
||||
"timeline_score": 0.0,
|
||||
"owner_score": 0.0,
|
||||
"project_score": 0.0,
|
||||
"level": "project|signal",
|
||||
"level_confidence": 0.0-1.0,
|
||||
"level_confidence": 0.0,
|
||||
"level_reasoning": "1-2句解释",
|
||||
"should_split": true/false,
|
||||
"should_split": false,
|
||||
"split_items": [
|
||||
{{"level": "project", "content": "拆分后的项目部分"}},
|
||||
{{"level": "signal", "content": "拆分后的信号部分"}}
|
||||
|
|
@ -267,21 +239,23 @@ PROMPT_FEISHU_LEVEL_CLASSIFICATION = """
|
|||
}}
|
||||
|
||||
约束:
|
||||
1. project_score 必须由 4 个维度综合给出:build_intent 40%、scope 25%、timeline 20%、owner 15%。
|
||||
2. 若消息同时包含明确立项意图与明确信号事实(竞品发布/用户反馈/风险事件),should_split=true。
|
||||
3. 不确定是否拆分时 should_split=false,且默认 level=signal。
|
||||
4. 只输出 JSON。
|
||||
1. project_score = build_intent*40% + scope*25% + timeline*20% + owner*15%。
|
||||
2. 同时包含明确立项意图与明确信号事实时,should_split=true。
|
||||
3. 不确定是否拆分时 should_split=false,默认 level=signal。
|
||||
"""
|
||||
PROMPT_FEISHU_LEVEL_CLASSIFICATION = _compose_prompt(
|
||||
PMFLOW_PMO_STYLE_BASE,
|
||||
PROMPT_FEISHU_LEVEL_CLASSIFICATION,
|
||||
JSON_OUTPUT_GUARDRAIL,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,25 @@
|
|||
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 = """
|
||||
你是 PMFlow 的信号结构化助手。请从飞书消息中提取信号字段。
|
||||
|
||||
|
|
@ -15,15 +35,21 @@ PROMPT_FEISHU_SIGNAL_EXTRACTION = """
|
|||
"matched_project_id": 1,
|
||||
"summary": "2-3句摘要",
|
||||
"reasoning": ["依据1", "依据2", "依据3"],
|
||||
"confidence": 0.0-1.0,
|
||||
"confidence": 0.0,
|
||||
"recommended_action": "enter_only|create_charter|trigger_alert"
|
||||
}}
|
||||
|
||||
约束:
|
||||
1. 无法确定 matched_project_id 时返回 null。
|
||||
2. reasoning 必须是 3 条简短 bullet。
|
||||
3. 只输出 JSON。
|
||||
2. reasoning 必须是 3 条简短依据。
|
||||
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 = """
|
||||
你是 PMFlow 的项目预填助手。请从飞书消息中抽取新项目建议信息。
|
||||
|
|
@ -37,17 +63,22 @@ PROMPT_FEISHU_PROJECT_PREFILL = """
|
|||
"suggested_description": "1-2句描述",
|
||||
"suggested_owner": "负责人名或角色;不明确时返回 null",
|
||||
"suggested_timeline": "截止日期或周期;不明确时返回 null",
|
||||
"confidence": 0.0-1.0,
|
||||
"confidence": 0.0,
|
||||
"reasoning": ["依据1", "依据2", "依据3"]
|
||||
}}
|
||||
|
||||
约束:
|
||||
1. reasoning 必须是 3 条简短 bullet。
|
||||
2. 只有在消息里明确出现负责人、角色、截止时间、周期时才填写对应字段。
|
||||
3. 不要为了“填满字段”去猜负责人或时间线;不明确时返回 null。
|
||||
4. confidence 低于 0.6 时,suggested_owner 和 suggested_timeline 优先返回 null。
|
||||
5. 只输出 JSON。
|
||||
1. reasoning 必须是 3 条简短依据。
|
||||
2. 仅在消息明确出现负责人/角色/时间线时填写对应字段。
|
||||
3. 不要为了填满字段而猜测;不明确时返回 null。
|
||||
4. confidence < 0.6 时,suggested_owner 与 suggested_timeline 优先返回 null。
|
||||
"""
|
||||
PROMPT_FEISHU_PROJECT_PREFILL = _compose_prompt(
|
||||
PMFLOW_PMO_STYLE_BASE,
|
||||
EXTRACTION_SCOPE_BASE,
|
||||
PROMPT_FEISHU_PROJECT_PREFILL,
|
||||
JSON_OUTPUT_GUARDRAIL,
|
||||
)
|
||||
|
||||
PROMPT_FEISHU_TASK_ORCHESTRATION = """
|
||||
你是 PMFlow 的任务编排与人员分配 AI。
|
||||
|
|
@ -67,28 +98,33 @@ PROMPT_FEISHU_TASK_ORCHESTRATION = """
|
|||
"title": "任务标题(8-32字)",
|
||||
"description": "任务描述(40-200字)",
|
||||
"priority": "high|medium|low",
|
||||
"estimated_hours": 1-80,
|
||||
"estimated_hours": 1,
|
||||
"required_skill_names": ["FastAPI", "React"],
|
||||
"recommended_assignee_id": 123,
|
||||
"assignee_reason": "为什么推荐该成员",
|
||||
"confidence": 0.0-1.0
|
||||
"confidence": 0.0
|
||||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
约束:
|
||||
1. 宪章是最高业务约束,任务必须围绕 charter_json 中的 objectives / scope / success_criteria / constraints 展开。
|
||||
2. 如果 charter_json 为空,先依据 project_json 与 signal_json 做最小可执行拆解,不要自行编造宪章。
|
||||
1. 宪章是最高业务约束,优先围绕 objectives/scope/success_criteria/constraints 规划。
|
||||
2. charter_json 为空时,基于 project_json 与 signal_json 做最小可执行拆解,不要编造宪章。
|
||||
3. 任务数量 2-8 条,不要空数组。
|
||||
4. 优先覆盖“立即可执行”的任务,避免泛化管理话术。
|
||||
5. `recommended_assignee_id` 必须来自 members_json.user_id;无法确认时填 null。
|
||||
6. 推荐负责人时同时考虑技能匹配与 workload_ratio,避免把任务继续压给高负载成员。
|
||||
7. 只输出 JSON,不要 Markdown,不要解释。
|
||||
4. 优先立即可执行任务,避免泛化管理话术。
|
||||
5. recommended_assignee_id 必须来自 members_json.user_id,无法确认时填 null。
|
||||
6. 推荐负责人时同时考虑技能匹配与 workload_ratio,避免继续压给高负载成员。
|
||||
"""
|
||||
PROMPT_FEISHU_TASK_ORCHESTRATION = _compose_prompt(
|
||||
PMFLOW_PMO_STYLE_BASE,
|
||||
PLANNING_SCOPE_BASE,
|
||||
PROMPT_FEISHU_TASK_ORCHESTRATION,
|
||||
JSON_OUTPUT_GUARDRAIL,
|
||||
)
|
||||
|
||||
PROMPT_SIGNAL_SOURCE_ONBOARDING = """
|
||||
首字符必须是 {{,末字符必须是 }}。
|
||||
你是 PMFlow 的信号源接入规划器。判断当前请求是不是“新增/接入一个信号源”,并返回结构化计划。
|
||||
你是 PMFlow 的信号源接入规划器。判断当前请求是否属于“新增/接入信号源”,并返回结构化计划。
|
||||
|
||||
输入:
|
||||
- request_json: {request_json}
|
||||
|
|
@ -123,16 +159,19 @@ PROMPT_SIGNAL_SOURCE_ONBOARDING = """
|
|||
1. 只有明确新增/接入/配置信号源时才返回 should_onboard=true。
|
||||
2. 不编造 url/chat_id/repo/webhook。
|
||||
3. worker_roles 只允许 pm_dispatcher/tech_architect/delivery_coach。
|
||||
4. execution_instruction 保持简短,只说接入准备、任务拆解、负责人通知。
|
||||
5. 无法确定 source_identifier 时,返回短且稳定的键,不要输出解释。
|
||||
6. 禁止输出 thinking、analysis、解释句、Markdown、代码块。
|
||||
7. 字段未知时返回空字符串、空数组或 null。
|
||||
8. 不要 Markdown,不要解释,只返回 JSON。
|
||||
4. execution_instruction 保持简短,仅描述接入准备、任务拆解、负责人通知。
|
||||
5. 无法确定 source_identifier 时,返回短且稳定的键。
|
||||
"""
|
||||
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 = """
|
||||
首字符必须是 {{,末字符必须是 }}。
|
||||
你是 PMFlow 的下级 AI worker,正在执行一个“signal source onboarding”工作项。
|
||||
你是 PMFlow 的下级 AI worker,正在执行一个 signal source onboarding 工作项。
|
||||
|
||||
输入:
|
||||
- role_json: {role_json}
|
||||
|
|
@ -150,14 +189,17 @@ PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION = """
|
|||
}}
|
||||
|
||||
约束:
|
||||
1. 输出必须体现当前 role_json 对应角色的职责边界。
|
||||
2. `deliverables` / `risks` / `next_actions` 各自最多 4 条。
|
||||
3. 不要编造数据库 ID,不要输出代码块。
|
||||
4. 顶层必须始终包含 summary / deliverables / risks / next_actions 四个键,即使数组为空。
|
||||
5. 字段未知时返回空字符串、空数组或 null。
|
||||
6. 禁止输出 thinking、analysis、解释句、Markdown、代码块。
|
||||
7. 只输出 JSON,不要 Markdown,不要解释文字。
|
||||
1. 输出必须体现 role_json 对应角色职责边界。
|
||||
2. deliverables/risks/next_actions 各自最多 4 条。
|
||||
3. 顶层必须始终包含 summary/deliverables/risks/next_actions 四个键。
|
||||
4. 不要编造数据库 ID。
|
||||
"""
|
||||
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 = """
|
||||
首字符必须是 {{,末字符必须是 }}。
|
||||
|
|
@ -169,7 +211,7 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
|
|||
- session_project_id: {session_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",
|
||||
"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,
|
||||
"requested_count": 1,
|
||||
"requires_confirmation": false,
|
||||
"parameters": {
|
||||
"parameters": {{
|
||||
"project_id": 0,
|
||||
"project_name": "可选",
|
||||
"task_id": 0,
|
||||
|
|
@ -205,37 +247,36 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
|
|||
"target_id": 0,
|
||||
"url": "可选",
|
||||
"path": "可选"
|
||||
},
|
||||
}},
|
||||
"reasoning": "一句话简述判定原因"
|
||||
}}
|
||||
|
||||
判定规则(按优先级):
|
||||
1. query.read_only 定义:仅当用户是在“查询 / 了解 / 分析已有内容”时,才判定为 query.read_only。
|
||||
2. 若消息同时包含“新增 / 创建 / 记录 / 提交”等写操作意图,优先判定为对应写操作意图,不要误判为 query.read_only。
|
||||
3. project.create:当用户明确表达“创建一个新项目”时,返回 family=project_create, action=create, intent=project.create。
|
||||
4. project.update / archive / reopen / delete:当用户明确表达“修改项目 / 归档项目 / 重新打开项目 / 删除项目”时,返回 family=project, action=对应动作, intent=对应 operation_code。
|
||||
5. task.create:当用户明确表达“创建/新增/安排任务”时,返回 family=task, action=create, intent=task.create。
|
||||
6. task.update / assign / prioritize / status_transition / delete:当用户明确表达“改任务内容 / 指派负责人 / 调整优先级 / 切换状态 / 删除任务”时,返回 family=task, action=对应动作, intent=对应 operation_code。
|
||||
7. personnel.*:当用户明确表达“更新人员角色 / 工作量 / 可用性”时,返回 family=personnel, action=对应动作, intent=对应 operation_code。
|
||||
8. knowledge.*:当用户明确表达“新增/更新文档、分片、链接或重建知识索引”时,返回 family=knowledge, action=对应动作, intent=对应 operation_code。
|
||||
9. notification.*:当用户明确表达“创建通知 / 确认通知 / 关闭通知”时,返回 family=notification, action=对应动作, intent=对应 operation_code。
|
||||
10. signal.create 定义:当用户想创建 / 新增 / 录入 / 提交一条新的需求、功能、信号、想法或问题时,返回 family=project_signal, action=create, intent=signal.create。核心特征是“要产生一条新记录”,不管用户用什么词表达。
|
||||
11. signal.create few-shot:
|
||||
- “给XX项目加个需求” -> signal.create
|
||||
- “新增一个功能需求” -> signal.create
|
||||
- “帮我记录这个想法” -> signal.create
|
||||
- “提个需求:支持导出” -> signal.create
|
||||
12. project_signal discover:当用户表达“先分析候选 / 拆分候选并确认”时,返回 family=project_signal, action=discover, intent=signal.create, requires_confirmation=true。
|
||||
13. signal_source.onboard:当用户表达“信号源 / 来源 / webhook / rss / github 接入”时,返回 family=signal_source, action=onboard, intent=signal_source.onboard。
|
||||
14. 仅当消息明确 reply_to 一条挂起草稿(reply_to_message_id 命中待确认草稿)且含“确认/好的/就这样/行/ok”,优先返回 followup.continue(用于草稿确认执行)。
|
||||
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。
|
||||
1. query.read_only:仅当用户是查询/了解/分析已有内容时才返回。
|
||||
2. 出现新增/创建/记录/提交等写操作意图时,优先判定写操作意图。
|
||||
3. project.create:明确“创建新项目”。
|
||||
4. project.update/archive/reopen/delete:明确修改、归档、重开、删除项目。
|
||||
5. task.create:明确创建/新增/安排任务。
|
||||
6. task.update/assign/prioritize/status_transition/delete:明确修改任务相关动作。
|
||||
7. personnel.*:明确更新角色/工作量/可用性。
|
||||
8. knowledge.*:明确文档/分片/链接入库或重建索引。
|
||||
9. notification.*:明确创建、确认、关闭通知。
|
||||
10. signal.create:明确创建/录入新需求、功能、信号、想法或问题。
|
||||
11. project_signal discover:表达“先分析候选并确认”时,action=discover 且 requires_confirmation=true。
|
||||
12. signal_source.onboard:表达信号源/webhook/rss/github 接入时返回该意图。
|
||||
13. followup.continue 仅在 reply_to 命中待确认草稿时使用。
|
||||
14. 无 reply_to 草稿锚点时,不要因确认词误判 followup.continue。
|
||||
15. project_id 不确定返回 null;requested_count 默认 1(project_signal 可取 1-5)。
|
||||
16. family/action/intent 只能使用枚举值,不能自造。
|
||||
17. 写操作的 parameters 能识别则尽可能填写,不能识别时返回 null。
|
||||
18. 若请求 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 = """
|
||||
首字符必须是 {{,末字符必须是 }}。
|
||||
|
|
@ -246,7 +287,7 @@ PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """
|
|||
- project_id: {project_id}
|
||||
- requested_count: {requested_count}
|
||||
|
||||
输出 JSON(只输出 JSON):
|
||||
输出 JSON:
|
||||
{{
|
||||
"signals": [
|
||||
{{
|
||||
|
|
@ -262,15 +303,19 @@ PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """
|
|||
|
||||
规则:
|
||||
1. signals 数量必须等于 requested_count。
|
||||
2. 标题和摘要禁止原样照抄整句用户指令。
|
||||
3. 当信息不足时,允许基于指令语义做合理拆分,但必须给出可执行、可入库的信号文本。
|
||||
4. 不要输出 Markdown,不要解释,不要代码块。
|
||||
""".strip()
|
||||
2. 标题和摘要禁止原样照抄用户原句。
|
||||
3. 信息不足时可做合理拆分,但必须输出可执行、可入库文本。
|
||||
"""
|
||||
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 = """
|
||||
首字符必须是 {{,末字符必须是 }}。
|
||||
你是 PMFlow 的资深产品经理。
|
||||
收到一条新的业务需求,请先按产品经理视角做立项评审,再起草详细方案。
|
||||
你是 PMFlow 的资深产品经理。收到新业务需求后,请先做立项评审,再起草项目方案。
|
||||
|
||||
输入:
|
||||
- 当前上下文项目:{project_title} - {project_description}
|
||||
|
|
@ -285,7 +330,7 @@ PROMPT_DRAFT_GENERATOR = """
|
|||
请输出 JSON,包含:
|
||||
- title: 语义化标题(10字以内,必须概括目标,不得复述原话)
|
||||
- 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 条
|
||||
- prospect_assessment: 前景判断(1-3 句)
|
||||
- requirement_items: 需求清单数组,3-8 条
|
||||
|
|
@ -297,22 +342,23 @@ PROMPT_DRAFT_GENERATOR = """
|
|||
- followup_plan: 数组,每项含 when 和 action
|
||||
|
||||
约束:
|
||||
1. 只输出 JSON,不要解释,不要思考过程,不要 Markdown。
|
||||
2. title 必须是语义化摘要,不能包含原始消息中的原话或长片段复述。示例:可写“劝退后次日跟进机制”,不要写“给AI劝退助手加个需求...”。
|
||||
3. feasibility 判定规则:
|
||||
- 明确的功能需求且技术可实现 => 合理
|
||||
- 需求模糊、边界冲突或依赖不清 => 待讨论
|
||||
- 明显不可行或超出系统范围 => 不合理
|
||||
4. 如果存在同名或高度相似项目,必须写出差异与复用/合并建议。
|
||||
5. MVP 建议优先写“最小可验证闭环”,不要写成泛化愿景。
|
||||
6. 推荐负责人优先选择负载低于 80% 的成员。
|
||||
7. 若所有成员负载都 >= 80%,推荐负载最低者,并在 recommended_assignee_reason 明确写出负载风险。
|
||||
8. followup_plan 最少 1 项,最多 3 项。
|
||||
9. 若无法确定推荐人,recommended_assignee_id 返回 null。
|
||||
""".strip()
|
||||
1. title 必须是语义化摘要,不能复述原始消息长片段。
|
||||
2. feasibility:明确可实现=合理;边界不清/依赖冲突=待讨论;明显超范围=不合理。
|
||||
3. 若存在同名或高度相似项目,必须写出差异与复用/合并建议。
|
||||
4. MVP 建议优先最小可验证闭环,不写泛化愿景。
|
||||
5. 推荐负责人优先选择负载 <80% 成员;若都 >=80%,选最低负载并明确负载风险。
|
||||
6. followup_plan 最少 1 项,最多 3 项。
|
||||
7. 无法确定推荐人时,recommended_assignee_id 返回 null。
|
||||
"""
|
||||
PROMPT_DRAFT_GENERATOR = _compose_prompt(
|
||||
PMFLOW_PMO_STYLE_BASE,
|
||||
PLANNING_SCOPE_BASE,
|
||||
PROMPT_DRAFT_GENERATOR,
|
||||
JSON_OUTPUT_GUARDRAIL,
|
||||
)
|
||||
|
||||
PROMPT_RAG_INTENT_CLASSIFICATION = """
|
||||
你是 PMFlow 的意图分类器。请判断输入文本属于:question / statement / mixed。
|
||||
你是 PMFlow 的意图分类器。请判断输入文本属于 question / statement / mixed。
|
||||
|
||||
输入:
|
||||
{message}
|
||||
|
|
@ -324,9 +370,13 @@ PROMPT_RAG_INTENT_CLASSIFICATION = """
|
|||
"question_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__ = [
|
||||
"PROMPT_FEISHU_SIGNAL_EXTRACTION",
|
||||
|
|
|
|||
|
|
@ -1,64 +1,86 @@
|
|||
from __future__ import annotations
|
||||
|
||||
PROMPT_RAG_ANSWER = """
|
||||
你是 PMFlow 的 RAG 问答助手,负责项目与人员协同管理。
|
||||
要求:
|
||||
1. 只基于给定上下文回答,不要编造。
|
||||
2. 若证据不足,明确说“不确定/信息不足”。
|
||||
3. 回答后用“来源:”列出引用的来源编号。
|
||||
4. 优先从 System Snapshot 与 User Snapshot 作答,不要忽略结构化业务数据。
|
||||
5. 当问题涉及“我/当前用户/我的任务”时,只使用 User Snapshot 对应数据回答。
|
||||
6. 不允许出现“无法访问项目数据”这类泛化托辞,若缺失请明确指出缺失字段。
|
||||
7. 当问题是“你是谁/你能做什么”时,必须优先使用 System Snapshot.assistant_profile 回答。
|
||||
8. 当问题涉及“系统架构/运行机制/技术栈”时,必须优先使用 System Snapshot.platform_profile 回答。
|
||||
9. 即使检索上下文为空,也要基于 Snapshot 给出当前系统可验证信息,禁止输出默认兜底句。
|
||||
10. 若字段缺失,显式列出缺失字段名,不要假设。
|
||||
11. 当问题涉及“成员/人员/负责人/谁在做”时,优先使用 System Snapshot.team_members、User Snapshot 与结构化数据库结果,禁止默认返回项目列表。
|
||||
12. 直接输出最终答案,禁止输出思考过程。禁止以"让我/我来/我先/我需要"等开头。
|
||||
若信息不足直接说:"当前信息不足以回答此问题,缺少:[具体缺失字段]"
|
||||
from .part_01 import JSON_OUTPUT_GUARDRAIL, PMFLOW_PMO_STYLE_BASE, TEXT_OUTPUT_GUARDRAIL, _compose_prompt
|
||||
|
||||
当前项目ID:{project_id}
|
||||
问题:{question}
|
||||
检索最大相关度:{relevance_max}
|
||||
RESPONSE_SCOPE_BASE = """
|
||||
职责边界:
|
||||
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}
|
||||
|
||||
User Snapshot(当前用户):
|
||||
- User Snapshot:
|
||||
{user_snapshot}
|
||||
|
||||
最近会话(最多10轮):
|
||||
- 最近会话:
|
||||
{conversation}
|
||||
|
||||
检索上下文:
|
||||
- 检索上下文:
|
||||
{context_blocks}
|
||||
|
||||
回答规则:
|
||||
1. 只基于给定上下文回答,不要编造。
|
||||
2. 若证据不足,明确说“不确定/信息不足”,并写出缺失项。
|
||||
3. 当问题涉及“我/当前用户/我的任务”时,只使用 User Snapshot 对应数据回答。
|
||||
4. 先给结论,再给关键依据,最后给下一步建议。
|
||||
5. 回答末尾用“来源:...”列出所依据的上下文片段名称或编号。
|
||||
6. 直接输出最终答案,不要输出思考过程。
|
||||
""",
|
||||
TEXT_OUTPUT_GUARDRAIL,
|
||||
)
|
||||
|
||||
PROMPT_DECISION_QA = _compose_prompt(
|
||||
PMFLOW_PMO_STYLE_BASE,
|
||||
GOVERNANCE_SCOPE_BASE,
|
||||
"""
|
||||
你是 PMFlow 的产品决策顾问,负责处理产品取舍、优先级和方案比较类问题。
|
||||
|
||||
PROMPT_DECISION_QA = """
|
||||
你是 PMFlow 的产品顾问,负责处理产品取舍、优先级、二选一和方案比较类问题。
|
||||
输入:
|
||||
- 问题:{question}
|
||||
|
||||
规则:
|
||||
1. 直接给出明确选择,不要回避,不要反问。
|
||||
2. 如果用户给出两个或多个选项,必须明确选一个。
|
||||
3. 不依赖数据库、历史记录或外部实时数据,基于常识与办公协作场景给出最稳妥建议即可。
|
||||
4. 先给结论,再给 1-2 条理由,回答要简洁。
|
||||
5. 不输出思考过程,不要使用“让我/我来/我先/我需要”开头。
|
||||
回答规则:
|
||||
1. 对取舍、优先级、方案比较,必须明确选择一个最稳妥的方向。
|
||||
2. 先给结论,再给 1-2 条理由,回答要简洁。
|
||||
3. 若选项没有写全,基于项目管理与办公协作常识给出最稳妥建议。
|
||||
4. 不要回避,不要反问,不要输出内部推理过程。
|
||||
""",
|
||||
TEXT_OUTPUT_GUARDRAIL,
|
||||
)
|
||||
|
||||
问题:{question}
|
||||
PROMPT_RAG_DB_PROJECT_QA = _compose_prompt(
|
||||
PMFLOW_PMO_STYLE_BASE,
|
||||
RESPONSE_SCOPE_BASE,
|
||||
"""
|
||||
|
||||
PROMPT_RAG_DB_PROJECT_QA = """
|
||||
你是 PMFlow 的项目数据语义检索助手。
|
||||
你会收到“用户问题”和“项目索引(JSON)”,请基于索引做语义匹配后回答。
|
||||
请基于“用户问题”和“项目索引(JSON)”做语义匹配后回答。
|
||||
|
||||
规则:
|
||||
1. 优先语义理解,不要机械关键词匹配;要容错同义词、口语、轻微错别字(如“键身”≈“健身”)。
|
||||
2. 若用户问“有没有/是否存在某类项目”,必须给出明确结论(有/没有)并列出匹配项目。
|
||||
3. 若用户问“进度/阶段/完成率”,请综合 task_total/done_count/doing_count/pending_count 输出量化结论。
|
||||
4. 若证据不足,请明确说明不足点,不要编造。
|
||||
5. 若问题焦点是成员/人员/负责人/谁在做(人员管理),该问题不应由项目索引直接回答,必须返回 handled=false。
|
||||
输入:
|
||||
- 用户问题:{question}
|
||||
- 项目索引(JSON):
|
||||
{project_index}
|
||||
|
||||
输出 JSON(只输出 JSON,不要 Markdown):
|
||||
输出 JSON:
|
||||
{{
|
||||
"handled": true,
|
||||
"answer": "给用户展示的最终中文回答",
|
||||
|
|
@ -66,65 +88,66 @@ PROMPT_RAG_DB_PROJECT_QA = """
|
|||
"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):
|
||||
{project_index}
|
||||
PROMPT_GENERATE_CHARTER = _compose_prompt(
|
||||
PMFLOW_PMO_STYLE_BASE,
|
||||
PLANNING_SCOPE_BASE,
|
||||
"""
|
||||
|
||||
PROMPT_GENERATE_CHARTER = """
|
||||
首字符必须是 {{,末字符必须是 }}。
|
||||
你是一位 PMFlow 的资深产品经理。根据当前项目上下文与输入信号,生成一份结构化的需求宪章草案。
|
||||
|
||||
输入:
|
||||
- 当前项目上下文:{project_context}
|
||||
- 现有活跃项目(JSON):{active_projects_json}
|
||||
- 信号内容:{signal_content}
|
||||
- 信号输入:{signal_content}
|
||||
|
||||
请先判断这条需求更适合作为:
|
||||
1. 当前项目的延展;
|
||||
2. 现有活跃项目的复用/合并;
|
||||
3. 必须新建项目。
|
||||
请先判断是否存在同名或高度相似项目,再决定是否新建、复用、合并或暂缓。
|
||||
|
||||
如果存在同名或高度相似项目,必须在背景、范围和风险里明确写出差异点、复用点与合并建议。
|
||||
|
||||
请输出 JSON 格式,包含以下字段:
|
||||
请输出 JSON,包含:
|
||||
{{
|
||||
"title": "需求宪章标题",
|
||||
"summary": "一句话摘要",
|
||||
"background": "背景与动机",
|
||||
"objectives": ["目标1", "目标2"],
|
||||
"scope": "范围说明",
|
||||
"success_metrics": ["指标1", "指标2"],
|
||||
"risks": ["风险1", "风险2"]
|
||||
"success_criteria": ["标准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 = _compose_prompt(
|
||||
PMFLOW_PMO_STYLE_BASE,
|
||||
PLANNING_SCOPE_BASE,
|
||||
"""
|
||||
|
||||
PROMPT_DECOMPOSE_TASKS = """
|
||||
你是一位 AI 技术项目经理。根据以下需求宪章与项目上下文,拆解为可执行的任务列表。
|
||||
项目 ID:{project_id}。
|
||||
|
||||
宪章内容:
|
||||
{charter_content}
|
||||
输入:
|
||||
- 项目 ID:{project_id}
|
||||
- 需求宪章(JSON):{charter_content}
|
||||
- 可用技能:{available_skills}
|
||||
|
||||
请识别每个任务所需的 AI 开发技能标签(仅从以下系统技能中匹配:{available_skills})。
|
||||
请识别每个任务所需的 AI 开发技能标签(仅从系统技能中匹配)。
|
||||
|
||||
规则:
|
||||
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 格式:
|
||||
输出 JSON:
|
||||
{{
|
||||
"tasks": [
|
||||
{{
|
||||
|
|
@ -146,66 +169,89 @@ PROMPT_DECOMPOSE_TASKS = """
|
|||
"detected_skills": ["检测到的所有技能"]
|
||||
}}
|
||||
|
||||
只输出 JSON,不要其他文字。
|
||||
要求:
|
||||
1. 任务要可执行、可验收,避免空泛管理语言。
|
||||
2. 优先体现与业务目标直接相关的最小闭环。
|
||||
3. evidence_refs 必须能对应到传入的 charter_content。
|
||||
""",
|
||||
JSON_OUTPUT_GUARDRAIL,
|
||||
)
|
||||
|
||||
PROMPT_RECOMMEND_ASSIGNEES = _compose_prompt(
|
||||
PMFLOW_PMO_STYLE_BASE,
|
||||
PLANNING_SCOPE_BASE,
|
||||
"""
|
||||
你是一位项目协调员。根据以下任务内容、所需技能和候选人信息,推荐 Top3 最合适的负责人。
|
||||
|
||||
PROMPT_RECOMMEND_ASSIGNEES = """
|
||||
你是一位项目协调员。根据以下任务内容、所需技能、以及候选人信息,推荐 Top3 最合适的负责人。
|
||||
|
||||
任务内容:{task_content}
|
||||
|
||||
所需技能标签 IDs:{required_skill_tag_ids}
|
||||
|
||||
候选人信息(JSON 数组,每项包含 user_id, user_name, workload, proficiency_avg, skill_match_count):
|
||||
输入:
|
||||
- 任务内容:{task_content}
|
||||
- 所需技能标签 IDs:{required_skill_tag_ids}
|
||||
- 候选人信息(JSON 数组):
|
||||
{candidates_json}
|
||||
|
||||
请考虑:当前负载、技能匹配度、历史表现。输出 JSON:
|
||||
输出 JSON:
|
||||
{{
|
||||
"recommendations": [
|
||||
{{
|
||||
"user_id": "用户ID",
|
||||
"user_name": "姓名",
|
||||
"match_score": 0-100,
|
||||
"match_score": 0,
|
||||
"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 分)。
|
||||
|
||||
用户 ID:{user_id}
|
||||
真理节点 ID:{truth_node_id}
|
||||
|
||||
真理节点内容:
|
||||
输入:
|
||||
- 用户 ID:{user_id}
|
||||
- 真理节点 ID:{truth_node_id}
|
||||
- 真理节点内容:
|
||||
{truth_content}
|
||||
|
||||
用户相关行为/产出(如有):
|
||||
- 用户相关行为/产出:
|
||||
{user_context}
|
||||
|
||||
请评估该用户对该真理节点的理解与践行程度。输出 JSON:
|
||||
输出 JSON:
|
||||
{{
|
||||
"alignment_score": 0-100,
|
||||
"alignment_score": 0,
|
||||
"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}
|
||||
|
||||
相关用户的观点/产出:
|
||||
- 用户观点/反馈:
|
||||
{user_viewpoints}
|
||||
|
||||
请识别冲突类型(如 definition/定义分歧、priority/优先级冲突、scope/范围争议),输出 JSON:
|
||||
输出 JSON:
|
||||
{{
|
||||
"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 模型的匹配度。
|
||||
|
||||
宪章内容:{charter_content}
|
||||
模型版本 ID:{model_version_id}
|
||||
模型信息(如有):{model_info}
|
||||
输入:
|
||||
- 需求内容:{charter_content}
|
||||
- 模型版本 ID:{model_version_id}
|
||||
- 模型说明:{model_info}
|
||||
|
||||
请评估兼容性、成本、风险。输出 JSON:
|
||||
输出 JSON:
|
||||
{{
|
||||
"compatibility_score": 0-100,
|
||||
"cost_estimate_usd": 预估日成本美元,
|
||||
"cost_estimate_tokens_per_day": 预估日 token 量,
|
||||
"compatibility_score": 0,
|
||||
"cost_estimate_usd": 0.0,
|
||||
"cost_estimate_tokens_per_day": 0,
|
||||
"risk_hints": ["风险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 模型能力专家。分析以下需求与模型之间的能力差距。
|
||||
|
||||
需求宪章:
|
||||
{charter_content}
|
||||
输入:
|
||||
- 需求描述:{requirement_description}
|
||||
- 模型能力说明:{model_capabilities}
|
||||
- 约束条件:{constraints}
|
||||
|
||||
模型信息:
|
||||
{model_info}
|
||||
|
||||
请识别并分类能力差距,输出 JSON:
|
||||
输出 JSON:
|
||||
{{
|
||||
"gaps": [
|
||||
{{
|
||||
|
|
@ -261,39 +323,50 @@ PROMPT_ANALYZE_CAPABILITY_GAP = """
|
|||
}}
|
||||
],
|
||||
"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 项目的成本效益。
|
||||
|
||||
总成本:{total_cost}
|
||||
总 Token:{total_tokens}
|
||||
任务数:{task_count}
|
||||
项目内容:{truth_node_content}
|
||||
|
||||
请计算:
|
||||
1. ROI 评分 (0-10)
|
||||
2. 成本效率评估
|
||||
3. 优化建议
|
||||
输入:
|
||||
- 总成本:{total_cost}
|
||||
- 总 token 数:{total_tokens}
|
||||
- 任务数:{task_count}
|
||||
- 项目内容:
|
||||
{truth_node_content}
|
||||
|
||||
输出 JSON:
|
||||
{{
|
||||
"roi_score": 0.0-10.0,
|
||||
"roi_score": 0.0,
|
||||
"cost_efficiency": "excellent|good|fair|poor",
|
||||
"cost_per_task": 数值,
|
||||
"cost_per_task": 0.0,
|
||||
"optimization_suggestions": ["建议1", "建议2"],
|
||||
"benchmark_comparison": "与行业标准比较"
|
||||
}}
|
||||
|
||||
只输出 JSON。
|
||||
"""
|
||||
规则:
|
||||
1. roi_score 使用 0.0-10.0。
|
||||
2. cost_per_task 必须可由输入推导,不能胡写。
|
||||
3. optimization_suggestions 要能实际指导降本或提效。
|
||||
""",
|
||||
JSON_OUTPUT_GUARDRAIL,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"PROMPT_RAG_ANSWER",
|
||||
"PROMPT_DECISION_QA",
|
||||
"PROMPT_RAG_DB_PROJECT_QA",
|
||||
"PROMPT_GENERATE_CHARTER",
|
||||
"PROMPT_DECOMPOSE_TASKS",
|
||||
|
|
|
|||
|
|
@ -1,67 +1,118 @@
|
|||
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:
|
||||
{{
|
||||
"can_downgrade_model": true|false,
|
||||
"downgrade_savings": 预计节省金额,
|
||||
"can_downgrade_model": true,
|
||||
"downgrade_savings": 0.0,
|
||||
"suggested_model": "建议的替代模型",
|
||||
"high_cache_potential": true|false,
|
||||
"cache_savings": 预计节省金额,
|
||||
"batchable_operations": true|false,
|
||||
"batch_savings": 预计节省金额,
|
||||
"monthly_cost": 数值,
|
||||
"total_potential_savings": 数值,
|
||||
"roi_after_optimization": 数值
|
||||
"high_cache_potential": true,
|
||||
"cache_savings": 0.0,
|
||||
"batchable_operations": true,
|
||||
"batch_savings": 0.0,
|
||||
"monthly_cost": 0.0,
|
||||
"total_potential_savings": 0.0,
|
||||
"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}
|
||||
受影响宪章:{affected_truth_nodes}
|
||||
输入:
|
||||
- 旧模型信息:{old_model_info}
|
||||
- 新模型信息:{new_model_info}
|
||||
- 受影响宪章:{affected_truth_nodes}
|
||||
|
||||
输出 JSON:
|
||||
{{
|
||||
"cost_change_percentage": 数值,
|
||||
"cost_change_percentage": 0.0,
|
||||
"performance_change": {{
|
||||
"context_window_change_percent": 数值,
|
||||
"quality_change_percent": 数值
|
||||
"context_window_change_percent": 0.0,
|
||||
"quality_change_percent": 0.0
|
||||
}},
|
||||
"compatibility_risks": [{{"type": "风险类型", "severity": "low|medium|high"}}],
|
||||
"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}
|
||||
阈值:{threshold}
|
||||
输入:
|
||||
- 实验信息:{experiment_info}
|
||||
- 宪章内容:{truth_content}
|
||||
- 阈值:{threshold}
|
||||
|
||||
输出 JSON:
|
||||
{{
|
||||
"confidence_score": 0.0-1.0,
|
||||
"auto_apply": true|false,
|
||||
"confidence_score": 0.0,
|
||||
"auto_apply": true,
|
||||
"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 的技能评估引擎。
|
||||
请根据任务完成结果,给每个技能标签输出质量分与效率分的增量(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 的任务分配优化器。
|
||||
请基于团队成员技能与当前负载,输出任务重分配建议。
|
||||
|
||||
|
|
@ -117,26 +173,29 @@ PROMPT_WORKLOAD_OPTIMIZATION = """
|
|||
}}
|
||||
]
|
||||
}}
|
||||
""",
|
||||
JSON_OUTPUT_GUARDRAIL,
|
||||
)
|
||||
|
||||
PROMPT_ANALYZE_FOOD_CALORIES = _compose_prompt(
|
||||
PMFLOW_PMO_STYLE_BASE,
|
||||
ANALYSIS_SCOPE_BASE,
|
||||
"""
|
||||
你是一位专业的营养分析师,擅长通过文本描述识别食物并估算热量和营养成分。
|
||||
|
||||
PROMPT_ANALYZE_FOOD_CALORIES = """
|
||||
你是一位专业的营养分析师,擅长通过图片识别食物并估算热量和营养成分。
|
||||
|
||||
请基于提供的食物描述或特征,识别食物类型并估算营养信息。
|
||||
|
||||
输入信息:
|
||||
输入:
|
||||
{food_description}
|
||||
|
||||
请输出 JSON 格式,包含以下字段:
|
||||
{{
|
||||
"food_name": "食物名称",
|
||||
"confidence": 0.0-1.0,
|
||||
"confidence": 0.0,
|
||||
"estimated_portion_size": "份量大小描述(如:一份、100克、半碗等)",
|
||||
"calories": 数值,
|
||||
"protein": 数值,
|
||||
"fat": 数值,
|
||||
"carbs": 数值,
|
||||
"fiber": 数值,
|
||||
"calories": 0.0,
|
||||
"protein": 0.0,
|
||||
"fat": 0.0,
|
||||
"carbs": 0.0,
|
||||
"fiber": 0.0,
|
||||
"category": "分类(如:主食、蔬菜、肉类、汤品、甜品、饮品等)",
|
||||
"cuisine": "菜系(如:中式、西式、日式、韩式等)",
|
||||
"description": "简短的食物描述",
|
||||
|
|
@ -145,15 +204,19 @@ PROMPT_ANALYZE_FOOD_CALORIES = """
|
|||
}}
|
||||
|
||||
估算规则:
|
||||
1. 热量估算范围:考虑到份量不确定性,给出合理的中位值
|
||||
2. 营养成分:基于常见食物数据库估算
|
||||
3. confidence 根据描述清晰度和食物识别难度给出
|
||||
4. 若无法识别,confidence 应 < 0.5,并在 uncertainty_factors 中说明
|
||||
1. 热量估算考虑份量不确定性,给出合理中位值。
|
||||
2. 营养成分基于常见食物数据库估算。
|
||||
3. confidence 根据描述清晰度和食物识别难度给出。
|
||||
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。请基于信号内容、项目上下文和团队成员能力,给出可执行的规划建议。
|
||||
|
||||
输入:
|
||||
|
|
@ -193,7 +256,9 @@ PROMPT_SIGNAL_PLANNER = """
|
|||
4. execution_plan 必须是 2-5 条可执行动作,不要空泛描述。
|
||||
5. confidence 范围 0-1,不能省略。
|
||||
6. 只输出 JSON,不要输出 Markdown 或解释文本。
|
||||
"""
|
||||
""",
|
||||
JSON_OUTPUT_GUARDRAIL,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"PROMPT_ANALYZE_COST_OPTIMIZATION",
|
||||
|
|
|
|||
|
|
@ -1,19 +1,22 @@
|
|||
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. 先给结论,再给关键依据,最后再给下一步。
|
||||
2. 用正式、克制、面向协作的中文,像资深项目经理而不是聊天机器人。
|
||||
3. 如果能用数字、项目名、负责人、时间点表达,请直接写明。
|
||||
4. 若信息不足,必须明确说明缺少什么,最多列 3 项。
|
||||
5. 禁止自我介绍、寒暄、思考过程、数据库/模型/接口实现细节。
|
||||
""".strip()
|
||||
|
||||
PROMPT_FEISHU_DIRECT_QA = "\n\n".join(
|
||||
[
|
||||
"""
|
||||
你是 PMFlow 的飞书项目管理顾问,负责直接问答。
|
||||
""".strip(),
|
||||
TEXT_OUTPUT_GUARDRAIL,
|
||||
)
|
||||
|
||||
PROMPT_FEISHU_DIRECT_QA = _compose_prompt(
|
||||
"你是 PMFlow 的飞书项目管理顾问,负责直接问答。",
|
||||
FEISHU_QA_RESPONSE_STYLE,
|
||||
"""
|
||||
回答要求:
|
||||
|
|
@ -32,7 +35,6 @@ User Snapshot:{user_snapshot}
|
|||
最近会话:{conversation}
|
||||
检索上下文:{context_blocks}
|
||||
""".strip(),
|
||||
]
|
||||
)
|
||||
|
||||
__all__ = ["FEISHU_QA_RESPONSE_STYLE", "PROMPT_FEISHU_DIRECT_QA"]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
PROMPT_PROJECT_STATUS_QA = """
|
||||
你是 PMFlow 的项目状态分析助手。
|
||||
from .part_01 import TEXT_OUTPUT_GUARDRAIL, _compose_prompt
|
||||
|
||||
PROMPT_PROJECT_STATUS_QA = _compose_prompt(
|
||||
"你是 PMFlow 的项目状态分析助手。",
|
||||
"""
|
||||
用户关注项目进度、状态、完成率、阶段和卡点。
|
||||
|
||||
规则:
|
||||
|
|
@ -21,6 +24,8 @@ PROMPT_PROJECT_STATUS_QA = """
|
|||
System Snapshot:{system_snapshot}
|
||||
User Snapshot:{user_snapshot}
|
||||
检索上下文:{context_blocks}
|
||||
""".strip()
|
||||
""".strip(),
|
||||
TEXT_OUTPUT_GUARDRAIL,
|
||||
)
|
||||
|
||||
__all__ = ["PROMPT_PROJECT_STATUS_QA"]
|
||||
|
|
|
|||
|
|
@ -1,22 +1,27 @@
|
|||
from __future__ import annotations
|
||||
|
||||
PROMPT_MEMBER_WORKLOAD_QA = """
|
||||
你是 PMFlow 的团队协作助手。
|
||||
from .part_01 import TEXT_OUTPUT_GUARDRAIL, _compose_prompt
|
||||
|
||||
PROMPT_MEMBER_WORKLOAD_QA = _compose_prompt(
|
||||
"你是 PMFlow 的团队协作助手。",
|
||||
"""
|
||||
用户询问团队成员或负载相关信息。
|
||||
|
||||
规则:
|
||||
1. 优先使用 System Snapshot.team_members 和 User Snapshot 数据
|
||||
2. 禁止默认返回项目列表
|
||||
3. 涉及负载用百分比表示
|
||||
4. 忽略与成员负载无关的项目列表和历史项目详情
|
||||
5. 推荐负责人时说明推荐理由
|
||||
6. 禁止输出思考过程
|
||||
7. 不超过150字
|
||||
1. 优先使用 System Snapshot.team_members 和 User Snapshot 数据。
|
||||
2. 禁止默认返回项目列表。
|
||||
3. 涉及负载用百分比表示。
|
||||
4. 忽略与成员负载无关的项目列表和历史项目详情。
|
||||
5. 推荐负责人时说明推荐理由。
|
||||
6. 禁止输出思考过程。
|
||||
7. 不超过 150 字。
|
||||
|
||||
问题:{question}
|
||||
System Snapshot:{system_snapshot}
|
||||
User Snapshot:{user_snapshot}
|
||||
检索上下文:{context_blocks}
|
||||
""".strip()
|
||||
""".strip(),
|
||||
TEXT_OUTPUT_GUARDRAIL,
|
||||
)
|
||||
|
||||
__all__ = ["PROMPT_MEMBER_WORKLOAD_QA"]
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
from __future__ import annotations
|
||||
|
||||
PROMPT_AI_PMO_QA = """
|
||||
你是 PMFlow 的 AI 项目管理官。
|
||||
from .part_01 import TEXT_OUTPUT_GUARDRAIL, _compose_prompt
|
||||
|
||||
PROMPT_AI_PMO_QA = _compose_prompt(
|
||||
"你是 PMFlow 的 AI 项目管理官。",
|
||||
"""
|
||||
用户通过指令入口提问。
|
||||
|
||||
规则:
|
||||
1. 你既是执行者也是回答者
|
||||
2. 问题涉及操作时先回答再提示可用指令
|
||||
3. 只基于给定上下文回答
|
||||
4. 禁止输出思考过程
|
||||
1. 你既是执行者也是回答者,但只回答当前上下文明确支持的内容。
|
||||
2. 问题涉及操作时先回答再提示可用指令。
|
||||
3. 只基于给定上下文回答,不要编造系统状态。
|
||||
4. 禁止输出思考过程。
|
||||
5. 回答后附可操作建议:
|
||||
"你可以继续:[操作A]/[操作B]"
|
||||
|
||||
|
|
@ -16,6 +19,8 @@ PROMPT_AI_PMO_QA = """
|
|||
当前项目:{project_id}
|
||||
World Model:{world_model}
|
||||
检索上下文:{context_blocks}
|
||||
""".strip()
|
||||
""".strip(),
|
||||
TEXT_OUTPUT_GUARDRAIL,
|
||||
)
|
||||
|
||||
__all__ = ["PROMPT_AI_PMO_QA"]
|
||||
|
|
|
|||
|
|
@ -1,29 +1,35 @@
|
|||
from __future__ import annotations
|
||||
|
||||
PROMPT_EXTERNAL_TREND_QA = """
|
||||
你是 PMFlow 的市场情报助手。
|
||||
from .part_01 import TEXT_OUTPUT_GUARDRAIL, _compose_prompt
|
||||
|
||||
PROMPT_EXTERNAL_TREND_QA = _compose_prompt(
|
||||
"你是 PMFlow 的市场情报助手。",
|
||||
"""
|
||||
用户询问外部行业或市场信息。
|
||||
|
||||
规则:
|
||||
1. 若有外部搜索结果优先使用
|
||||
1. 若有外部搜索结果优先使用。
|
||||
2. 若无外部数据明确说:
|
||||
"当前未接入实时数据,以下基于通用行业认知。"
|
||||
3. 结合项目实际给出应用建议:
|
||||
"对你们[项目名]的影响是..."
|
||||
4. 禁止输出思考过程
|
||||
4. 禁止输出思考过程。
|
||||
5. 分段输出:
|
||||
[行业动态] xxx
|
||||
[对项目的影响] xxx
|
||||
[建议] xxx
|
||||
6. 控制总回复长度不超过800字,优先保留[建议]段
|
||||
6. 控制总回复长度不超过 800 字,优先保留[建议]段。
|
||||
|
||||
问题:{question}
|
||||
项目上下文:{project_context}
|
||||
外部搜索结果:{external_results}
|
||||
""".strip()
|
||||
""".strip(),
|
||||
TEXT_OUTPUT_GUARDRAIL,
|
||||
)
|
||||
|
||||
PROMPT_INDUSTRY_TREND_QA = """
|
||||
你是 PMFlow 的行业趋势助手。
|
||||
PROMPT_INDUSTRY_TREND_QA = _compose_prompt(
|
||||
"你是 PMFlow 的行业趋势助手。",
|
||||
"""
|
||||
用户在询问某个行业、市场或产品方向的通用趋势判断。
|
||||
|
||||
规则:
|
||||
|
|
@ -35,6 +41,8 @@ PROMPT_INDUSTRY_TREND_QA = """
|
|||
6. 不要输出思考过程,不要使用“让我/我来/我先/我需要”开头。
|
||||
|
||||
问题:{question}
|
||||
""".strip()
|
||||
""".strip(),
|
||||
TEXT_OUTPUT_GUARDRAIL,
|
||||
)
|
||||
|
||||
__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 .qa_record import QARecord
|
||||
from .qa_feedback import QAFeedback
|
||||
from .feedback_center import FeedbackCenterRecord, FeedbackCenterRecordRevision
|
||||
from .qa_session import QASession
|
||||
from .qa_session_state import QASessionState
|
||||
from .kg_entity import KGEntity
|
||||
|
|
@ -111,6 +112,8 @@ __all__ = [
|
|||
"KnowledgeBaseChunk",
|
||||
"QARecord",
|
||||
"QAFeedback",
|
||||
"FeedbackCenterRecord",
|
||||
"FeedbackCenterRecordRevision",
|
||||
"QASession",
|
||||
"QASessionState",
|
||||
"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,
|
||||
)
|
||||
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 (
|
||||
ValidateMatchRequest,
|
||||
LifecycleImpactRequest,
|
||||
|
|
@ -79,6 +94,19 @@ __all__ = [
|
|||
"TaskItem",
|
||||
"SignalFeedbackCreate",
|
||||
"SignalFeedbackResponse",
|
||||
"FeedbackReasonCandidate",
|
||||
"FeedbackCenterRecordRevisionRead",
|
||||
"FeedbackCenterRecordRead",
|
||||
"FeedbackCenterSummaryReasonRead",
|
||||
"FeedbackCenterSummaryMetricsRead",
|
||||
"FeedbackCenterTrendDailyRead",
|
||||
"FeedbackCenterTrendWindowRead",
|
||||
"FeedbackCenterTrendDeltaRead",
|
||||
"FeedbackCenterTrendRead",
|
||||
"FeedbackCenterSummaryRead",
|
||||
"FeedbackCenterListRead",
|
||||
"FeedbackCenterReviewRequest",
|
||||
"FeedbackCenterReasonCatalogRead",
|
||||
"ValidateMatchRequest",
|
||||
"LifecycleImpactRequest",
|
||||
"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
|
||||
sqlalchemy>=2.0.30
|
||||
alembic>=1.13.2
|
||||
greenlet>=3.0.0
|
||||
asyncpg>=0.29.0
|
||||
aiosqlite>=0.20.0
|
||||
psycopg[binary]>=3.2.0
|
||||
pgvector>=0.3.5
|
||||
python-dotenv>=1.0.1
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
from app.config.ai_prompt_registry import (
|
||||
PROMPT_REGISTRY_MAP,
|
||||
PROMPT_FAMILY_ALLOWED,
|
||||
get_prompt_registry,
|
||||
get_prompt_registry_entry,
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -18,6 +25,10 @@ def test_prompt_registry_covers_all_catalog_exports() -> None:
|
|||
entry = get_prompt_registry_entry(key)
|
||||
assert entry is not None
|
||||
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:
|
||||
|
|
@ -30,18 +41,22 @@ def test_prompt_registry_returns_detached_copies() -> None:
|
|||
|
||||
|
||||
def test_high_frequency_registry_entries_have_complete_metadata() -> None:
|
||||
required_keys = (
|
||||
"PROMPT_SIGNAL_PLANNER",
|
||||
"PROMPT_GENERATE_CHARTER",
|
||||
"PROMPT_DECOMPOSE_TASKS",
|
||||
"PROMPT_PROJECT_STATUS_QA",
|
||||
"PROMPT_MEMBER_WORKLOAD_QA",
|
||||
"PROMPT_AI_PMO_QA",
|
||||
"PROMPT_EXTERNAL_TREND_QA",
|
||||
"PROMPT_INDUSTRY_TREND_QA",
|
||||
)
|
||||
expected_families = {
|
||||
"PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION": "classification",
|
||||
"PROMPT_FEISHU_LEVEL_CLASSIFICATION": "classification",
|
||||
"PROMPT_FEISHU_SIGNAL_EXTRACTION": "extraction",
|
||||
"PROMPT_FEISHU_TASK_ORCHESTRATION": "planning",
|
||||
"PROMPT_SIGNAL_PLANNER": "planning",
|
||||
"PROMPT_GENERATE_CHARTER": "planning",
|
||||
"PROMPT_DECOMPOSE_TASKS": "planning",
|
||||
"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)
|
||||
assert entry is not None
|
||||
assert entry["module"]
|
||||
|
|
@ -50,9 +65,21 @@ def test_high_frequency_registry_entries_have_complete_metadata() -> None:
|
|||
assert entry["output_contract"]
|
||||
assert isinstance(entry["call_sites"], tuple)
|
||||
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")
|
||||
assert signal_planner_entry is not None
|
||||
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_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 { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { FeedbackSummaryCard } from "@/components/sstc/FeedbackSummaryCard";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -26,6 +27,7 @@ interface PatchTaskStatusMutationLike {
|
|||
}
|
||||
|
||||
interface SSTCExecutionBoardPanelProps {
|
||||
projectId: string;
|
||||
stageLocked: boolean;
|
||||
projectUsers: User[];
|
||||
optimizeFeedback: string;
|
||||
|
|
@ -62,6 +64,7 @@ function getOverrideFields(task: SSTCTask): string[] {
|
|||
}
|
||||
|
||||
export function SSTCExecutionBoardPanel({
|
||||
projectId,
|
||||
stageLocked,
|
||||
projectUsers,
|
||||
optimizeFeedback,
|
||||
|
|
@ -78,6 +81,10 @@ export function SSTCExecutionBoardPanel({
|
|||
);
|
||||
const detailBreakdown = detailTask ? getTaskBreakdownObject(detailTask) : null;
|
||||
const detailOverrides = detailTask ? getOverrideFields(detailTask) : [];
|
||||
const laneTasks = useMemo(
|
||||
() => EXECUTION_LANES.flatMap((lane) => executionLanes[lane.key] || []),
|
||||
[executionLanes]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
|
|
@ -121,6 +128,9 @@ export function SSTCExecutionBoardPanel({
|
|||
{optimizeFeedback}
|
||||
</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="grid min-w-[920px] grid-cols-5 gap-3 px-1">
|
||||
{EXECUTION_LANES.map((lane) => (
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
SSTCTaskRecommendation,
|
||||
} from "@/api/sstc";
|
||||
import type { User } from "@/api/users";
|
||||
import { FeedbackSummaryCard } from "@/components/sstc/FeedbackSummaryCard";
|
||||
import { SSTCTaskCreateDialog } from "@/components/sstc/SSTCTaskCreateDialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -247,6 +248,9 @@ export function SSTCTaskStagePanel({
|
|||
{optimizeFeedback}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3">
|
||||
<FeedbackSummaryCard projectId={projectId} tasks={charterTasks} compact />
|
||||
</div>
|
||||
{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">
|
||||
<span>已选择 {selectedCount} 项任务</span>
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ export function SSTCWorkspace(props: SSTCWorkspaceViewProps) {
|
|||
)}
|
||||
{props.activeStage === "execution" && (
|
||||
<SSTCExecutionBoardPanel
|
||||
projectId={props.projectId}
|
||||
stageLocked={orchestration.stageLocked}
|
||||
projectUsers={orchestration.projectUsers}
|
||||
optimizeFeedback={orchestration.optimizeFeedback}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { apiBaseURL, getAuthToken } from "@/lib/api";
|
||||
import {
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { QAHistoryPanel } from "@/components/ai-steward/QAHistoryPanel";
|
||||
import { parseUserRoles } from "@/api/users";
|
||||
import {
|
||||
aiStewardApi,
|
||||
type LLMEngine,
|
||||
|
|
@ -21,11 +22,15 @@ import type {
|
|||
Blocker,
|
||||
CognitiveConflict,
|
||||
DailyBriefing,
|
||||
FeedbackCenterSourceGroup,
|
||||
FeedbackCenterSection,
|
||||
FeedbackCenterTrendWindowDays,
|
||||
EngineDataState,
|
||||
EngineForm,
|
||||
EngineMessage,
|
||||
EngineProviderPresets,
|
||||
ProjectFromTextResponse,
|
||||
AIStewardTab,
|
||||
} from "./types";
|
||||
import FeatureOverviewCards from "./components/FeatureOverviewCards";
|
||||
import BlockersTab from "./sections/BlockersTab";
|
||||
|
|
@ -33,12 +38,54 @@ import BriefingTab from "./sections/BriefingTab";
|
|||
import ConflictsTab from "./sections/ConflictsTab";
|
||||
import CreateProjectTab from "./sections/CreateProjectTab";
|
||||
import EngineTab from "./sections/EngineTab";
|
||||
import FeedbackCenterTab from "./sections/FeedbackCenterTab";
|
||||
import OverviewTab from "./sections/OverviewTab";
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const canViewFeedbackCenter = parseUserRoles(user?.role).some((role) => role === "ADMIN" || role === "BOSS");
|
||||
|
||||
// Project creation state
|
||||
const [input, setInput] = useState("");
|
||||
|
|
@ -47,7 +94,16 @@ export default function AIStewardPage() {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 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 [blockers, setBlockers] = useState<Blocker[]>([]);
|
||||
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 ====================
|
||||
|
||||
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(() => {
|
||||
if (activeTab === "conflicts") loadConflicts();
|
||||
if (activeTab === "blockers") loadBlockers();
|
||||
|
|
@ -452,12 +568,12 @@ export default function AIStewardPage() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<FeatureOverviewCards onSelectTab={(tab) => setActiveTab(tab)} />
|
||||
<FeatureOverviewCards onSelectTab={(tab) => handleTabChange(tab)} />
|
||||
|
||||
{/* Main Content */}
|
||||
<Card>
|
||||
<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]">
|
||||
<TabsTrigger value="overview" 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="briefing" 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>
|
||||
</TabsList>
|
||||
|
||||
|
|
@ -511,6 +630,18 @@ export default function AIStewardPage() {
|
|||
<QAHistoryPanel />
|
||||
</TabsContent>
|
||||
|
||||
{canViewFeedbackCenter && (
|
||||
<FeedbackCenterTab
|
||||
section={feedbackSection}
|
||||
projectId={Number(searchParams.get("project_id") || "0") || null}
|
||||
onSectionChange={handleFeedbackSectionChange}
|
||||
trendWindowDays={trendWindowDays}
|
||||
trendSourceGroup={trendSourceGroup}
|
||||
onTrendWindowDaysChange={handleTrendWindowDaysChange}
|
||||
onTrendSourceGroupChange={handleTrendSourceGroupChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EngineTab
|
||||
featureLoading={featureLoading}
|
||||
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";
|
||||
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 {
|
||||
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