Add scoped trend controls to feedback center

This commit is contained in:
Yuuuu 2026-03-26 11:49:34 +08:00
parent 4da49c7fc4
commit 77a094e363
48 changed files with 6693 additions and 630 deletions

View File

@ -135,8 +135,18 @@ backend/
- 新增:`backend/tests/test_draft_generator_service.py`(草稿生成服务回归:PM 评审草稿包含相似项目、MVP、前景与需求清单) - 新增:`backend/tests/test_draft_generator_service.py`(草稿生成服务回归:PM 评审草稿包含相似项目、MVP、前景与需求清单)
- 新增:`backend/tests/test_signal_planner_contract.py`(Signal Planner 契约回归:最新宪章加载、prompt 注入、无宪章降级) - 新增:`backend/tests/test_signal_planner_contract.py`(Signal Planner 契约回归:最新宪章加载、prompt 注入、无宪章降级)
- 新增:`backend/tests/test_ai_prompt_registry_contract.py`(Prompt registry 契约回归:覆盖率与元数据完整性) - 新增:`backend/tests/test_ai_prompt_registry_contract.py`(Prompt registry 契约回归:覆盖率与元数据完整性)
- 新增:`backend/tests/test_prompt_profile_bundle_contract.py`(默认角色 prompt bundle 契约回归:PMFlow PMO 语气、职责边界与协作口径)
- 新增:`backend/alembic/versions/20260324_add_project_create_idempotency_indexes.py`(活动项目标题大小写无关唯一索引,收口项目创建重复写入) - 新增:`backend/alembic/versions/20260324_add_project_create_idempotency_indexes.py`(活动项目标题大小写无关唯一索引,收口项目创建重复写入)
- 新增:`backend/alembic/versions/20260326_add_task_breakdown_payload_fields.py`(tasks 拆解业务对象 JSONB 字段:`ai_breakdown_payload/manual_overrides`) - 新增:`backend/alembic/versions/20260326_add_task_breakdown_payload_fields.py`(tasks 拆解业务对象 JSONB 字段:`ai_breakdown_payload/manual_overrides`)
- 新增:`backend/alembic/versions/20260326_create_feedback_center_tables.py`(统一反馈学习中心双表建模与 QA/Signal/Task 初始回填)
- 新增:`backend/app/api/ai_steward_api/routes_feedback_center.py`(AI Steward 反馈中心接口:summary/list/detail/review/reason-catalog;summary 支持 `window_days` / `source_group` 趋势作用域)
- 新增:`backend/app/models/feedback_center.py`(`FeedbackCenterRecord/FeedbackCenterRecordRevision` 统一反馈中心模型)
- 新增:`backend/app/schemas/feedback_center.py`(反馈中心 summary/list/detail/review/reason-catalog 对应 Pydantic 模型)
- 新增:`backend/app/services/feedback_center/`(反馈中心服务层:`service.py/mappers.py/reason_catalog.py`)
- 新增:`backend/tests/test_feedback_center_service.py`(反馈中心服务回归:tenant/project 过滤、review 修订、映射与原因候选)
- 新增:`backend/tests/test_feedback_center_api.py`(反馈中心 API 回归:路由、权限与三类来源回填)
- 更新:`backend/app/services/feedback_center/service.py`(summary 输出新增 metrics 学习指标,并支持 `window_days` / `source_group` 的趋势分析作用域)
- 更新:`backend/app/schemas/feedback_center.py`(summary 增加 metrics / trend 结构,并回传 `source_group`)
- 新增:`backend/app/services/signal_planner_service.py`(Signal Planner 异步分析服务) - 新增:`backend/app/services/signal_planner_service.py`(Signal Planner 异步分析服务)
- 新增:`backend/app/services/model/model_routing/service.py`(函数级模型路由配置服务) - 新增:`backend/app/services/model/model_routing/service.py`(函数级模型路由配置服务)
- 新增:`backend/app/services/task_brief_service.py`(增量任务包中心服务) - 新增:`backend/app/services/task_brief_service.py`(增量任务包中心服务)
@ -346,7 +356,9 @@ frontend/
- 更新:`frontend/src/pages/AIStewardPage.tsx`(降级为轻量入口,转发到 `pages/ai-steward/AIStewardPage`) - 更新:`frontend/src/pages/AIStewardPage.tsx`(降级为轻量入口,转发到 `pages/ai-steward/AIStewardPage`)
- 新增:`frontend/src/pages/ai-steward/types.ts`、`frontend/src/pages/ai-steward/constants.ts`(AI Steward 类型与常量集中管理) - 新增:`frontend/src/pages/ai-steward/types.ts`、`frontend/src/pages/ai-steward/constants.ts`(AI Steward 类型与常量集中管理)
- 新增:`frontend/src/pages/ai-steward/components/FeatureOverviewCards.tsx`(AI Steward 首页功能卡片组件) - 新增:`frontend/src/pages/ai-steward/components/FeatureOverviewCards.tsx`(AI Steward 首页功能卡片组件)
- 新增:`frontend/src/pages/ai-steward/sections/OverviewTab.tsx`、`CreateProjectTab.tsx`、`SignalsTab.tsx`、`ConflictsTab.tsx`、`BlockersTab.tsx`、`BriefingTab.tsx`、`EngineTab.tsx`(AI Steward 分阶段 UI 拆分) - 新增:`frontend/src/pages/ai-steward/sections/OverviewTab.tsx`、`CreateProjectTab.tsx`、`SignalsTab.tsx`、`ConflictsTab.tsx`、`BlockersTab.tsx`、`BriefingTab.tsx`、`EngineTab.tsx`、`FeedbackCenterTab.tsx`(AI Steward 分阶段 UI 拆分)
- 新增:`frontend/src/pages/ai-steward/components/feedback-center/FeedbackList.tsx`、`FeedbackDetail.tsx`、`ReasonPicker.tsx`、`MetricsPanel.tsx`、`TrendPanel.tsx`(反馈中心三栏工作台、指标页与趋势页组件;趋势页支持 `7/30 天` 与 `all/task/qa/signal` 局部筛选)
- 新增:`frontend/src/api/feedbackCenter.ts`(统一反馈中心 API 封装)
- 新增:`frontend/src/pages/ai-steward/AIStewardPage.tsx`(AI Steward 页面编排层,主文件聚焦状态与事件) - 新增:`frontend/src/pages/ai-steward/AIStewardPage.tsx`(AI Steward 页面编排层,主文件聚焦状态与事件)
- 更新:`frontend/src/pages/ProjectBoardPage.tsx`(降级为轻量入口,转发到 `pages/project-board/ProjectBoardPage`) - 更新:`frontend/src/pages/ProjectBoardPage.tsx`(降级为轻量入口,转发到 `pages/project-board/ProjectBoardPage`)
- 新增:`frontend/src/pages/project-board/helpers.ts`(Project Board 信号筛选状态与 PMO 工作项文本归一 helper) - 新增:`frontend/src/pages/project-board/helpers.ts`(Project Board 信号筛选状态与 PMO 工作项文本归一 helper)
@ -377,6 +389,7 @@ frontend/
- `SSTCReleaseCreateDialog.tsx`:发布创建弹窗 - `SSTCReleaseCreateDialog.tsx`:发布创建弹窗
- `SSTCReviewCreateDialog.tsx`:复盘创建弹窗 - `SSTCReviewCreateDialog.tsx`:复盘创建弹窗
- `AIVisibilityPanel.tsx`:AI 可视化信息面板 - `AIVisibilityPanel.tsx`:AI 可视化信息面板
- `FeedbackSummaryCard.tsx`:反馈中心摘要卡片(任务阶段/执行看板复用)
- `SSTCGlobalHeader.tsx`:SSTC 全局头部 - `SSTCGlobalHeader.tsx`:SSTC 全局头部
- `SSTCStageNav.tsx`:阶段导航 - `SSTCStageNav.tsx`:阶段导航
- `SSTCChainView.tsx`:链路视图 - `SSTCChainView.tsx`:链路视图

View File

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

View File

@ -6,6 +6,7 @@ from . import (
routes_brief_blockers, routes_brief_blockers,
routes_capture, routes_capture,
routes_conflicts, routes_conflicts,
routes_feedback_center,
routes_rag_sources, routes_rag_sources,
routes_stats_config, routes_stats_config,
routes_workflow, routes_workflow,
@ -16,6 +17,7 @@ for _module in [
routes_rag_sources, routes_rag_sources,
routes_capture, routes_capture,
routes_conflicts, routes_conflicts,
routes_feedback_center,
routes_brief_blockers, routes_brief_blockers,
routes_stats_config, routes_stats_config,
routes_workflow, routes_workflow,

View File

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

View File

@ -1,57 +1,27 @@
"""反馈统计 API""" """反馈统计 API"""
from __future__ import annotations
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from ..core.database import get_db from ..core.database import get_db
from ..models import SignalFeedback from ..core.response import success_response
from ..dependencies.auth import get_current_user
from ..dependencies.tenant import get_tenant_id
from ..models import User
from ..services.feedback_center import FeedbackCenterService
router = APIRouter(prefix="/feedbacks", tags=["feedbacks"]) router = APIRouter(prefix="/feedbacks", tags=["feedbacks"])
@router.get("/stats") @router.get("/stats")
async def get_feedback_stats(db: AsyncSession = Depends(get_db)): async def get_feedback_stats(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
"""获取反馈统计""" """获取反馈统计"""
result = await db.execute(select(SignalFeedback)) effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
all_feedbacks = result.scalars().all() data = await FeedbackCenterService(db).get_signal_feedback_stats(tenant_id=effective_tenant_id)
return success_response(data)
total = len(all_feedbacks)
if total == 0:
return {
"total_feedbacks": 0,
"accuracy": 0.0,
"true_positives": 0,
"false_positives": 0,
"true_negatives": 0,
"false_negatives": 0,
}
correct = sum(
1 for f in all_feedbacks if f.ai_predicted_strategic == f.was_strategic
)
true_positives = sum(
1 for f in all_feedbacks if f.ai_predicted_strategic and f.was_strategic
)
false_positives = sum(
1 for f in all_feedbacks if f.ai_predicted_strategic and not f.was_strategic
)
true_negatives = sum(
1
for f in all_feedbacks
if not f.ai_predicted_strategic and not f.was_strategic
)
false_negatives = sum(
1
for f in all_feedbacks
if not f.ai_predicted_strategic and f.was_strategic
)
return {
"total_feedbacks": total,
"accuracy": correct / total,
"true_positives": true_positives,
"false_positives": false_positives,
"true_negatives": true_negatives,
"false_negatives": false_negatives,
}

View File

@ -1,7 +1,11 @@
from __future__ import annotations from __future__ import annotations
from sqlalchemy import and_, select
from .shared import * from .shared import *
from .helpers import _resolve_tenant_id from .helpers import _resolve_tenant_id
from ...models import QAFeedback, QARecord
from ...services.feedback_center import FeedbackCenterService
@router.post("/qa-records/{qa_id}/feedback") @router.post("/qa-records/{qa_id}/feedback")
async def submit_qa_feedback( async def submit_qa_feedback(
@ -23,6 +27,38 @@ async def submit_qa_feedback(
) )
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
try:
feedback_row = (
await db.execute(
select(QAFeedback)
.where(
and_(
QAFeedback.tenant_id == _resolve_tenant_id(current_user, tenant_id),
QAFeedback.qa_id == qa_id,
QAFeedback.submitted_by == int(current_user.id),
)
)
.order_by(QAFeedback.created_at.desc(), QAFeedback.id.desc())
)
).scalars().first()
qa_row = (
await db.execute(
select(QARecord).where(
and_(
QARecord.tenant_id == _resolve_tenant_id(current_user, tenant_id),
QARecord.id == qa_id,
)
)
)
).scalar_one_or_none()
if feedback_row and qa_row:
await FeedbackCenterService(db).ingest_qa_feedback(
qa_feedback=feedback_row,
qa_record=qa_row,
commit=True,
)
except Exception:
await db.rollback()
return success_response(result) return success_response(result)
@router.get("/qa-records") @router.get("/qa-records")
@ -195,4 +231,3 @@ async def export_qa_records_jsonl(
date_to=query.date_to, date_to=query.date_to,
) )
return Response(content=payload, media_type="application/x-ndjson") return Response(content=payload, media_type="application/x-ndjson")

View File

@ -9,21 +9,33 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from ...core.response import success_response
from ...core.database import get_db from ...core.database import get_db
from ...models import Signal, SignalFeedback from ...dependencies.auth import get_current_user
from ...dependencies.tenant import get_tenant_id
from ...models import Signal, SignalFeedback, User
from ...schemas import SignalFeedbackCreate, SignalFeedbackResponse from ...schemas import SignalFeedbackCreate, SignalFeedbackResponse
from ...services.feedback_center import FeedbackCenterService
router = APIRouter() router = APIRouter()
@router.post("/{signal_id}/feedback", response_model=SignalFeedbackResponse) @router.post("/{signal_id}/feedback")
async def submit_signal_feedback( async def submit_signal_feedback(
signal_id: int, signal_id: int,
feedback: SignalFeedbackCreate, feedback: SignalFeedbackCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
): ):
"""提交信号反馈""" """提交信号反馈"""
result = await db.execute(select(Signal).where(Signal.id == signal_id)) effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
result = await db.execute(
select(Signal).where(
Signal.id == signal_id,
Signal.tenant_id == effective_tenant_id,
)
)
signal = result.scalar_one_or_none() signal = result.scalar_one_or_none()
if not signal: if not signal:
raise HTTPException(status_code=404, detail="Signal not found") raise HTTPException(status_code=404, detail="Signal not found")
@ -39,10 +51,11 @@ async def submit_signal_feedback(
ai_predicted_strategic = signal.impact_score >= 0.9 ai_predicted_strategic = signal.impact_score >= 0.9
new_feedback = SignalFeedback( new_feedback = SignalFeedback(
tenant_id=effective_tenant_id,
signal_id=signal_id, signal_id=signal_id,
was_strategic=feedback.was_strategic, was_strategic=feedback.was_strategic,
reason=feedback.reason, reason=feedback.reason,
user_id=feedback.user_id, user_id=str(getattr(current_user, "id", feedback.user_id) or feedback.user_id or ""),
ai_predicted_strategic=ai_predicted_strategic, ai_predicted_strategic=ai_predicted_strategic,
ai_confidence=ai_confidence, ai_confidence=ai_confidence,
) )
@ -50,32 +63,48 @@ async def submit_signal_feedback(
await db.commit() await db.commit()
await db.refresh(new_feedback) await db.refresh(new_feedback)
try:
await FeedbackCenterService(db).ingest_signal_feedback(
signal_feedback=new_feedback,
signal=signal,
commit=True,
)
except Exception:
await db.rollback()
is_correct = ai_predicted_strategic == feedback.was_strategic is_correct = ai_predicted_strategic == feedback.was_strategic
return SignalFeedbackResponse( return success_response(
SignalFeedbackResponse(
id=new_feedback.id, id=new_feedback.id,
signal_id=new_feedback.signal_id, signal_id=new_feedback.signal_id,
was_strategic=new_feedback.was_strategic, was_strategic=new_feedback.was_strategic,
ai_predicted_strategic=new_feedback.ai_predicted_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, is_correct=is_correct,
).model_dump()
) )
@router.get("/{signal_id}/feedbacks")
@router.get("/{signal_id}/feedbacks", response_model=List[SignalFeedbackResponse])
async def get_signal_feedbacks( async def get_signal_feedbacks(
signal_id: int, signal_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
): ):
"""获取某信号的全部反馈""" """获取某信号的全部反馈"""
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
result = await db.execute( result = await db.execute(
select(SignalFeedback) select(SignalFeedback)
.where(SignalFeedback.signal_id == signal_id) .where(
SignalFeedback.signal_id == signal_id,
SignalFeedback.tenant_id == effective_tenant_id,
)
.order_by(SignalFeedback.created_at.desc()) .order_by(SignalFeedback.created_at.desc())
) )
feedbacks = result.scalars().all() feedbacks = result.scalars().all()
return [ items = [
SignalFeedbackResponse( SignalFeedbackResponse(
id=feedback.id, id=feedback.id,
signal_id=feedback.signal_id, signal_id=feedback.signal_id,
@ -83,6 +112,7 @@ async def get_signal_feedbacks(
ai_predicted_strategic=feedback.ai_predicted_strategic, ai_predicted_strategic=feedback.ai_predicted_strategic,
ai_confidence=feedback.ai_confidence or 0.0, ai_confidence=feedback.ai_confidence or 0.0,
is_correct=feedback.ai_predicted_strategic == feedback.was_strategic, is_correct=feedback.ai_predicted_strategic == feedback.was_strategic,
) ).model_dump()
for feedback in feedbacks for feedback in feedbacks
] ]
return success_response({"items": items})

View File

@ -14,6 +14,7 @@ from ...services.sstc.entity_lookup import _ensure_charter, _ensure_project
from ...services.sstc.governance import _assert_charter_aligned, _require_manager from ...services.sstc.governance import _assert_charter_aligned, _require_manager
from ...services.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async from ...services.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async
from ...services.context_cache_service import context_cache_service from ...services.context_cache_service import context_cache_service
from ...services.feedback_center import FeedbackCenterService, map_task_breakdown_to_record_payload
from ...services.task_breakdown import ( from ...services.task_breakdown import (
build_task_breakdown_ai_payload, build_task_breakdown_ai_payload,
normalize_task_breakdown_payload, normalize_task_breakdown_payload,
@ -263,6 +264,23 @@ async def ai_breakdown_tasks(
user_id=user_id, user_id=user_id,
) )
await context_cache_service.invalidate_system_context(tenant_id=tenant_id) await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
try:
payloads = [
map_task_breakdown_to_record_payload(
task=t,
project_title=project.title,
source_context=ai_breakdown_payload["source_context"],
)
for t in created
]
if payloads:
await FeedbackCenterService(db).ingest_payloads(payloads=payloads, commit=True)
except Exception:
if hasattr(db, "rollback"):
try:
await db.rollback()
except Exception:
pass
return success_response( return success_response(
{ {
"created_count": len(created), "created_count": len(created),

View File

@ -3,6 +3,7 @@ from __future__ import annotations
from .shared import * from .shared import *
from ...services.context_cache_service import context_cache_service from ...services.context_cache_service import context_cache_service
from ...services.feedback_center import FeedbackCenterService
from ...services.sstc.entity_lookup import _ensure_project, _ensure_task, _parse_due_date from ...services.sstc.entity_lookup import _ensure_project, _ensure_task, _parse_due_date
from ...services.sstc.governance import _require_manager from ...services.sstc.governance import _require_manager
from ...services.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async from ...services.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async
@ -118,6 +119,14 @@ async def patch_task(
title=task.title, title=task.title,
content=task.description or "", content=task.description or "",
) )
try:
await FeedbackCenterService(db).ingest_task_overrides(task=task, commit=True)
except Exception:
if hasattr(db, "rollback"):
try:
await db.rollback()
except Exception:
pass
return success_response({"id": str(task.id), "status": task.status}) return success_response({"id": str(task.id), "status": task.status})

View File

@ -4,6 +4,109 @@ from typing import Any
from .ai_prompts_catalog import __all__ as PROMPT_CATALOG_EXPORTS from .ai_prompts_catalog import __all__ as PROMPT_CATALOG_EXPORTS
PROMPT_FAMILY_CLASSIFICATION = "classification"
PROMPT_FAMILY_EXTRACTION = "extraction"
PROMPT_FAMILY_PLANNING = "planning"
PROMPT_FAMILY_RESPONSE = "response"
PROMPT_FAMILY_GOVERNANCE = "governance"
PROMPT_FAMILY_ALLOWED = {
PROMPT_FAMILY_CLASSIFICATION,
PROMPT_FAMILY_EXTRACTION,
PROMPT_FAMILY_PLANNING,
PROMPT_FAMILY_RESPONSE,
PROMPT_FAMILY_GOVERNANCE,
}
PROMPT_FAMILY_KEYWORD_RULES: tuple[tuple[str, str], ...] = (
("CLASSIFICATION", PROMPT_FAMILY_CLASSIFICATION),
("EXTRACTION", PROMPT_FAMILY_EXTRACTION),
("PREFILL", PROMPT_FAMILY_EXTRACTION),
("INTENT", PROMPT_FAMILY_CLASSIFICATION),
("TASK_ORCHESTRATION", PROMPT_FAMILY_PLANNING),
("PLANNER", PROMPT_FAMILY_PLANNING),
("GENERATE", PROMPT_FAMILY_PLANNING),
("DECOMPOSE", PROMPT_FAMILY_PLANNING),
("RECOMMEND", PROMPT_FAMILY_PLANNING),
("DRAFT", PROMPT_FAMILY_PLANNING),
("TASK_BREAKDOWN", PROMPT_FAMILY_PLANNING),
("QA", PROMPT_FAMILY_RESPONSE),
("REPLY", PROMPT_FAMILY_RESPONSE),
)
PROMPT_FAMILY_SCENARIO_RULES: tuple[tuple[str, str], ...] = (
("分类", PROMPT_FAMILY_CLASSIFICATION),
("抽取", PROMPT_FAMILY_EXTRACTION),
("预填", PROMPT_FAMILY_EXTRACTION),
("规划", PROMPT_FAMILY_PLANNING),
("编排", PROMPT_FAMILY_PLANNING),
("草案", PROMPT_FAMILY_PLANNING),
("拆解", PROMPT_FAMILY_PLANNING),
("问答", PROMPT_FAMILY_RESPONSE),
("回复", PROMPT_FAMILY_RESPONSE),
)
RESPONSIBILITY_SCOPE_BY_FAMILY: dict[str, str] = {
PROMPT_FAMILY_CLASSIFICATION: "分类层:只负责识别请求类型、归属和优先级,不生成执行方案。",
PROMPT_FAMILY_EXTRACTION: "抽取层:只负责结构化提取实体、槽位和证据,不做规划决策。",
PROMPT_FAMILY_PLANNING: "规划层:只负责生成可执行规划、任务和负责人建议,不承担最终对话呈现。",
PROMPT_FAMILY_RESPONSE: "呈现层:只负责面向用户输出结论与建议,不直接做写操作决策。",
PROMPT_FAMILY_GOVERNANCE: "治理层:只负责约束、评估、同步与风控,不承担主对话生成。",
}
PROMPT_PRIORITY_BY_FAMILY: dict[str, str] = {
PROMPT_FAMILY_CLASSIFICATION: "high",
PROMPT_FAMILY_EXTRACTION: "high",
PROMPT_FAMILY_PLANNING: "high",
PROMPT_FAMILY_RESPONSE: "high",
PROMPT_FAMILY_GOVERNANCE: "medium",
}
PROMPT_AUDIENCE_BY_FAMILY: dict[str, str] = {
PROMPT_FAMILY_CLASSIFICATION: "signal_router",
PROMPT_FAMILY_EXTRACTION: "signal_ingestion",
PROMPT_FAMILY_PLANNING: "planning_orchestrator",
PROMPT_FAMILY_RESPONSE: "assistant_response",
PROMPT_FAMILY_GOVERNANCE: "risk_budget_guardrail",
}
def _normalize_family(prompt_family: str) -> str:
value = str(prompt_family or "").strip().lower()
if value in PROMPT_FAMILY_ALLOWED:
return value
return PROMPT_FAMILY_GOVERNANCE
def _derive_prompt_family(prompt_key: str, business_scenario: str) -> str:
key_text = str(prompt_key or "").strip().upper()
scenario_text = str(business_scenario or "").strip()
for keyword, family in PROMPT_FAMILY_KEYWORD_RULES:
if keyword in key_text:
return family
for keyword, family in PROMPT_FAMILY_SCENARIO_RULES:
if keyword in scenario_text:
return family
return PROMPT_FAMILY_GOVERNANCE
def _derive_responsibility_scope(prompt_family: str) -> str:
family = _normalize_family(prompt_family)
return RESPONSIBILITY_SCOPE_BY_FAMILY[family]
def _derive_priority(prompt_family: str) -> str:
family = _normalize_family(prompt_family)
return PROMPT_PRIORITY_BY_FAMILY[family]
def _derive_audience(prompt_family: str) -> str:
family = _normalize_family(prompt_family)
return PROMPT_AUDIENCE_BY_FAMILY[family]
def _entry( def _entry(
prompt_key: str, prompt_key: str,
@ -13,7 +116,16 @@ def _entry(
input_contract: str, input_contract: str,
output_contract: str, output_contract: str,
call_sites: tuple[str, ...] = (), call_sites: tuple[str, ...] = (),
prompt_family: str | None = None,
responsibility_scope: str | None = None,
priority: str | None = None,
audience: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
resolved_family = _normalize_family(prompt_family or _derive_prompt_family(prompt_key, business_scenario))
resolved_scope = str(responsibility_scope or "").strip() or _derive_responsibility_scope(resolved_family)
resolved_priority = str(priority or "").strip().lower() or _derive_priority(resolved_family)
resolved_audience = str(audience or "").strip() or _derive_audience(resolved_family)
return { return {
"prompt_key": prompt_key, "prompt_key": prompt_key,
"module": module, "module": module,
@ -21,6 +133,10 @@ def _entry(
"call_sites": tuple(str(site) for site in call_sites if str(site or "").strip()), "call_sites": tuple(str(site) for site in call_sites if str(site or "").strip()),
"input_contract": input_contract, "input_contract": input_contract,
"output_contract": output_contract, "output_contract": output_contract,
"prompt_family": resolved_family,
"responsibility_scope": resolved_scope,
"priority": resolved_priority,
"audience": resolved_audience,
} }
@ -64,13 +180,6 @@ def _part_01_entries() -> tuple[dict[str, Any], ...]:
input_contract=JSON_INPUT, input_contract=JSON_INPUT,
output_contract="输出 JSON 对象,包含影响结论、风险、建议。", output_contract="输出 JSON 对象,包含影响结论、风险、建议。",
), ),
_entry(
"PROMPT_TASK_BREAKDOWN",
module="part_01",
business_scenario="任务拆解基础片段",
input_contract=FRAGMENT_INPUT,
output_contract=FRAGMENT_OUTPUT,
),
_entry( _entry(
"PROMPT_STRATEGIC_ANALYSIS_HEADER", "PROMPT_STRATEGIC_ANALYSIS_HEADER",
module="part_01", module="part_01",
@ -106,14 +215,6 @@ def _part_01_entries() -> tuple[dict[str, Any], ...]:
input_contract=JSON_INPUT, input_contract=JSON_INPUT,
output_contract="输出 JSON 对象,包含关键洞察与下一步建议。", output_contract="输出 JSON 对象,包含关键洞察与下一步建议。",
), ),
_entry(
"PROMPT_FEISHU_REQUIREMENT_ANALYSIS",
module="part_01",
business_scenario="飞书需求解析",
input_contract=JSON_INPUT,
output_contract="输出 JSON 对象,包含需求抽取、风险与建议。",
call_sites=FEISHU_EXTRACT_CALL_SITES,
),
_entry( _entry(
"PROMPT_FEISHU_HUMANIZED_REPLY", "PROMPT_FEISHU_HUMANIZED_REPLY",
module="part_01", module="part_01",

View File

@ -9,13 +9,11 @@ AI Prompt 集中管理
from .ai_prompts_catalog import ( from .ai_prompts_catalog import (
PROMPT_IMPACT_ANALYSIS, PROMPT_IMPACT_ANALYSIS,
PROMPT_TASK_BREAKDOWN,
PROMPT_STRATEGIC_ANALYSIS_HEADER, PROMPT_STRATEGIC_ANALYSIS_HEADER,
PROMPT_RAG_CONTEXT_HEADER, PROMPT_RAG_CONTEXT_HEADER,
PROMPT_RAG_CONTEXT_ITEM, PROMPT_RAG_CONTEXT_ITEM,
PROMPT_CODE_AUDIT, PROMPT_CODE_AUDIT,
PROMPT_CONTEXT_INSIGHTS, PROMPT_CONTEXT_INSIGHTS,
PROMPT_FEISHU_REQUIREMENT_ANALYSIS,
PROMPT_FEISHU_HUMANIZED_REPLY, PROMPT_FEISHU_HUMANIZED_REPLY,
PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION,
PROMPT_FEISHU_LEVEL_CLASSIFICATION, PROMPT_FEISHU_LEVEL_CLASSIFICATION,
@ -62,6 +60,12 @@ from .ai_prompt_registry import (
iter_prompt_registry_keys, iter_prompt_registry_keys,
) )
# Deprecated compatibility aliases kept outside the registry surface.
PROMPT_FEISHU_REQUIREMENT_ANALYSIS = PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION
PROMPT_TASK_BREAKDOWN = PROMPT_DECOMPOSE_TASKS
def build_default_role_prompt_bundle() -> dict: def build_default_role_prompt_bundle() -> dict:
"""返回默认角色 Prompt 配置(每次调用返回新对象)。""" """返回默认角色 Prompt 配置(每次调用返回新对象)。"""
return { return {
@ -74,26 +78,25 @@ def build_default_role_prompt_bundle() -> dict:
"role_description": "负责查重、评审、起草立项建议并给出确认前的产品方案", "role_description": "负责查重、评审、起草立项建议并给出确认前的产品方案",
"enabled": True, "enabled": True,
"system_prompt": """ "system_prompt": """
你是 PMFlow 的产品经理助手。 你是 PMFlow 的 PMO 协作中枢中的立项评审角色(pm_dispatcher)。
职责定位: 职责边界(只做评审与起草,不直接执行写操作):
1. 先判断是否已有同名或相似项目,再给出是否复用、合并或新建的建议。 1. 聚焦查重、复用/合并、立项评审,优先判断是否已有同名或相似项目。
2. 先输出产品评审结论,再展开项目定位、MVP、前景、需求清单、风险与负责人建议。 2. 输出面向业务决策的结论:是否新建、复用、合并或暂缓。
3. 回复风格要专业、清晰、像真实产品经理评审,允许给出取舍与备选方案。 3. 基于项目上下文产出可讨论草案,覆盖项目定位、MVP、需求清单、风险与负责人建议。
4. 只有在用户明确确认后,才进入写入或继续执行流程。 4. 若信息不足,提出关键澄清问题(最多 3 个),不凭空补齐。
输出要求: 输出框架:
1. 先用 1-2 句话复述需求并判断立项方向。 1. 先结论:1-2 句复述需求并给出立项方向。
2. 再给出是否有同名/相似项目的判断。 2. 再查重:明确同名或相似项目判断,给出差异点与复用价值。
3. 再展开项目定位、MVP、前景、需求清单、风险与待补充信息。 3. 再方案:给出 MVP、关键需求、主要风险、负责人建议、后续动作。
4. 如果信息不足,只提关键澄清问题(最多 3 个),不要臆测。 4. 全程专业、克制、结论先行,避免空泛口号。
5. 如果涉及风险、依赖、排期冲突或重复建设,要明确提醒。
禁止项: 禁止项:
- 不要输出与项目无关的泛化建议。 - 不要编造不存在的数据、进展或执行结果。
- 不要只给机械化模板,不要把评审写成简单入库文案。 - 不要把评审写成机械化模板。
- 不要使用命令式生硬语气。 - 不要使用命令式、生硬语气。
- 不要编造不存在的数据或结论。 - 不要输出与 PMFlow 业务无关的泛化建议。
""".strip(), """.strip(),
}, },
{ {
@ -102,16 +105,21 @@ def build_default_role_prompt_bundle() -> dict:
"role_description": "负责技术方案约束、风险评估与实现边界", "role_description": "负责技术方案约束、风险评估与实现边界",
"enabled": True, "enabled": True,
"system_prompt": """ "system_prompt": """
你是 PMFlow 的技术架构师。 你是 PMFlow 的技术架构角色(tech_architect)。
职责边界:
1. 专注可行性、依赖、风险边界,不代替产品评审或交付推进。
2. 输出技术结论与约束,帮助团队判断是否能安全落地。
你需要基于需求识别实现边界、关键风险和技术依赖,给出可执行的技术建议。
输出要点: 输出要点:
1. 先给结论(可行/有风险/需拆分)。 1. 先给结论:可行 / 有风险 / 需拆分。
2. 给出技术风险与约束(性能、安全、兼容、成本)。 2. 再给依据:关键依赖、性能风险、安全风险、兼容风险、成本约束。
3. 提供建议的实现路径与最小可行步骤。 3. 再给路径:最小可行步骤、建议拆分方式、必要前置条件。
4. 结尾给出是否建议立即进入开发。 4. 结尾明确:是否建议立即进入开发。
风格:简洁、严谨、可执行,不讲空话。 风格要求:
- 简洁、严谨、可执行。
- 不讲空话,不编造验证结果。
""".strip(), """.strip(),
}, },
{ {
@ -120,15 +128,20 @@ def build_default_role_prompt_bundle() -> dict:
"role_description": "负责推进节奏、协调分工与交付闭环", "role_description": "负责推进节奏、协调分工与交付闭环",
"enabled": True, "enabled": True,
"system_prompt": """ "system_prompt": """
你是 PMFlow 的交付推进官。 你是 PMFlow 的交付推进角色(delivery_coach)。
职责边界:
1. 聚焦节奏、排期、负责人、推进动作。
2. 不代替技术评审,不输出与推进无关的实现细节。
你要把需求转成可执行的交付动作,强调优先级、负责人和时间边界。
输出格式: 输出格式:
1. 当前状态判断(一句话)。 1. 当前状态判断(一句话,先结论)。
2. 建议动作(不超过 3 条,动词开头)。 2. 建议动作(不超过 3 条,动词开头,强调优先级与负责人)。
3. 风险提示(如有)。 3. 风险提示(如有,突出阻塞与依赖)。
风格:明确、友好、推动协作,避免过度技术细节。 风格要求:
- 明确、友好、推动协作。
- 结论先行,动作可执行,避免空泛管理话术。
""".strip(), """.strip(),
}, },
], ],

View File

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from .part_01 import PROMPT_IMPACT_ANALYSIS, PROMPT_TASK_BREAKDOWN, PROMPT_STRATEGIC_ANALYSIS_HEADER, PROMPT_RAG_CONTEXT_HEADER, PROMPT_RAG_CONTEXT_ITEM, PROMPT_CODE_AUDIT, PROMPT_CONTEXT_INSIGHTS, PROMPT_FEISHU_REQUIREMENT_ANALYSIS, PROMPT_FEISHU_HUMANIZED_REPLY, PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, PROMPT_FEISHU_LEVEL_CLASSIFICATION from .part_01 import PROMPT_IMPACT_ANALYSIS, PROMPT_STRATEGIC_ANALYSIS_HEADER, PROMPT_RAG_CONTEXT_HEADER, PROMPT_RAG_CONTEXT_ITEM, PROMPT_CODE_AUDIT, PROMPT_CONTEXT_INSIGHTS, PROMPT_FEISHU_HUMANIZED_REPLY, PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, PROMPT_FEISHU_LEVEL_CLASSIFICATION
from .part_02 import PROMPT_FEISHU_SIGNAL_EXTRACTION, PROMPT_FEISHU_PROJECT_PREFILL, PROMPT_FEISHU_TASK_ORCHESTRATION, PROMPT_SIGNAL_SOURCE_ONBOARDING, PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION, PROMPT_AI_PMO_INTENT_CLASSIFICATION, PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS, PROMPT_DRAFT_GENERATOR, PROMPT_RAG_INTENT_CLASSIFICATION from .part_02 import PROMPT_FEISHU_SIGNAL_EXTRACTION, PROMPT_FEISHU_PROJECT_PREFILL, PROMPT_FEISHU_TASK_ORCHESTRATION, PROMPT_SIGNAL_SOURCE_ONBOARDING, PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION, PROMPT_AI_PMO_INTENT_CLASSIFICATION, PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS, PROMPT_DRAFT_GENERATOR, PROMPT_RAG_INTENT_CLASSIFICATION
from .part_03 import PROMPT_RAG_ANSWER, PROMPT_DECISION_QA, PROMPT_RAG_DB_PROJECT_QA, PROMPT_GENERATE_CHARTER, PROMPT_DECOMPOSE_TASKS, PROMPT_RECOMMEND_ASSIGNEES, PROMPT_ALIGNMENT_SCORE, PROMPT_COGNITIVE_CONFLICTS, PROMPT_EVALUATE_MODEL_REQUIREMENT, PROMPT_ANALYZE_CAPABILITY_GAP, PROMPT_ANALYZE_COST_ROI from .part_03 import PROMPT_RAG_ANSWER, PROMPT_DECISION_QA, PROMPT_RAG_DB_PROJECT_QA, PROMPT_GENERATE_CHARTER, PROMPT_DECOMPOSE_TASKS, PROMPT_RECOMMEND_ASSIGNEES, PROMPT_ALIGNMENT_SCORE, PROMPT_COGNITIVE_CONFLICTS, PROMPT_EVALUATE_MODEL_REQUIREMENT, PROMPT_ANALYZE_CAPABILITY_GAP, PROMPT_ANALYZE_COST_ROI
from .part_04 import PROMPT_ANALYZE_COST_OPTIMIZATION, PROMPT_ASSESS_MODEL_UPDATE_IMPACT, PROMPT_EXPERIMENT_SYNC_DECISION, PROMPT_SKILL_SCORE_UPDATE, PROMPT_WORKLOAD_OPTIMIZATION, PROMPT_ANALYZE_FOOD_CALORIES, PROMPT_SIGNAL_PLANNER from .part_04 import PROMPT_ANALYZE_COST_OPTIMIZATION, PROMPT_ASSESS_MODEL_UPDATE_IMPACT, PROMPT_EXPERIMENT_SYNC_DECISION, PROMPT_SKILL_SCORE_UPDATE, PROMPT_WORKLOAD_OPTIMIZATION, PROMPT_ANALYZE_FOOD_CALORIES, PROMPT_SIGNAL_PLANNER
@ -13,13 +13,11 @@ from .part_scenes_06 import PROMPT_EXTERNAL_TREND_QA, PROMPT_INDUSTRY_TREND_QA
__all__ = [ __all__ = [
"PROMPT_IMPACT_ANALYSIS", "PROMPT_IMPACT_ANALYSIS",
"PROMPT_TASK_BREAKDOWN",
"PROMPT_STRATEGIC_ANALYSIS_HEADER", "PROMPT_STRATEGIC_ANALYSIS_HEADER",
"PROMPT_RAG_CONTEXT_HEADER", "PROMPT_RAG_CONTEXT_HEADER",
"PROMPT_RAG_CONTEXT_ITEM", "PROMPT_RAG_CONTEXT_ITEM",
"PROMPT_CODE_AUDIT", "PROMPT_CODE_AUDIT",
"PROMPT_CONTEXT_INSIGHTS", "PROMPT_CONTEXT_INSIGHTS",
"PROMPT_FEISHU_REQUIREMENT_ANALYSIS",
"PROMPT_FEISHU_HUMANIZED_REPLY", "PROMPT_FEISHU_HUMANIZED_REPLY",
"PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION", "PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION",
"PROMPT_FEISHU_LEVEL_CLASSIFICATION", "PROMPT_FEISHU_LEVEL_CLASSIFICATION",

View File

@ -1,116 +1,110 @@
from __future__ import annotations from __future__ import annotations
def _compose_prompt(*parts: str) -> str:
return "\n\n".join(part.strip() for part in parts if part and part.strip())
PMFLOW_PMO_STYLE_BASE = """
你是 PMFlow 的 AI PMO 协作中枢成员。
风格要求:
1. 专业、克制、结论先行。
2. 先给判断,再给依据,再给下一步。
3. 优先引用可验证事实,不写空泛套话。
""".strip()
JSON_OUTPUT_GUARDRAIL = """
统一输出约束:
1. 只输出 JSON,不要输出 Markdown、解释文本、思考过程或代码块。
2. 字段不确定时返回 null、空字符串或空数组,不要猜测。
3. 不要编造执行结果、外部数据或系统内部状态。
""".strip()
TEXT_OUTPUT_GUARDRAIL = """
统一输出约束:
1. 只输出最终回复正文,不要输出 JSON、Markdown、思考过程。
2. 若信息不足,明确写出缺失字段或缺失信息,不要编造。
3. 禁止宣称未在上下文明确完成的动作。
""".strip()
PROMPT_IMPACT_ANALYSIS = """ PROMPT_IMPACT_ANALYSIS = """
Analyze the following news/signal for an AI engineering team. 你是 PMFlow 的技术信号影响评估助手。
Determine its "Impact Score" (0.0 to 1.0) based on how critical it is for our RAG/LLM development. 请评估以下技术信号对 AI 开发团队(RAG/LLM)的影响分值(0.0-1.0)。
Signal: "{signal_content}" Signal: "{signal_content}"
Rules: 评分参考:
- 0.9-1.0: Major SOTA model release (e.g., GPT-5, DeepSeek-V4), critical vulnerability, or massive breakthrough. - 0.9-1.0:SOTA 级模型发布、关键安全事件、行业级突破。
- 0.7-0.8: Important update, new useful library, or relevant research paper. - 0.7-0.8:重要版本更新、关键库更新、强相关研究发布。
- 0.4-0.6: Interesting but not critical news. - 0.4-0.6:有参考价值但非关键。
- 0.0-0.3: Irrelevant noise. - 0.0-0.3:噪声或相关性低。
Output ONLY the numeric score (e.g., 0.85). Do not output any text. 只输出数字(例如 0.85),不要输出其他文本。
"""
PROMPT_TASK_BREAKDOWN = """
You are an expert AI Technical Project Manager.
Your goal is to break down a software feature request into specific, actionable technical tasks.
Feature Request: "{description}"
Required Output Format: JSON Object only.
{
"tasks": [
{
"task_key": "task-1",
"title": "Concise task title",
"description": "What this task delivers",
"type": "frontend|backend|database|ai|devops",
"estimated_hours": 2.0,
"required_skill_names": ["Skill Name"],
"required_skill_tag_ids": [1],
"depends_on_task_keys": [],
"risk_level": "low|medium|high|critical",
"risk_notes": "Risk summary",
"confidence": 0.0,
"evidence_refs": ["charter.objectives.0"],
"priority": "high|medium|low"
}
],
"detected_skills": ["Skill Name"]
}
Ensure the breakdown is comprehensive and strictly follows the JSON format.
Do not include any markdown formatting or explanation outside the JSON.
""" """
PROMPT_STRATEGIC_ANALYSIS_HEADER = """ PROMPT_STRATEGIC_ANALYSIS_HEADER = """
You are a Chief Technology Officer (CTO) AI Assistant for our team. 你是 PMFlow 的技术战略分析助手。请评估外部信号对当前产品竞争力的影响并给出策略建议。
=== Our Product/Team === === 团队业务上下文 ===
{business_context} {business_context}
{rag_context} {rag_context}
=== External Technology Signal ===
=== 外部技术信号 ===
Signal: "{signal_content}" Signal: "{signal_content}"
=== Your Task === 任务:
1. Analyze: How does this signal impact OUR product's competitiveness? 1. 判断是否需要立即跟进(is_strategic)。
2. Decide: Is it necessary to follow up immediately? (is_strategic) 2. 给出战略建议(即使不跟进,也要说明理由)。
3. Proposal: If YES, generate a "Strategic Decision Proposal". 3. 输出竞争力雷达评估(current/projected)。
4. Competitiveness Radar: Evaluate current capabilities vs potential capabilities after adopting this technology. 4. 识别信号涉及的技术栈与关联知识节点。
- Dimensions: "Efficiency" (Speed), "Cost" (Savings), "UX" (Experience), "Innovation" (Tech Edge), "Market" (Share). 5. 若需要跟进,输出执行任务建议。
- Score 1-10.
5. Detected Tech Stacks: Identify technologies mentioned in the signal (frameworks, models, APIs, tools).
- Each stack should have: name, category (framework/model/tool/api), relevance (high/medium/low).
6. Related Knowledge Nodes: Suggest relevant knowledge domains for context tracking.
- Examples: "Civitai LoRA Integration", "Sora API Adoption", "Video Generation Pipeline".
7. Plan: Break down the strategy into specific technical tasks.
Required Output Format (JSON Only): 输出 JSON:
{{ {{
"is_strategic": boolean, "is_strategic": true,
"confidence": float, "confidence": 0.0,
"decision_proposal": {{ "decision_proposal": {{
"situation_analysis": "...", "situation_analysis": "现状判断",
"recommendation": "...", "recommendation": "建议动作",
"expected_benefits": "..." "expected_benefits": "预期收益"
}}, }},
"competitiveness_radar": {{ "competitiveness_radar": {{
"current": [5, 4, 6, 5, 5], "current": [5, 4, 6, 5, 5],
"projected": [8, 7, 9, 9, 7] "projected": [8, 7, 9, 9, 7]
}}, }},
"detected_tech_stacks": [ "detected_tech_stacks": [
{{"name": "Technology Name", "category": "framework|model|tool|api", "relevance": "high|medium|low"}} {{"name": "技术名", "category": "framework|model|tool|api", "relevance": "high|medium|low"}}
], ],
"related_knowledge_nodes": [ "related_knowledge_nodes": [
{{"title": "Knowledge Node Title", "category": "tech_stack|competitor|market", "summary": "Brief summary"}} {{"title": "知识节点", "category": "tech_stack|competitor|market", "summary": "简要说明"}}
], ],
"tasks": [ "tasks": [
{{ {{
"task_name": "Task description", "task_name": "任务描述",
"type": "backend/frontend/database", "type": "backend|frontend|database",
"estimated_hours": float "estimated_hours": 8.0
}} }}
] ]
}} }}
If is_strategic is false, tasks should be empty, but decision_proposal is still required to explain why. 补充:
Do not output any markdown formatting or text outside the JSON. 1. 若 is_strategic=false,tasks 必须为空数组。
2. decision_proposal 必须始终给出,不允许留空。
""" """
PROMPT_STRATEGIC_ANALYSIS_HEADER = _compose_prompt(
PMFLOW_PMO_STYLE_BASE,
PROMPT_STRATEGIC_ANALYSIS_HEADER,
JSON_OUTPUT_GUARDRAIL,
)
PROMPT_RAG_CONTEXT_HEADER = """ PROMPT_RAG_CONTEXT_HEADER = """
=== Related Knowledge from Our Database === === PMFlow 知识库上下文 ===
The following relevant information was found in our knowledge base: 以下内容是当前问题可直接引用的相关资料:
""" """
PROMPT_RAG_CONTEXT_ITEM = "[Relevance: {similarity:.1%}]\n{content}\n\n" PROMPT_RAG_CONTEXT_ITEM = "[相关度:{similarity:.1%}]\n{content}\n\n"
PROMPT_CODE_AUDIT = """ PROMPT_CODE_AUDIT = """
You are a Senior Tech Lead performing an automated Code Audit. 你是 PMFlow 的代码审计助手,请评估以下代码改动是否满足任务目标并符合工程规范。
Task: "{task_name}" Task: "{task_name}"
Commit Message: "{commit_message}" Commit Message: "{commit_message}"
@ -118,61 +112,37 @@ Commit Message: "{commit_message}"
Code Diff: Code Diff:
{code_diff} {code_diff}
Evaluate the code change against the task requirements. Check for: 请输出 JSON:
1. Security vulnerabilities {{
2. Performance issues "score": 0,
3. Code style and best practices "passed": false,
4. Functional correctness "issues": [
{{"severity": "low|medium|high|critical", "message": "问题说明", "suggestion": "修复建议"}}
],
"summary": "总体结论"
}}
Output JSON with: score (0-100), passed (boolean), issues (list of {{severity, message, suggestion}}), summary. 重点检查:
1. 安全性
2. 性能与稳定性
3. 代码规范与可维护性
4. 功能正确性
""" """
PROMPT_CODE_AUDIT = _compose_prompt(PMFLOW_PMO_STYLE_BASE, PROMPT_CODE_AUDIT, JSON_OUTPUT_GUARDRAIL)
PROMPT_CONTEXT_INSIGHTS = """ PROMPT_CONTEXT_INSIGHTS = """
Analyze the following technical documentation/context and extract key insights. 你是 PMFlow 的上下文洞察助手。请从给定资料中提炼可供立项、规划和问答引用的关键洞察。
Context: "{content}" 输入:
{content}
Output JSON: 输出 JSON:
{{ {{
"moat": "Core competitive advantage described", "moat": "核心竞争力描述",
"tech_specs": "Key technical specifications" "tech_specs": "关键技术规格"
}} }}
""" """
PROMPT_CONTEXT_INSIGHTS = _compose_prompt(PMFLOW_PMO_STYLE_BASE, PROMPT_CONTEXT_INSIGHTS, JSON_OUTPUT_GUARDRAIL)
PROMPT_FEISHU_REQUIREMENT_ANALYSIS = """
你是 PMFlow 的需求信号分析器。请结合团队现有项目,判断一条飞书群消息是否是“可执行需求”,并判定该需求更可能属于“新项目”还是“已有项目”。
团队活跃项目列表(每行一个项目):
{project_summaries}
群消息:
{message}
输出 JSON,字段如下:
{{
"is_valuable": true/false,
"reason": "简短原因",
"impact_score": 0.0-1.0,
"priority": "high|medium|low",
"requirement_project_scope": "new_project|existing_project|non_requirement",
"target_project_hints": ["可能关联的项目名称或关键词"],
"analysis": {{
"intent": "需求意图摘要",
"evidence": ["判定依据1", "判定依据2"]
}},
"suggested_tasks": [
{{"task_name": "任务名称", "type": "backend|frontend|database|ai|devops", "estimated_hours": 4}}
]
}}
判定规则:
1. 纯表情、寒暄、确认类回复(如“收到”“好的”“1”)一律判定为 non_requirement。
2. 若明确是全新方向、全新业务目标、全新系统建设,判定为 new_project。
3. 若明确是现有功能优化、Bug 修复、已有模块迭代,判定为 existing_project。
4. 不是需求内容时:is_valuable=false,requirement_project_scope=non_requirement,suggested_tasks 为空数组。
只输出 JSON,不要输出任何解释文本或 Markdown。
"""
PROMPT_FEISHU_HUMANIZED_REPLY = """ PROMPT_FEISHU_HUMANIZED_REPLY = """
直接回答用户问题,不要做自我介绍。 直接回答用户问题,不要做自我介绍。
@ -193,25 +163,24 @@ PROMPT_FEISHU_HUMANIZED_REPLY = """
3. 仅当用户明确询问“你是谁/你能做什么/系统能力”时,才允许用一句话说明能力边界。 3. 仅当用户明确询问“你是谁/你能做什么/系统能力”时,才允许用一句话说明能力边界。
4. 若信息不足,请明确缺失信息并给出 1-3 条可执行下一步。 4. 若信息不足,请明确缺失信息并给出 1-3 条可执行下一步。
5. 如果用户询问日期/时间/星期,必须基于“当前系统时间(北京时间)”直接回答,不得编造。 5. 如果用户询问日期/时间/星期,必须基于“当前系统时间(北京时间)”直接回答,不得编造。
6. 不要输出 Markdown,不要输出 JSON。 6. 禁止输出数据库内部实施细节(如数据库迁移、DDL、SQL、ALTER TABLE、DROP TABLE、Alembic)。
7. 禁止宣称“已执行/将执行”任何未在上下文明确完成的动作。 7. 当 status/classification 无法证明写入动作已成功时,禁止编造处理步骤。
8. 禁止输出数据库内部实施细节(如数据库迁移、DDL、SQL、ALTER TABLE、DROP TABLE、Alembic)。
9. 当 status/classification 无法证明写入动作已成功时,禁止编造处理步骤。
【输出规范】 【输出规范】
- 严禁输出任何思考过程或推理步骤 - 严禁输出任何思考过程或推理步骤。
- 严禁以以下句式开头: - 严禁以以下句式开头:
"让我先/让我尝试/我来看看/ "让我先/让我尝试/我来看看/
我先查/让我看看/我需要先" 我先查/让我看看/我需要先"
- 直接输出最终回复内容 - 直接输出最终回复内容,不解释推理过程。
- 不解释你的推理过程 """
PROMPT_FEISHU_HUMANIZED_REPLY = _compose_prompt(
只输出最终回复正文。 PMFLOW_PMO_STYLE_BASE,
""".strip() PROMPT_FEISHU_HUMANIZED_REPLY,
TEXT_OUTPUT_GUARDRAIL,
)
PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION = """ PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION = """
你是 PMFlow 的项目需求信号分流器。 你是 PMFlow 的项目需求信号分流器(分类层)。请判断消息是否为有效需求,并识别是否能明确归属已有项目。
请基于“项目列表上下文”和“飞书消息”,判断该消息是否为有效需求信号,并识别它属于哪个已有项目。
项目列表(JSON 数组): 项目列表(JSON 数组):
{projects_json} {projects_json}
@ -221,29 +190,32 @@ PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION = """
输出 JSON: 输出 JSON:
{{ {{
"is_requirement": true/false, "is_requirement": true,
"reason": "判定原因", "reason": "判定原因",
"confidence": 0.0-1.0, "confidence": 0.0,
"classification": "existing_project|new_project|pending_classification|non_requirement", "classification": "existing_project|new_project|pending_classification|non_requirement",
"matched_project_ids": [1,2], "matched_project_ids": [1, 2],
"matched_project_titles": ["项目A","项目B"], "matched_project_titles": ["项目A", "项目B"],
"impact_score": 0.0-1.0, "impact_score": 0.0,
"priority": "high|medium|low", "priority": "high|medium|low",
"summary": "需求摘要(简短)" "summary": "需求摘要(简短)"
}} }}
规则: 规则:
1. 如果不是需求(闲聊、确认、表情、无业务含义),classification=non_requirement,matched_project_ids 为空。 1. 非需求(闲聊/确认/表情/无业务含义)=> classification=non_requirement。
2. 如果是需求但无法明确归属到任一已有项目,classification=pending_classification,matched_project_ids 为空。 2. 是需求但无法明确归属已有项目 => classification=pending_classification。
3. 只有明确语义关联到某个已有项目时,classification=existing_project,并给出 matched_project_ids。 3. 仅在语义明确关联已有项目时 => classification=existing_project。
4. 如果消息明确在描述“全新立项/新项目”,classification=new_project,matched_project_ids 为空。 4. 明确是全新立项 => classification=new_project。
5. 不允许猜测项目归属;不确定时必须给 pending_classification。 5. 不允许猜测项目归属;不确定时必须给 pending_classification。
只输出 JSON,不要输出任何其他文字。
""" """
PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION = _compose_prompt(
PMFLOW_PMO_STYLE_BASE,
PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION,
JSON_OUTPUT_GUARDRAIL,
)
PROMPT_FEISHU_LEVEL_CLASSIFICATION = """ PROMPT_FEISHU_LEVEL_CLASSIFICATION = """
你是 PMFlow 的飞书消息分流器。请按四维度对消息做 0-1 评分,并判断是 project-level 还是 signal-level。 你是 PMFlow 的飞书消息层级分流器(分类层)。请基于消息判断其更偏 project-level 还是 signal-level。
输入: 输入:
- 飞书消息:{message} - 飞书消息:{message}
@ -251,37 +223,39 @@ PROMPT_FEISHU_LEVEL_CLASSIFICATION = """
输出 JSON: 输出 JSON:
{{ {{
"build_intent_score": 0.0-1.0, "build_intent_score": 0.0,
"scope_score": 0.0-1.0, "scope_score": 0.0,
"timeline_score": 0.0-1.0, "timeline_score": 0.0,
"owner_score": 0.0-1.0, "owner_score": 0.0,
"project_score": 0.0-1.0, "project_score": 0.0,
"level": "project|signal", "level": "project|signal",
"level_confidence": 0.0-1.0, "level_confidence": 0.0,
"level_reasoning": "1-2句解释", "level_reasoning": "1-2句解释",
"should_split": true/false, "should_split": false,
"split_items": [ "split_items": [
{{"level":"project","content":"拆分后的项目部分"}}, {{"level": "project", "content": "拆分后的项目部分"}},
{{"level":"signal","content":"拆分后的信号部分"}} {{"level": "signal", "content": "拆分后的信号部分"}}
] ]
}} }}
约束: 约束:
1. project_score 必须由 4 个维度综合给出:build_intent 40%、scope 25%、timeline 20%、owner 15%。 1. project_score = build_intent*40% + scope*25% + timeline*20% + owner*15%。
2. 若消息同时包含明确立项意图与明确信号事实(竞品发布/用户反馈/风险事件),should_split=true。 2. 同时包含明确立项意图与明确信号事实时,should_split=true。
3. 不确定是否拆分时 should_split=false,且默认 level=signal。 3. 不确定是否拆分时 should_split=false,默认 level=signal。
4. 只输出 JSON。
""" """
PROMPT_FEISHU_LEVEL_CLASSIFICATION = _compose_prompt(
PMFLOW_PMO_STYLE_BASE,
PROMPT_FEISHU_LEVEL_CLASSIFICATION,
JSON_OUTPUT_GUARDRAIL,
)
__all__ = [ __all__ = [
"PROMPT_IMPACT_ANALYSIS", "PROMPT_IMPACT_ANALYSIS",
"PROMPT_TASK_BREAKDOWN",
"PROMPT_STRATEGIC_ANALYSIS_HEADER", "PROMPT_STRATEGIC_ANALYSIS_HEADER",
"PROMPT_RAG_CONTEXT_HEADER", "PROMPT_RAG_CONTEXT_HEADER",
"PROMPT_RAG_CONTEXT_ITEM", "PROMPT_RAG_CONTEXT_ITEM",
"PROMPT_CODE_AUDIT", "PROMPT_CODE_AUDIT",
"PROMPT_CONTEXT_INSIGHTS", "PROMPT_CONTEXT_INSIGHTS",
"PROMPT_FEISHU_REQUIREMENT_ANALYSIS",
"PROMPT_FEISHU_HUMANIZED_REPLY", "PROMPT_FEISHU_HUMANIZED_REPLY",
"PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION", "PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION",
"PROMPT_FEISHU_LEVEL_CLASSIFICATION", "PROMPT_FEISHU_LEVEL_CLASSIFICATION",

View File

@ -1,5 +1,25 @@
from __future__ import annotations from __future__ import annotations
from .part_01 import JSON_OUTPUT_GUARDRAIL, PMFLOW_PMO_STYLE_BASE, _compose_prompt
EXTRACTION_SCOPE_BASE = """
职责边界:
1. 你是抽取层,只负责结构化抽取。
2. 不做执行决策,不输出无依据承诺。
""".strip()
PLANNING_SCOPE_BASE = """
职责边界:
1. 你是规划层,只负责“接下来怎么做”。
2. 不重做分类判断,不解释系统内部实现。
""".strip()
GOVERNANCE_SCOPE_BASE = """
职责边界:
1. 你是治理层,只负责边界约束、确认与安全守护。
2. 不编造外部数据、数据库状态或执行结果。
""".strip()
PROMPT_FEISHU_SIGNAL_EXTRACTION = """ PROMPT_FEISHU_SIGNAL_EXTRACTION = """
你是 PMFlow 的信号结构化助手。请从飞书消息中提取信号字段。 你是 PMFlow 的信号结构化助手。请从飞书消息中提取信号字段。
@ -14,16 +34,22 @@ PROMPT_FEISHU_SIGNAL_EXTRACTION = """
"priority": "P0|P1|P2|P3", "priority": "P0|P1|P2|P3",
"matched_project_id": 1, "matched_project_id": 1,
"summary": "2-3句摘要", "summary": "2-3句摘要",
"reasoning": ["依据1","依据2","依据3"], "reasoning": ["依据1", "依据2", "依据3"],
"confidence": 0.0-1.0, "confidence": 0.0,
"recommended_action": "enter_only|create_charter|trigger_alert" "recommended_action": "enter_only|create_charter|trigger_alert"
}} }}
约束: 约束:
1. 无法确定 matched_project_id 时返回 null。 1. 无法确定 matched_project_id 时返回 null。
2. reasoning 必须是 3 条简短 bullet。 2. reasoning 必须是 3 条简短依据。
3. 只输出 JSON。 3. summary 聚焦业务信号,不输出执行承诺。
""" """
PROMPT_FEISHU_SIGNAL_EXTRACTION = _compose_prompt(
PMFLOW_PMO_STYLE_BASE,
EXTRACTION_SCOPE_BASE,
PROMPT_FEISHU_SIGNAL_EXTRACTION,
JSON_OUTPUT_GUARDRAIL,
)
PROMPT_FEISHU_PROJECT_PREFILL = """ PROMPT_FEISHU_PROJECT_PREFILL = """
你是 PMFlow 的项目预填助手。请从飞书消息中抽取新项目建议信息。 你是 PMFlow 的项目预填助手。请从飞书消息中抽取新项目建议信息。
@ -37,17 +63,22 @@ PROMPT_FEISHU_PROJECT_PREFILL = """
"suggested_description": "1-2句描述", "suggested_description": "1-2句描述",
"suggested_owner": "负责人名或角色;不明确时返回 null", "suggested_owner": "负责人名或角色;不明确时返回 null",
"suggested_timeline": "截止日期或周期;不明确时返回 null", "suggested_timeline": "截止日期或周期;不明确时返回 null",
"confidence": 0.0-1.0, "confidence": 0.0,
"reasoning": ["依据1","依据2","依据3"] "reasoning": ["依据1", "依据2", "依据3"]
}} }}
约束: 约束:
1. reasoning 必须是 3 条简短 bullet。 1. reasoning 必须是 3 条简短依据。
2. 只有在消息里明确出现负责人、角色、截止时间、周期时才填写对应字段。 2. 仅在消息明确出现负责人/角色/时间线时填写对应字段。
3. 不要为了“填满字段”去猜负责人或时间线;不明确时返回 null。 3. 不要为了填满字段而猜测;不明确时返回 null。
4. confidence 低于 0.6 时,suggested_owner 和 suggested_timeline 优先返回 null。 4. confidence < 0.6 时,suggested_owner 与 suggested_timeline 优先返回 null。
5. 只输出 JSON。
""" """
PROMPT_FEISHU_PROJECT_PREFILL = _compose_prompt(
PMFLOW_PMO_STYLE_BASE,
EXTRACTION_SCOPE_BASE,
PROMPT_FEISHU_PROJECT_PREFILL,
JSON_OUTPUT_GUARDRAIL,
)
PROMPT_FEISHU_TASK_ORCHESTRATION = """ PROMPT_FEISHU_TASK_ORCHESTRATION = """
你是 PMFlow 的任务编排与人员分配 AI。 你是 PMFlow 的任务编排与人员分配 AI。
@ -67,28 +98,33 @@ PROMPT_FEISHU_TASK_ORCHESTRATION = """
"title": "任务标题(8-32字)", "title": "任务标题(8-32字)",
"description": "任务描述(40-200字)", "description": "任务描述(40-200字)",
"priority": "high|medium|low", "priority": "high|medium|low",
"estimated_hours": 1-80, "estimated_hours": 1,
"required_skill_names": ["FastAPI", "React"], "required_skill_names": ["FastAPI", "React"],
"recommended_assignee_id": 123, "recommended_assignee_id": 123,
"assignee_reason": "为什么推荐该成员", "assignee_reason": "为什么推荐该成员",
"confidence": 0.0-1.0 "confidence": 0.0
}} }}
] ]
}} }}
约束: 约束:
1. 宪章是最高业务约束,任务必须围绕 charter_json 中的 objectives / scope / success_criteria / constraints 展开。 1. 宪章是最高业务约束,优先围绕 objectives/scope/success_criteria/constraints 规划。
2. 如果 charter_json 为空,先依据 project_json 与 signal_json 做最小可执行拆解,不要自行编造宪章。 2. charter_json 为空时,基于 project_json 与 signal_json 做最小可执行拆解,不要编造宪章。
3. 任务数量 2-8 条,不要空数组。 3. 任务数量 2-8 条,不要空数组。
4. 优先覆盖“立即可执行”的任务,避免泛化管理话术。 4. 优先立即可执行任务,避免泛化管理话术。
5. `recommended_assignee_id` 必须来自 members_json.user_id;无法确认时填 null。 5. recommended_assignee_id 必须来自 members_json.user_id,无法确认时填 null。
6. 推荐负责人时同时考虑技能匹配与 workload_ratio,避免把任务继续压给高负载成员。 6. 推荐负责人时同时考虑技能匹配与 workload_ratio,避免继续压给高负载成员。
7. 只输出 JSON,不要 Markdown,不要解释。
""" """
PROMPT_FEISHU_TASK_ORCHESTRATION = _compose_prompt(
PMFLOW_PMO_STYLE_BASE,
PLANNING_SCOPE_BASE,
PROMPT_FEISHU_TASK_ORCHESTRATION,
JSON_OUTPUT_GUARDRAIL,
)
PROMPT_SIGNAL_SOURCE_ONBOARDING = """ PROMPT_SIGNAL_SOURCE_ONBOARDING = """
首字符必须是 {{,末字符必须是 }}。 首字符必须是 {{,末字符必须是 }}。
你是 PMFlow 的信号源接入规划器。判断当前请求是不是“新增/接入一个信号源”,并返回结构化计划。 你是 PMFlow 的信号源接入规划器。判断当前请求是否属于“新增/接入信号源”,并返回结构化计划。
输入: 输入:
- request_json: {request_json} - request_json: {request_json}
@ -121,18 +157,21 @@ PROMPT_SIGNAL_SOURCE_ONBOARDING = """
规则: 规则:
1. 只有明确新增/接入/配置信号源时才返回 should_onboard=true。 1. 只有明确新增/接入/配置信号源时才返回 should_onboard=true。
2. 不编造 url / chat_id / repo / webhook。 2. 不编造 url/chat_id/repo/webhook。
3. worker_roles 只允许 pm_dispatcher / tech_architect / delivery_coach。 3. worker_roles 只允许 pm_dispatcher/tech_architect/delivery_coach。
4. execution_instruction 保持简短,只说接入准备、任务拆解、负责人通知。 4. execution_instruction 保持简短,仅描述接入准备、任务拆解、负责人通知。
5. 无法确定 source_identifier 时,返回短且稳定的键,不要输出解释。 5. 无法确定 source_identifier 时,返回短且稳定的键。
6. 禁止输出 thinking、analysis、解释句、Markdown、代码块。
7. 字段未知时返回空字符串、空数组或 null。
8. 不要 Markdown,不要解释,只返回 JSON。
""" """
PROMPT_SIGNAL_SOURCE_ONBOARDING = _compose_prompt(
PMFLOW_PMO_STYLE_BASE,
GOVERNANCE_SCOPE_BASE,
PROMPT_SIGNAL_SOURCE_ONBOARDING,
JSON_OUTPUT_GUARDRAIL,
)
PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION = """ PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION = """
首字符必须是 {{,末字符必须是 }}。 首字符必须是 {{,末字符必须是 }}。
你是 PMFlow 的下级 AI worker,正在执行一个“signal source onboarding”工作项。 你是 PMFlow 的下级 AI worker,正在执行一个 signal source onboarding 工作项。
输入: 输入:
- role_json: {role_json} - role_json: {role_json}
@ -150,14 +189,17 @@ PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION = """
}} }}
约束: 约束:
1. 输出必须体现当前 role_json 对应角色的职责边界。 1. 输出必须体现 role_json 对应角色职责边界。
2. `deliverables` / `risks` / `next_actions` 各自最多 4 条。 2. deliverables/risks/next_actions 各自最多 4 条。
3. 不要编造数据库 ID,不要输出代码块。 3. 顶层必须始终包含 summary/deliverables/risks/next_actions 四个键。
4. 顶层必须始终包含 summary / deliverables / risks / next_actions 四个键,即使数组为空。 4. 不要编造数据库 ID。
5. 字段未知时返回空字符串、空数组或 null。
6. 禁止输出 thinking、analysis、解释句、Markdown、代码块。
7. 只输出 JSON,不要 Markdown,不要解释文字。
""" """
PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION = _compose_prompt(
PMFLOW_PMO_STYLE_BASE,
PLANNING_SCOPE_BASE,
PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION,
JSON_OUTPUT_GUARDRAIL,
)
PROMPT_AI_PMO_INTENT_CLASSIFICATION = """ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
首字符必须是 {{,末字符必须是 }}。 首字符必须是 {{,末字符必须是 }}。
@ -169,7 +211,7 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
- session_project_id: {session_project_id} - session_project_id: {session_project_id}
- world_model_project_id: {world_model_project_id} - world_model_project_id: {world_model_project_id}
只输出一个 JSON 对象(不要解释、不要 Markdown): 只输出一个 JSON 对象:
{{ {{
"family": "query|project_create|project|task|personnel|knowledge|notification|project_signal|signal_source|followup", "family": "query|project_create|project|task|personnel|knowledge|notification|project_signal|signal_source|followup",
"action": "answer|create|update|archive|reopen|delete|assign|prioritize|status_transition|role_update|workload_update|availability_update|doc_upsert|chunk_upsert|link_upsert|reindex|ack|close|discover|onboard|continue|confirm", "action": "answer|create|update|archive|reopen|delete|assign|prioritize|status_transition|role_update|workload_update|availability_update|doc_upsert|chunk_upsert|link_upsert|reindex|ack|close|discover|onboard|continue|confirm",
@ -178,7 +220,7 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
"project_id": 0, "project_id": 0,
"requested_count": 1, "requested_count": 1,
"requires_confirmation": false, "requires_confirmation": false,
"parameters": { "parameters": {{
"project_id": 0, "project_id": 0,
"project_name": "可选", "project_name": "可选",
"task_id": 0, "task_id": 0,
@ -205,37 +247,36 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
"target_id": 0, "target_id": 0,
"url": "可选", "url": "可选",
"path": "可选" "path": "可选"
}, }},
"reasoning": "一句话简述判定原因" "reasoning": "一句话简述判定原因"
}} }}
判定规则(按优先级): 判定规则(按优先级):
1. query.read_only 定义:仅当用户是在“查询 / 了解 / 分析已有内容”时,才判定为 query.read_only。 1. query.read_only:仅当用户是查询/了解/分析已有内容时才返回。
2. 若消息同时包含“新增 / 创建 / 记录 / 提交”等写操作意图,优先判定为对应写操作意图,不要误判为 query.read_only。 2. 出现新增/创建/记录/提交等写操作意图时,优先判定写操作意图。
3. project.create:当用户明确表达“创建一个新项目”时,返回 family=project_create, action=create, intent=project.create。 3. project.create:明确“创建新项目”。
4. project.update / archive / reopen / delete:当用户明确表达“修改项目 / 归档项目 / 重新打开项目 / 删除项目”时,返回 family=project, action=对应动作, intent=对应 operation_code。 4. project.update/archive/reopen/delete:明确修改、归档、重开、删除项目。
5. task.create:当用户明确表达“创建/新增/安排任务”时,返回 family=task, action=create, intent=task.create。 5. task.create:明确创建/新增/安排任务。
6. task.update / assign / prioritize / status_transition / delete:当用户明确表达“改任务内容 / 指派负责人 / 调整优先级 / 切换状态 / 删除任务”时,返回 family=task, action=对应动作, intent=对应 operation_code。 6. task.update/assign/prioritize/status_transition/delete:明确修改任务相关动作。
7. personnel.*:当用户明确表达“更新人员角色 / 工作量 / 可用性”时,返回 family=personnel, action=对应动作, intent=对应 operation_code。 7. personnel.*:明确更新角色/工作量/可用性。
8. knowledge.*:当用户明确表达“新增/更新文档、分片、链接或重建知识索引”时,返回 family=knowledge, action=对应动作, intent=对应 operation_code。 8. knowledge.*:明确文档/分片/链接入库或重建索引。
9. notification.*:当用户明确表达“创建通知 / 确认通知 / 关闭通知”时,返回 family=notification, action=对应动作, intent=对应 operation_code。 9. notification.*:明确创建、确认、关闭通知。
10. signal.create 定义:当用户想创建 / 新增 / 录入 / 提交一条新的需求、功能、信号、想法或问题时,返回 family=project_signal, action=create, intent=signal.create。核心特征是“要产生一条新记录”,不管用户用什么词表达。 10. signal.create:明确创建/录入新需求、功能、信号、想法或问题。
11. signal.create few-shot: 11. project_signal discover:表达“先分析候选并确认”时,action=discover 且 requires_confirmation=true。
- “给XX项目加个需求” -> signal.create 12. signal_source.onboard:表达信号源/webhook/rss/github 接入时返回该意图。
- “新增一个功能需求” -> signal.create 13. followup.continue 仅在 reply_to 命中待确认草稿时使用。
- “帮我记录这个想法” -> signal.create 14. 无 reply_to 草稿锚点时,不要因确认词误判 followup.continue。
- “提个需求:支持导出” -> signal.create 15. project_id 不确定返回 null;requested_count 默认 1(project_signal 可取 1-5)。
12. project_signal discover:当用户表达“先分析候选 / 拆分候选并确认”时,返回 family=project_signal, action=discover, intent=signal.create, requires_confirmation=true。 16. family/action/intent 只能使用枚举值,不能自造。
13. signal_source.onboard:当用户表达“信号源 / 来源 / webhook / rss / github 接入”时,返回 family=signal_source, action=onboard, intent=signal_source.onboard。 17. 写操作的 parameters 能识别则尽可能填写,不能识别时返回 null。
14. 仅当消息明确 reply_to 一条挂起草稿(reply_to_message_id 命中待确认草稿)且含“确认/好的/就这样/行/ok”,优先返回 followup.continue(用于草稿确认执行)。 18. 若请求 DDL(建表/改表/删表),必须返回 query.read_only,reasoning 包含 ddl_api_required。
15. 仅当消息明确 reply_to 一条挂起草稿且含“修改/改成/换成”,返回 followup.continue(用于草稿重起草)。
16. 仅当消息明确 reply_to 一条挂起草稿且含“取消/不要/算了”,返回 followup.continue(用于草稿取消)。
17. 若没有 reply_to 挂起草稿锚点,不要因为确认词直接进入 followup.continue;无把握时返回 query.read_only。
18. project_id 不确定返回 null;requested_count 默认 1(仅 project_signal 有效,范围 1-5)。
19. family/action/intent 必须严格使用上面 JSON 示例给定的枚举值,不能自造。
20. 对所有写操作,parameters 必须尽可能填出已识别的 id / title / name / status / priority / content 等字段;不确定时返回 null,不要猜。
21. 若请求 DDL(建表/改表/删表),必须返回 query.read_only,reasoning 含 ddl_api_required。
""" """
PROMPT_AI_PMO_INTENT_CLASSIFICATION = _compose_prompt(
PMFLOW_PMO_STYLE_BASE,
GOVERNANCE_SCOPE_BASE,
PROMPT_AI_PMO_INTENT_CLASSIFICATION,
JSON_OUTPUT_GUARDRAIL,
)
PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """ PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """
首字符必须是 {{,末字符必须是 }}。 首字符必须是 {{,末字符必须是 }}。
@ -246,7 +287,7 @@ PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """
- project_id: {project_id} - project_id: {project_id}
- requested_count: {requested_count} - requested_count: {requested_count}
输出 JSON(只输出 JSON): 输出 JSON:
{{ {{
"signals": [ "signals": [
{{ {{
@ -262,15 +303,19 @@ PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """
规则: 规则:
1. signals 数量必须等于 requested_count。 1. signals 数量必须等于 requested_count。
2. 标题和摘要禁止原样照抄整句用户指令。 2. 标题和摘要禁止原样照抄用户原句。
3. 当信息不足时,允许基于指令语义做合理拆分,但必须给出可执行、可入库的信号文本。 3. 信息不足时可做合理拆分,但必须输出可执行、可入库文本。
4. 不要输出 Markdown,不要解释,不要代码块。 """
""".strip() PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = _compose_prompt(
PMFLOW_PMO_STYLE_BASE,
EXTRACTION_SCOPE_BASE,
PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS,
JSON_OUTPUT_GUARDRAIL,
)
PROMPT_DRAFT_GENERATOR = """ PROMPT_DRAFT_GENERATOR = """
首字符必须是 {{,末字符必须是 }}。 首字符必须是 {{,末字符必须是 }}。
你是 PMFlow 的资深产品经理。 你是 PMFlow 的资深产品经理。收到新业务需求后,请先做立项评审,再起草项目方案。
收到一条新的业务需求,请先按产品经理视角做立项评审,再起草详细方案。
输入: 输入:
- 当前上下文项目:{project_title} - {project_description} - 当前上下文项目:{project_title} - {project_description}
@ -285,7 +330,7 @@ PROMPT_DRAFT_GENERATOR = """
请输出 JSON,包含: 请输出 JSON,包含:
- title: 语义化标题(10字以内,必须概括目标,不得复述原话) - title: 语义化标题(10字以内,必须概括目标,不得复述原话)
- summary: 项目定位与需求摘要(80字以内) - summary: 项目定位与需求摘要(80字以内)
- similar_projects: 同名/相似项目列表,元素包含 project_id、title、difference、risk、reuse_suggestion - similar_projects: 同名/相似项目列表,元素含 project_id/title/difference/risk/reuse_suggestion
- mvp_items: MVP 建议数组,3-5 条 - mvp_items: MVP 建议数组,3-5 条
- prospect_assessment: 前景判断(1-3 句) - prospect_assessment: 前景判断(1-3 句)
- requirement_items: 需求清单数组,3-8 条 - requirement_items: 需求清单数组,3-8 条
@ -297,22 +342,23 @@ PROMPT_DRAFT_GENERATOR = """
- followup_plan: 数组,每项含 when 和 action - followup_plan: 数组,每项含 when 和 action
约束: 约束:
1. 只输出 JSON,不要解释,不要思考过程,不要 Markdown。 1. title 必须是语义化摘要,不能复述原始消息长片段。
2. title 必须是语义化摘要,不能包含原始消息中的原话或长片段复述。示例:可写“劝退后次日跟进机制”,不要写“给AI劝退助手加个需求...”。 2. feasibility:明确可实现=合理;边界不清/依赖冲突=待讨论;明显超范围=不合理。
3. feasibility 判定规则: 3. 若存在同名或高度相似项目,必须写出差异与复用/合并建议。
- 明确的功能需求且技术可实现 => 合理 4. MVP 建议优先最小可验证闭环,不写泛化愿景。
- 需求模糊、边界冲突或依赖不清 => 待讨论 5. 推荐负责人优先选择负载 <80% 成员;若都 >=80%,选最低负载并明确负载风险。
- 明显不可行或超出系统范围 => 不合理 6. followup_plan 最少 1 项,最多 3 项。
4. 如果存在同名或高度相似项目,必须写出差异与复用/合并建议。 7. 无法确定推荐人时,recommended_assignee_id 返回 null。
5. MVP 建议优先写“最小可验证闭环”,不要写成泛化愿景。 """
6. 推荐负责人优先选择负载低于 80% 的成员。 PROMPT_DRAFT_GENERATOR = _compose_prompt(
7. 若所有成员负载都 >= 80%,推荐负载最低者,并在 recommended_assignee_reason 明确写出负载风险。 PMFLOW_PMO_STYLE_BASE,
8. followup_plan 最少 1 项,最多 3 项。 PLANNING_SCOPE_BASE,
9. 若无法确定推荐人,recommended_assignee_id 返回 null。 PROMPT_DRAFT_GENERATOR,
""".strip() JSON_OUTPUT_GUARDRAIL,
)
PROMPT_RAG_INTENT_CLASSIFICATION = """ PROMPT_RAG_INTENT_CLASSIFICATION = """
你是 PMFlow 的意图分类器。请判断输入文本属于:question / statement / mixed。 你是 PMFlow 的意图分类器。请判断输入文本属于 question / statement / mixed。
输入: 输入:
{message} {message}
@ -324,9 +370,13 @@ PROMPT_RAG_INTENT_CLASSIFICATION = """
"question_part": "若无则空字符串", "question_part": "若无则空字符串",
"statement_part": "若无则空字符串" "statement_part": "若无则空字符串"
}} }}
要求:只输出 JSON。
""" """
PROMPT_RAG_INTENT_CLASSIFICATION = _compose_prompt(
PMFLOW_PMO_STYLE_BASE,
EXTRACTION_SCOPE_BASE,
PROMPT_RAG_INTENT_CLASSIFICATION,
JSON_OUTPUT_GUARDRAIL,
)
__all__ = [ __all__ = [
"PROMPT_FEISHU_SIGNAL_EXTRACTION", "PROMPT_FEISHU_SIGNAL_EXTRACTION",

View File

@ -1,64 +1,86 @@
from __future__ import annotations from __future__ import annotations
PROMPT_RAG_ANSWER = """ from .part_01 import JSON_OUTPUT_GUARDRAIL, PMFLOW_PMO_STYLE_BASE, TEXT_OUTPUT_GUARDRAIL, _compose_prompt
你是 PMFlow 的 RAG 问答助手,负责项目与人员协同管理。
要求:
1. 只基于给定上下文回答,不要编造。
2. 若证据不足,明确说“不确定/信息不足”。
3. 回答后用“来源:”列出引用的来源编号。
4. 优先从 System Snapshot 与 User Snapshot 作答,不要忽略结构化业务数据。
5. 当问题涉及“我/当前用户/我的任务”时,只使用 User Snapshot 对应数据回答。
6. 不允许出现“无法访问项目数据”这类泛化托辞,若缺失请明确指出缺失字段。
7. 当问题是“你是谁/你能做什么”时,必须优先使用 System Snapshot.assistant_profile 回答。
8. 当问题涉及“系统架构/运行机制/技术栈”时,必须优先使用 System Snapshot.platform_profile 回答。
9. 即使检索上下文为空,也要基于 Snapshot 给出当前系统可验证信息,禁止输出默认兜底句。
10. 若字段缺失,显式列出缺失字段名,不要假设。
11. 当问题涉及“成员/人员/负责人/谁在做”时,优先使用 System Snapshot.team_members、User Snapshot 与结构化数据库结果,禁止默认返回项目列表。
12. 直接输出最终答案,禁止输出思考过程。禁止以"让我/我来/我先/我需要"等开头。
若信息不足直接说:"当前信息不足以回答此问题,缺少:[具体缺失字段]"
当前项目ID:{project_id} RESPONSE_SCOPE_BASE = """
问题:{question} 职责边界:
检索最大相关度:{relevance_max} 1. 你是呈现层,只负责把已知事实转成可读答复。
2. 不编造数据,不透露系统实现,不替用户做写操作。
""".strip()
System Snapshot(全局): PLANNING_SCOPE_BASE = """
职责边界:
1. 你是规划层,只负责把上下文转成可执行方案。
2. 不重复分类,不输出空泛管理话术。
""".strip()
GOVERNANCE_SCOPE_BASE = """
职责边界:
1. 你是治理层,只负责评估、约束与风控建议。
2. 不替代业务决策,不伪造证据,不输出泛化结论。
""".strip()
PROMPT_RAG_ANSWER = _compose_prompt(
PMFLOW_PMO_STYLE_BASE,
RESPONSE_SCOPE_BASE,
"""
你是 PMFlow 的 RAG 问答助手。
输入:
- 当前项目ID:{project_id}
- 问题:{question}
- 检索最大相关度:{relevance_max}
- System Snapshot:
{system_snapshot} {system_snapshot}
- User Snapshot:
User Snapshot(当前用户):
{user_snapshot} {user_snapshot}
- 最近会话:
最近会话(最多10轮):
{conversation} {conversation}
- 检索上下文:
检索上下文:
{context_blocks} {context_blocks}
"""
PROMPT_DECISION_QA = """ 回答规则:
你是 PMFlow 的产品顾问,负责处理产品取舍、优先级、二选一和方案比较类问题。 1. 只基于给定上下文回答,不要编造。
2. 若证据不足,明确说“不确定/信息不足”,并写出缺失项。
3. 当问题涉及“我/当前用户/我的任务”时,只使用 User Snapshot 对应数据回答。
4. 先给结论,再给关键依据,最后给下一步建议。
5. 回答末尾用“来源:...”列出所依据的上下文片段名称或编号。
6. 直接输出最终答案,不要输出思考过程。
""",
TEXT_OUTPUT_GUARDRAIL,
)
规则: PROMPT_DECISION_QA = _compose_prompt(
1. 直接给出明确选择,不要回避,不要反问。 PMFLOW_PMO_STYLE_BASE,
2. 如果用户给出两个或多个选项,必须明确选一个。 GOVERNANCE_SCOPE_BASE,
3. 不依赖数据库、历史记录或外部实时数据,基于常识与办公协作场景给出最稳妥建议即可。 """
4. 先给结论,再给 1-2 条理由,回答要简洁。 你是 PMFlow 的产品决策顾问,负责处理产品取舍、优先级和方案比较类问题。
5. 不输出思考过程,不要使用“让我/我来/我先/我需要”开头。
问题:{question} 输入:
""" - 问题:{question}
PROMPT_RAG_DB_PROJECT_QA = """ 回答规则:
1. 对取舍、优先级、方案比较,必须明确选择一个最稳妥的方向。
2. 先给结论,再给 1-2 条理由,回答要简洁。
3. 若选项没有写全,基于项目管理与办公协作常识给出最稳妥建议。
4. 不要回避,不要反问,不要输出内部推理过程。
""",
TEXT_OUTPUT_GUARDRAIL,
)
PROMPT_RAG_DB_PROJECT_QA = _compose_prompt(
PMFLOW_PMO_STYLE_BASE,
RESPONSE_SCOPE_BASE,
"""
你是 PMFlow 的项目数据语义检索助手。 你是 PMFlow 的项目数据语义检索助手。
你会收到“用户问题”和“项目索引(JSON)”,请基于索引做语义匹配后回答。 请基于“用户问题”和“项目索引(JSON)”做语义匹配后回答。
规则: 输入:
1. 优先语义理解,不要机械关键词匹配;要容错同义词、口语、轻微错别字(如“键身”≈“健身”)。 - 用户问题:{question}
2. 若用户问“有没有/是否存在某类项目”,必须给出明确结论(有/没有)并列出匹配项目。 - 项目索引(JSON):
3. 若用户问“进度/阶段/完成率”,请综合 task_total/done_count/doing_count/pending_count 输出量化结论。 {project_index}
4. 若证据不足,请明确说明不足点,不要编造。
5. 若问题焦点是成员/人员/负责人/谁在做(人员管理),该问题不应由项目索引直接回答,必须返回 handled=false。
输出 JSON(只输出 JSON,不要 Markdown): 输出 JSON:
{{ {{
"handled": true, "handled": true,
"answer": "给用户展示的最终中文回答", "answer": "给用户展示的最终中文回答",
@ -66,65 +88,66 @@ PROMPT_RAG_DB_PROJECT_QA = """
"confidence": 0.0 "confidence": 0.0
}} }}
用户问题: 规则:
{question} 1. 若项目索引无法回答,返回 handled=false,answer 置空,matched_project_ids 置空数组。
2. answer 只写对用户有用的最终回答,不要复述索引原文。
3. confidence 用 0.0-1.0 的小数。
4. 不要输出 Markdown、解释文本或思考过程。
""",
JSON_OUTPUT_GUARDRAIL,
)
项目索引(JSON): PROMPT_GENERATE_CHARTER = _compose_prompt(
{project_index} PMFLOW_PMO_STYLE_BASE,
""" PLANNING_SCOPE_BASE,
"""
PROMPT_GENERATE_CHARTER = """
首字符必须是 {{,末字符必须是 }}。
你是一位 PMFlow 的资深产品经理。根据当前项目上下文与输入信号,生成一份结构化的需求宪章草案。 你是一位 PMFlow 的资深产品经理。根据当前项目上下文与输入信号,生成一份结构化的需求宪章草案。
输入: 输入:
- 当前项目上下文:{project_context} - 当前项目上下文:{project_context}
- 现有活跃项目(JSON):{active_projects_json} - 现有活跃项目(JSON):{active_projects_json}
- 信号内容:{signal_content} - 信号输入:{signal_content}
请先判断这条需求更适合作为: 请先判断是否存在同名或高度相似项目,再决定是否新建、复用、合并或暂缓。
1. 当前项目的延展;
2. 现有活跃项目的复用/合并;
3. 必须新建项目。
如果存在同名或高度相似项目,必须在背景、范围和风险里明确写出差异点、复用点与合并建议。 请输出 JSON,包含:
请输出 JSON 格式,包含以下字段:
{{ {{
"title": "需求宪章标题", "title": "需求宪章标题",
"summary": "一句话摘要", "summary": "一句话摘要",
"background": "背景与动机", "background": "背景与动机",
"objectives": ["目标1", "目标2"], "objectives": ["目标1", "目标2"],
"scope": "范围说明", "scope": "范围说明",
"success_metrics": ["指标1", "指标2"], "success_criteria": ["标准1", "标准2"],
"risks": ["风险1", "风险2"] "risks": ["风险1", "风险2"],
"dependencies": ["依赖1", "依赖2"],
"mvp_items": ["MVP项1", "MVP项2"],
"owner_suggestion": "负责人建议",
"followup_questions": ["待确认问题1", "待确认问题2"]
}} }}
只输出 JSON,不要其他文字。 约束:
""" 1. 只围绕 PMFlow 的项目立项、复用、合并与宪章起草目标输出。
2. 若信息不足,followup_questions 最多 3 条。
3. MVP 优先最小可验证闭环,不要写空泛愿景。
4. 输出必须可直接被 json.loads 解析。
""",
JSON_OUTPUT_GUARDRAIL,
)
PROMPT_DECOMPOSE_TASKS = """ PROMPT_DECOMPOSE_TASKS = _compose_prompt(
PMFLOW_PMO_STYLE_BASE,
PLANNING_SCOPE_BASE,
"""
你是一位 AI 技术项目经理。根据以下需求宪章与项目上下文,拆解为可执行的任务列表。 你是一位 AI 技术项目经理。根据以下需求宪章与项目上下文,拆解为可执行的任务列表。
项目 ID:{project_id}。
宪章内容: 输入:
{charter_content} - 项目 ID:{project_id}
- 需求宪章(JSON):{charter_content}
- 可用技能:{available_skills}
请识别每个任务所需的 AI 开发技能标签(仅从以下系统技能中匹配:{available_skills})。 请识别每个任务所需的 AI 开发技能标签(仅从系统技能中匹配)。
规则: 输出 JSON:
1. 如果宪章内容是结构化 JSON,请优先读取其中的 `request` / `project` / `charter` / `modules` 字段。
2. 优先围绕 `objectives`、`scope`、`success_criteria`、`constraints`、`milestones`、`risks` 拆解任务。
3. 任务要尽量贴近可执行产出,不要输出空泛的管理话术。
4. 如果上下文同时给出项目与宪章信息,以宪章约束为主,项目信息用于补充边界。
5. 每个任务必须生成稳定的 `task_key`,推荐格式为 `task-1`、`task-2`、`task-3`。
6. `required_skill_names` 只写系统技能名称;`required_skill_tag_ids` 可留空,由后端按技能名称自动回填。
7. `depends_on_task_keys` 用于描述任务依赖,必须引用前面任务的 `task_key`。
8. `risk_level` 只能是 `low`、`medium`、`high` 或 `critical`。
9. `confidence` 使用 0 到 1 的小数,越接近 1 表示越确定。
10. `evidence_refs` 填写宪章或项目上下文里的证据位置,例如 `charter.objectives.0`、`charter.risks.1`。
输出 JSON 格式:
{{ {{
"tasks": [ "tasks": [
{{ {{
@ -146,66 +169,89 @@ PROMPT_DECOMPOSE_TASKS = """
"detected_skills": ["检测到的所有技能"] "detected_skills": ["检测到的所有技能"]
}} }}
只输出 JSON,不要其他文字。 要求:
""" 1. 任务要可执行、可验收,避免空泛管理语言。
2. 优先体现与业务目标直接相关的最小闭环。
3. evidence_refs 必须能对应到传入的 charter_content。
""",
JSON_OUTPUT_GUARDRAIL,
)
PROMPT_RECOMMEND_ASSIGNEES = """ PROMPT_RECOMMEND_ASSIGNEES = _compose_prompt(
你是一位项目协调员。根据以下任务内容、所需技能、以及候选人信息,推荐 Top3 最合适的负责人。 PMFLOW_PMO_STYLE_BASE,
PLANNING_SCOPE_BASE,
"""
你是一位项目协调员。根据以下任务内容、所需技能和候选人信息,推荐 Top3 最合适的负责人。
任务内容:{task_content} 输入:
- 任务内容:{task_content}
所需技能标签 IDs:{required_skill_tag_ids} - 所需技能标签 IDs:{required_skill_tag_ids}
- 候选人信息(JSON 数组):
候选人信息(JSON 数组,每项包含 user_id, user_name, workload, proficiency_avg, skill_match_count):
{candidates_json} {candidates_json}
请考虑:当前负载、技能匹配度、历史表现。输出 JSON: 输出 JSON:
{{ {{
"recommendations": [ "recommendations": [
{{ {{
"user_id": "用户ID", "user_id": "用户ID",
"user_name": "姓名", "user_name": "姓名",
"match_score": 0-100, "match_score": 0,
"reason": "推荐理由" "reason": "推荐理由"
}} }}
] ]
}} }}
只输出 JSON。 规则:
""" 1. 至多推荐 3 人,按 match_score 从高到低排序。
2. 推荐理由要同时解释技能匹配与负载/可用性判断。
3. 不要编造候选人列表中不存在的用户。
4. match_score 使用 0-100 整数或小数都可,但需保持一致。
""",
JSON_OUTPUT_GUARDRAIL,
)
PROMPT_ALIGNMENT_SCORE = """ PROMPT_ALIGNMENT_SCORE = _compose_prompt(
PMFLOW_PMO_STYLE_BASE,
GOVERNANCE_SCOPE_BASE,
"""
计算用户对真理节点的认知对齐度(0-100 分)。 计算用户对真理节点的认知对齐度(0-100 分)。
用户 ID:{user_id} 输入:
真理节点 ID:{truth_node_id} - 用户 ID:{user_id}
- 真理节点 ID:{truth_node_id}
真理节点内容: - 真理节点内容:
{truth_content} {truth_content}
- 用户相关行为/产出:
用户相关行为/产出(如有):
{user_context} {user_context}
请评估该用户对该真理节点的理解与践行程度。输出 JSON: 输出 JSON:
{{ {{
"alignment_score": 0-100, "alignment_score": 0,
"explanation": "简要说明" "explanation": "简要说明"
}} }}
只输出 JSON。 规则:
""" 1. alignment_score 范围必须是 0-100。
2. explanation 只写最关键的判断依据,不要写推理过程。
3. 若证据不足,说明缺少什么。
""",
JSON_OUTPUT_GUARDRAIL,
)
PROMPT_COGNITIVE_CONFLICTS = """ PROMPT_COGNITIVE_CONFLICTS = _compose_prompt(
PMFLOW_PMO_STYLE_BASE,
GOVERNANCE_SCOPE_BASE,
"""
识别真理节点下的隐性认知冲突。 识别真理节点下的隐性认知冲突。
真理节点 ID:{truth_node_id} 输入:
真理节点内容: - 真理节点 ID:{truth_node_id}
- 真理节点内容:
{truth_content} {truth_content}
- 用户观点/反馈:
相关用户的观点/产出:
{user_viewpoints} {user_viewpoints}
请识别冲突类型(如 definition/定义分歧、priority/优先级冲突、scope/范围争议),输出 JSON: 输出 JSON:
{{ {{
"conflicts": [ "conflicts": [
{{ {{
@ -218,38 +264,54 @@ PROMPT_COGNITIVE_CONFLICTS = """
] ]
}} }}
只输出 JSON。 规则:
""" 1. 只识别有明确证据支持的冲突。
2. 没有冲突时返回空数组,不要编造。
3. description 需要业务化表达,避免空泛术语。
""",
JSON_OUTPUT_GUARDRAIL,
)
PROMPT_EVALUATE_MODEL_REQUIREMENT = """ PROMPT_EVALUATE_MODEL_REQUIREMENT = _compose_prompt(
PMFLOW_PMO_STYLE_BASE,
GOVERNANCE_SCOPE_BASE,
"""
评估需求与 AI 模型的匹配度。 评估需求与 AI 模型的匹配度。
宪章内容:{charter_content} 输入:
模型版本 ID:{model_version_id} - 需求内容:{charter_content}
模型信息(如有):{model_info} - 模型版本 ID:{model_version_id}
- 模型说明:{model_info}
请评估兼容性、成本、风险。输出 JSON: 输出 JSON:
{{ {{
"compatibility_score": 0-100, "compatibility_score": 0,
"cost_estimate_usd": 预估日成本美元, "cost_estimate_usd": 0.0,
"cost_estimate_tokens_per_day": 预估日 token 量, "cost_estimate_tokens_per_day": 0,
"risk_hints": ["风险1", "风险2"], "risk_hints": ["风险1", "风险2"],
"recommendations": ["建议1", "建议2"] "recommendations": ["建议1", "建议2"]
}} }}
只输出 JSON。 规则:
""" 1. compatibility_score 使用 0-100。
2. 风险和建议必须能直接指导模型选型或范围调整。
3. 若信息不足,明确指出无法判断的关键缺口。
""",
JSON_OUTPUT_GUARDRAIL,
)
PROMPT_ANALYZE_CAPABILITY_GAP = """ PROMPT_ANALYZE_CAPABILITY_GAP = _compose_prompt(
PMFLOW_PMO_STYLE_BASE,
GOVERNANCE_SCOPE_BASE,
"""
你是一位 AI 模型能力专家。分析以下需求与模型之间的能力差距。 你是一位 AI 模型能力专家。分析以下需求与模型之间的能力差距。
需求宪章: 输入:
{charter_content} - 需求描述:{requirement_description}
- 模型能力说明:{model_capabilities}
- 约束条件:{constraints}
模型信息: 输出 JSON:
{model_info}
请识别并分类能力差距,输出 JSON:
{{ {{
"gaps": [ "gaps": [
{{ {{
@ -261,39 +323,50 @@ PROMPT_ANALYZE_CAPABILITY_GAP = """
}} }}
], ],
"overall_risk_level": "critical|high|medium|low", "overall_risk_level": "critical|high|medium|low",
"confidence": 0.0-1.0 "confidence": 0.0
}} }}
只输出 JSON。 规则:
""" 1. gaps 只列出真正影响落地的差距。
2. overall_risk_level 要与 gaps 的严重程度保持一致。
3. confidence 使用 0-1 小数。
""",
JSON_OUTPUT_GUARDRAIL,
)
PROMPT_ANALYZE_COST_ROI = """ PROMPT_ANALYZE_COST_ROI = _compose_prompt(
PMFLOW_PMO_STYLE_BASE,
GOVERNANCE_SCOPE_BASE,
"""
分析 AI 项目的成本效益。 分析 AI 项目的成本效益。
总成本:{total_cost} 输入:
总 Token:{total_tokens} - 总成本:{total_cost}
任务数:{task_count} - 总 token 数:{total_tokens}
项目内容:{truth_node_content} - 任务数:{task_count}
- 项目内容:
请计算: {truth_node_content}
1. ROI 评分 (0-10)
2. 成本效率评估
3. 优化建议
输出 JSON: 输出 JSON:
{{ {{
"roi_score": 0.0-10.0, "roi_score": 0.0,
"cost_efficiency": "excellent|good|fair|poor", "cost_efficiency": "excellent|good|fair|poor",
"cost_per_task": 数值, "cost_per_task": 0.0,
"optimization_suggestions": ["建议1", "建议2"], "optimization_suggestions": ["建议1", "建议2"],
"benchmark_comparison": "与行业标准比较" "benchmark_comparison": "与行业标准比较"
}} }}
只输出 JSON。 规则:
""" 1. roi_score 使用 0.0-10.0。
2. cost_per_task 必须可由输入推导,不能胡写。
3. optimization_suggestions 要能实际指导降本或提效。
""",
JSON_OUTPUT_GUARDRAIL,
)
__all__ = [ __all__ = [
"PROMPT_RAG_ANSWER", "PROMPT_RAG_ANSWER",
"PROMPT_DECISION_QA",
"PROMPT_RAG_DB_PROJECT_QA", "PROMPT_RAG_DB_PROJECT_QA",
"PROMPT_GENERATE_CHARTER", "PROMPT_GENERATE_CHARTER",
"PROMPT_DECOMPOSE_TASKS", "PROMPT_DECOMPOSE_TASKS",

View File

@ -1,67 +1,118 @@
from __future__ import annotations from __future__ import annotations
PROMPT_ANALYZE_COST_OPTIMIZATION = """ from .part_01 import JSON_OUTPUT_GUARDRAIL, PMFLOW_PMO_STYLE_BASE, TEXT_OUTPUT_GUARDRAIL, _compose_prompt
ANALYSIS_SCOPE_BASE = """
职责边界:
1. 你是分析层,只负责基于输入做测算、比较与建议。
2. 不编造缺失数据,不替代人工验证。
""".strip()
PLANNING_SCOPE_BASE = """
职责边界:
1. 你是规划层,只负责给出可执行优化或重分配建议。
2. 不重复分类判断,不解释系统内部实现。
""".strip()
GOVERNANCE_SCOPE_BASE = """
职责边界:
1. 你是治理层,只负责变更评估、同步决策与风控建议。
2. 不替代业务决策,不伪造证据,不输出泛化结论。
""".strip()
PROMPT_ANALYZE_COST_OPTIMIZATION = _compose_prompt(
PMFLOW_PMO_STYLE_BASE,
GOVERNANCE_SCOPE_BASE,
"""
分析成本优化机会。 分析成本优化机会。
成本历史:{cost_history} 输入:
项目内容:{truth_node_content} - 成本历史:{cost_history}
- 项目内容:
{truth_node_content}
请识别优化机会,输出 JSON: 请识别优化机会,输出 JSON:
{{ {{
"can_downgrade_model": true|false, "can_downgrade_model": true,
"downgrade_savings": 预计节省金额, "downgrade_savings": 0.0,
"suggested_model": "建议的替代模型", "suggested_model": "建议的替代模型",
"high_cache_potential": true|false, "high_cache_potential": true,
"cache_savings": 预计节省金额, "cache_savings": 0.0,
"batchable_operations": true|false, "batchable_operations": true,
"batch_savings": 预计节省金额, "batch_savings": 0.0,
"monthly_cost": 数值, "monthly_cost": 0.0,
"total_potential_savings": 数值, "total_potential_savings": 0.0,
"roi_after_optimization": 数值 "roi_after_optimization": 0.0
}} }}
只输出 JSON。 规则:
""" 1. 只基于输入中的成本历史与项目内容做判断,不要编造。
2. 结果要能直接指导降本动作。
3. 不要输出解释文本,只输出 JSON。
""",
JSON_OUTPUT_GUARDRAIL,
)
PROMPT_ASSESS_MODEL_UPDATE_IMPACT = """ PROMPT_ASSESS_MODEL_UPDATE_IMPACT = _compose_prompt(
PMFLOW_PMO_STYLE_BASE,
GOVERNANCE_SCOPE_BASE,
"""
评估模型升级影响。 评估模型升级影响。
旧模型信息:{old_model_info} 输入:
新模型信息:{new_model_info} - 旧模型信息:{old_model_info}
受影响宪章:{affected_truth_nodes} - 新模型信息:{new_model_info}
- 受影响宪章:{affected_truth_nodes}
输出 JSON: 输出 JSON:
{{ {{
"cost_change_percentage": 数值, "cost_change_percentage": 0.0,
"performance_change": {{ "performance_change": {{
"context_window_change_percent": 数值, "context_window_change_percent": 0.0,
"quality_change_percent": 数值 "quality_change_percent": 0.0
}}, }},
"compatibility_risks": [{{"type": "风险类型", "severity": "low|medium|high"}}], "compatibility_risks": [{{"type": "风险类型", "severity": "low|medium|high"}}],
"recommendation": "immediate_upgrade|evaluate|defer" "recommendation": "immediate_upgrade|evaluate|defer"
}} }}
只输出 JSON。 规则:
""" 1. 结论要明确是否适合立即升级。
2. 风险说明必须落到兼容性、稳定性或成本变化。
3. 不要输出解释文本,只输出 JSON。
""",
JSON_OUTPUT_GUARDRAIL,
)
PROMPT_EXPERIMENT_SYNC_DECISION = """ PROMPT_EXPERIMENT_SYNC_DECISION = _compose_prompt(
PMFLOW_PMO_STYLE_BASE,
GOVERNANCE_SCOPE_BASE,
"""
评估实验结果是否可自动同步到宪章。 评估实验结果是否可自动同步到宪章。
实验信息:{experiment_info} 输入:
宪章内容:{truth_content} - 实验信息:{experiment_info}
阈值:{threshold} - 宪章内容:{truth_content}
- 阈值:{threshold}
输出 JSON: 输出 JSON:
{{ {{
"confidence_score": 0.0-1.0, "confidence_score": 0.0,
"auto_apply": true|false, "auto_apply": true,
"reason": "原因" "reason": "原因"
}} }}
只输出 JSON。 规则:
""" 1. 只有实验结果与宪章目标高度一致时才允许 auto_apply=true。
2. reason 要明确说明同步或拒绝同步的依据。
3. 不要输出解释文本,只输出 JSON。
""",
JSON_OUTPUT_GUARDRAIL,
)
PROMPT_SKILL_SCORE_UPDATE = """ PROMPT_SKILL_SCORE_UPDATE = _compose_prompt(
PMFLOW_PMO_STYLE_BASE,
PLANNING_SCOPE_BASE,
"""
你是 PMFlow 的技能评估引擎。 你是 PMFlow 的技能评估引擎。
请根据任务完成结果,给每个技能标签输出质量分与效率分的增量(delta)。 请根据任务完成结果,给每个技能标签输出质量分与效率分的增量(delta)。
@ -90,9 +141,14 @@ PROMPT_SKILL_SCORE_UPDATE = """
}} }}
] ]
}} }}
""" """,
JSON_OUTPUT_GUARDRAIL,
)
PROMPT_WORKLOAD_OPTIMIZATION = """ PROMPT_WORKLOAD_OPTIMIZATION = _compose_prompt(
PMFLOW_PMO_STYLE_BASE,
PLANNING_SCOPE_BASE,
"""
你是 PMFlow 的任务分配优化器。 你是 PMFlow 的任务分配优化器。
请基于团队成员技能与当前负载,输出任务重分配建议。 请基于团队成员技能与当前负载,输出任务重分配建议。
@ -117,26 +173,29 @@ PROMPT_WORKLOAD_OPTIMIZATION = """
}} }}
] ]
}} }}
""" """,
JSON_OUTPUT_GUARDRAIL,
)
PROMPT_ANALYZE_FOOD_CALORIES = """ PROMPT_ANALYZE_FOOD_CALORIES = _compose_prompt(
你是一位专业的营养分析师,擅长通过图片识别食物并估算热量和营养成分。 PMFLOW_PMO_STYLE_BASE,
ANALYSIS_SCOPE_BASE,
"""
你是一位专业的营养分析师,擅长通过文本描述识别食物并估算热量和营养成分。
请基于提供的食物描述或特征,识别食物类型并估算营养信息。 输入:
输入信息:
{food_description} {food_description}
请输出 JSON 格式,包含以下字段: 请输出 JSON 格式,包含以下字段:
{{ {{
"food_name": "食物名称", "food_name": "食物名称",
"confidence": 0.0-1.0, "confidence": 0.0,
"estimated_portion_size": "份量大小描述(如:一份、100克、半碗等)", "estimated_portion_size": "份量大小描述(如:一份、100克、半碗等)",
"calories": 数值, "calories": 0.0,
"protein": 数值, "protein": 0.0,
"fat": 数值, "fat": 0.0,
"carbs": 数值, "carbs": 0.0,
"fiber": 数值, "fiber": 0.0,
"category": "分类(如:主食、蔬菜、肉类、汤品、甜品、饮品等)", "category": "分类(如:主食、蔬菜、肉类、汤品、甜品、饮品等)",
"cuisine": "菜系(如:中式、西式、日式、韩式等)", "cuisine": "菜系(如:中式、西式、日式、韩式等)",
"description": "简短的食物描述", "description": "简短的食物描述",
@ -145,15 +204,19 @@ PROMPT_ANALYZE_FOOD_CALORIES = """
}} }}
估算规则: 估算规则:
1. 热量估算范围:考虑到份量不确定性,给出合理的中位值 1. 热量估算考虑份量不确定性,给出合理中位值。
2. 营养成分:基于常见食物数据库估算 2. 营养成分基于常见食物数据库估算。
3. confidence 根据描述清晰度和食物识别难度给出 3. confidence 根据描述清晰度和食物识别难度给出。
4. 若无法识别,confidence 应 < 0.5,并在 uncertainty_factors 中说明 4. 若无法识别,confidence 应 < 0.5,并在 uncertainty_factors 中说明。
5. 只输出 JSON,不要其他文字或 Markdown。
""",
JSON_OUTPUT_GUARDRAIL,
)
只输出 JSON,不要其他文字或 Markdown。 PROMPT_SIGNAL_PLANNER = _compose_prompt(
""" PMFLOW_PMO_STYLE_BASE,
PLANNING_SCOPE_BASE,
PROMPT_SIGNAL_PLANNER = """ """
你是 PMFlow 的 Signal Planner。请基于信号内容、项目上下文和团队成员能力,给出可执行的规划建议。 你是 PMFlow 的 Signal Planner。请基于信号内容、项目上下文和团队成员能力,给出可执行的规划建议。
输入: 输入:
@ -193,7 +256,9 @@ PROMPT_SIGNAL_PLANNER = """
4. execution_plan 必须是 2-5 条可执行动作,不要空泛描述。 4. execution_plan 必须是 2-5 条可执行动作,不要空泛描述。
5. confidence 范围 0-1,不能省略。 5. confidence 范围 0-1,不能省略。
6. 只输出 JSON,不要输出 Markdown 或解释文本。 6. 只输出 JSON,不要输出 Markdown 或解释文本。
""" """,
JSON_OUTPUT_GUARDRAIL,
)
__all__ = [ __all__ = [
"PROMPT_ANALYZE_COST_OPTIMIZATION", "PROMPT_ANALYZE_COST_OPTIMIZATION",

View File

@ -1,19 +1,22 @@
from __future__ import annotations from __future__ import annotations
FEISHU_QA_RESPONSE_STYLE = """ from .part_01 import PMFLOW_PMO_STYLE_BASE, TEXT_OUTPUT_GUARDRAIL, _compose_prompt
FEISHU_QA_RESPONSE_STYLE = _compose_prompt(
PMFLOW_PMO_STYLE_BASE,
"""
回复风格要求: 回复风格要求:
1. 先给结论,再给关键依据,最后再给下一步。 1. 先给结论,再给关键依据,最后再给下一步。
2. 用正式、克制、面向协作的中文,像资深项目经理而不是聊天机器人。 2. 用正式、克制、面向协作的中文,像资深项目经理而不是聊天机器人。
3. 如果能用数字、项目名、负责人、时间点表达,请直接写明。 3. 如果能用数字、项目名、负责人、时间点表达,请直接写明。
4. 若信息不足,必须明确说明缺少什么,最多列 3 项。 4. 若信息不足,必须明确说明缺少什么,最多列 3 项。
5. 禁止自我介绍、寒暄、思考过程、数据库/模型/接口实现细节。 5. 禁止自我介绍、寒暄、思考过程、数据库/模型/接口实现细节。
""".strip()
PROMPT_FEISHU_DIRECT_QA = "\n\n".join(
[
"""
你是 PMFlow 的飞书项目管理顾问,负责直接问答。
""".strip(), """.strip(),
TEXT_OUTPUT_GUARDRAIL,
)
PROMPT_FEISHU_DIRECT_QA = _compose_prompt(
"你是 PMFlow 的飞书项目管理顾问,负责直接问答。",
FEISHU_QA_RESPONSE_STYLE, FEISHU_QA_RESPONSE_STYLE,
""" """
回答要求: 回答要求:
@ -32,7 +35,6 @@ User Snapshot:{user_snapshot}
最近会话:{conversation} 最近会话:{conversation}
检索上下文:{context_blocks} 检索上下文:{context_blocks}
""".strip(), """.strip(),
]
) )
__all__ = ["FEISHU_QA_RESPONSE_STYLE", "PROMPT_FEISHU_DIRECT_QA"] __all__ = ["FEISHU_QA_RESPONSE_STYLE", "PROMPT_FEISHU_DIRECT_QA"]

View File

@ -1,7 +1,10 @@
from __future__ import annotations from __future__ import annotations
PROMPT_PROJECT_STATUS_QA = """ from .part_01 import TEXT_OUTPUT_GUARDRAIL, _compose_prompt
你是 PMFlow 的项目状态分析助手。
PROMPT_PROJECT_STATUS_QA = _compose_prompt(
"你是 PMFlow 的项目状态分析助手。",
"""
用户关注项目进度、状态、完成率、阶段和卡点。 用户关注项目进度、状态、完成率、阶段和卡点。
规则: 规则:
@ -21,6 +24,8 @@ PROMPT_PROJECT_STATUS_QA = """
System Snapshot:{system_snapshot} System Snapshot:{system_snapshot}
User Snapshot:{user_snapshot} User Snapshot:{user_snapshot}
检索上下文:{context_blocks} 检索上下文:{context_blocks}
""".strip() """.strip(),
TEXT_OUTPUT_GUARDRAIL,
)
__all__ = ["PROMPT_PROJECT_STATUS_QA"] __all__ = ["PROMPT_PROJECT_STATUS_QA"]

View File

@ -1,22 +1,27 @@
from __future__ import annotations from __future__ import annotations
PROMPT_MEMBER_WORKLOAD_QA = """ from .part_01 import TEXT_OUTPUT_GUARDRAIL, _compose_prompt
你是 PMFlow 的团队协作助手。
PROMPT_MEMBER_WORKLOAD_QA = _compose_prompt(
"你是 PMFlow 的团队协作助手。",
"""
用户询问团队成员或负载相关信息。 用户询问团队成员或负载相关信息。
规则: 规则:
1. 优先使用 System Snapshot.team_members 和 User Snapshot 数据 1. 优先使用 System Snapshot.team_members 和 User Snapshot 数据。
2. 禁止默认返回项目列表 2. 禁止默认返回项目列表。
3. 涉及负载用百分比表示 3. 涉及负载用百分比表示。
4. 忽略与成员负载无关的项目列表和历史项目详情 4. 忽略与成员负载无关的项目列表和历史项目详情。
5. 推荐负责人时说明推荐理由 5. 推荐负责人时说明推荐理由。
6. 禁止输出思考过程 6. 禁止输出思考过程。
7. 不超过150字 7. 不超过 150 字。
问题:{question} 问题:{question}
System Snapshot:{system_snapshot} System Snapshot:{system_snapshot}
User Snapshot:{user_snapshot} User Snapshot:{user_snapshot}
检索上下文:{context_blocks} 检索上下文:{context_blocks}
""".strip() """.strip(),
TEXT_OUTPUT_GUARDRAIL,
)
__all__ = ["PROMPT_MEMBER_WORKLOAD_QA"] __all__ = ["PROMPT_MEMBER_WORKLOAD_QA"]

View File

@ -1,14 +1,17 @@
from __future__ import annotations from __future__ import annotations
PROMPT_AI_PMO_QA = """ from .part_01 import TEXT_OUTPUT_GUARDRAIL, _compose_prompt
你是 PMFlow 的 AI 项目管理官。
PROMPT_AI_PMO_QA = _compose_prompt(
"你是 PMFlow 的 AI 项目管理官。",
"""
用户通过指令入口提问。 用户通过指令入口提问。
规则: 规则:
1. 你既是执行者也是回答者 1. 你既是执行者也是回答者,但只回答当前上下文明确支持的内容。
2. 问题涉及操作时先回答再提示可用指令 2. 问题涉及操作时先回答再提示可用指令。
3. 只基于给定上下文回答 3. 只基于给定上下文回答,不要编造系统状态。
4. 禁止输出思考过程 4. 禁止输出思考过程。
5. 回答后附可操作建议: 5. 回答后附可操作建议:
"你可以继续:[操作A]/[操作B]" "你可以继续:[操作A]/[操作B]"
@ -16,6 +19,8 @@ PROMPT_AI_PMO_QA = """
当前项目:{project_id} 当前项目:{project_id}
World Model:{world_model} World Model:{world_model}
检索上下文:{context_blocks} 检索上下文:{context_blocks}
""".strip() """.strip(),
TEXT_OUTPUT_GUARDRAIL,
)
__all__ = ["PROMPT_AI_PMO_QA"] __all__ = ["PROMPT_AI_PMO_QA"]

View File

@ -1,29 +1,35 @@
from __future__ import annotations from __future__ import annotations
PROMPT_EXTERNAL_TREND_QA = """ from .part_01 import TEXT_OUTPUT_GUARDRAIL, _compose_prompt
你是 PMFlow 的市场情报助手。
PROMPT_EXTERNAL_TREND_QA = _compose_prompt(
"你是 PMFlow 的市场情报助手。",
"""
用户询问外部行业或市场信息。 用户询问外部行业或市场信息。
规则: 规则:
1. 若有外部搜索结果优先使用 1. 若有外部搜索结果优先使用。
2. 若无外部数据明确说: 2. 若无外部数据明确说:
"当前未接入实时数据,以下基于通用行业认知。" "当前未接入实时数据,以下基于通用行业认知。"
3. 结合项目实际给出应用建议: 3. 结合项目实际给出应用建议:
"对你们[项目名]的影响是..." "对你们[项目名]的影响是..."
4. 禁止输出思考过程 4. 禁止输出思考过程。
5. 分段输出: 5. 分段输出:
[行业动态] xxx [行业动态] xxx
[对项目的影响] xxx [对项目的影响] xxx
[建议] xxx [建议] xxx
6. 控制总回复长度不超过800字,优先保留[建议]段 6. 控制总回复长度不超过 800 字,优先保留[建议]段。
问题:{question} 问题:{question}
项目上下文:{project_context} 项目上下文:{project_context}
外部搜索结果:{external_results} 外部搜索结果:{external_results}
""".strip() """.strip(),
TEXT_OUTPUT_GUARDRAIL,
)
PROMPT_INDUSTRY_TREND_QA = """ PROMPT_INDUSTRY_TREND_QA = _compose_prompt(
你是 PMFlow 的行业趋势助手。 "你是 PMFlow 的行业趋势助手。",
"""
用户在询问某个行业、市场或产品方向的通用趋势判断。 用户在询问某个行业、市场或产品方向的通用趋势判断。
规则: 规则:
@ -35,6 +41,8 @@ PROMPT_INDUSTRY_TREND_QA = """
6. 不要输出思考过程,不要使用“让我/我来/我先/我需要”开头。 6. 不要输出思考过程,不要使用“让我/我来/我先/我需要”开头。
问题:{question} 问题:{question}
""".strip() """.strip(),
TEXT_OUTPUT_GUARDRAIL,
)
__all__ = ["PROMPT_EXTERNAL_TREND_QA", "PROMPT_INDUSTRY_TREND_QA"] __all__ = ["PROMPT_EXTERNAL_TREND_QA", "PROMPT_INDUSTRY_TREND_QA"]

View File

@ -45,6 +45,7 @@ from .knowledge_base_doc import KnowledgeBaseDoc
from .knowledge_base_chunk import KnowledgeBaseChunk from .knowledge_base_chunk import KnowledgeBaseChunk
from .qa_record import QARecord from .qa_record import QARecord
from .qa_feedback import QAFeedback from .qa_feedback import QAFeedback
from .feedback_center import FeedbackCenterRecord, FeedbackCenterRecordRevision
from .qa_session import QASession from .qa_session import QASession
from .qa_session_state import QASessionState from .qa_session_state import QASessionState
from .kg_entity import KGEntity from .kg_entity import KGEntity
@ -111,6 +112,8 @@ __all__ = [
"KnowledgeBaseChunk", "KnowledgeBaseChunk",
"QARecord", "QARecord",
"QAFeedback", "QAFeedback",
"FeedbackCenterRecord",
"FeedbackCenterRecordRevision",
"QASession", "QASession",
"QASessionState", "QASessionState",
"KGEntity", "KGEntity",

View File

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

View File

@ -23,6 +23,21 @@ from .common import (
TaskItem, TaskItem,
) )
from .feedback import SignalFeedbackCreate, SignalFeedbackResponse from .feedback import SignalFeedbackCreate, SignalFeedbackResponse
from .feedback_center import (
FeedbackReasonCandidate,
FeedbackCenterRecordRevisionRead,
FeedbackCenterRecordRead,
FeedbackCenterSummaryReasonRead,
FeedbackCenterSummaryMetricsRead,
FeedbackCenterTrendDailyRead,
FeedbackCenterTrendWindowRead,
FeedbackCenterTrendDeltaRead,
FeedbackCenterTrendRead,
FeedbackCenterSummaryRead,
FeedbackCenterListRead,
FeedbackCenterReviewRequest,
FeedbackCenterReasonCatalogRead,
)
from .model_capability import ( from .model_capability import (
ValidateMatchRequest, ValidateMatchRequest,
LifecycleImpactRequest, LifecycleImpactRequest,
@ -79,6 +94,19 @@ __all__ = [
"TaskItem", "TaskItem",
"SignalFeedbackCreate", "SignalFeedbackCreate",
"SignalFeedbackResponse", "SignalFeedbackResponse",
"FeedbackReasonCandidate",
"FeedbackCenterRecordRevisionRead",
"FeedbackCenterRecordRead",
"FeedbackCenterSummaryReasonRead",
"FeedbackCenterSummaryMetricsRead",
"FeedbackCenterTrendDailyRead",
"FeedbackCenterTrendWindowRead",
"FeedbackCenterTrendDeltaRead",
"FeedbackCenterTrendRead",
"FeedbackCenterSummaryRead",
"FeedbackCenterListRead",
"FeedbackCenterReviewRequest",
"FeedbackCenterReasonCatalogRead",
"ValidateMatchRequest", "ValidateMatchRequest",
"LifecycleImpactRequest", "LifecycleImpactRequest",
"GenerateMigrationTasksRequest", "GenerateMigrationTasksRequest",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,9 @@ fastapi>=0.115.0
uvicorn[standard]>=0.30.0 uvicorn[standard]>=0.30.0
sqlalchemy>=2.0.30 sqlalchemy>=2.0.30
alembic>=1.13.2 alembic>=1.13.2
greenlet>=3.0.0
asyncpg>=0.29.0 asyncpg>=0.29.0
aiosqlite>=0.20.0
psycopg[binary]>=3.2.0 psycopg[binary]>=3.2.0
pgvector>=0.3.5 pgvector>=0.3.5
python-dotenv>=1.0.1 python-dotenv>=1.0.1

View File

@ -1,9 +1,16 @@
from app.config.ai_prompt_registry import ( from app.config.ai_prompt_registry import (
PROMPT_REGISTRY_MAP, PROMPT_REGISTRY_MAP,
PROMPT_FAMILY_ALLOWED,
get_prompt_registry, get_prompt_registry,
get_prompt_registry_entry, get_prompt_registry_entry,
iter_prompt_registry_keys, iter_prompt_registry_keys,
) )
from app.config.ai_prompts import (
PROMPT_DECOMPOSE_TASKS,
PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION,
PROMPT_FEISHU_REQUIREMENT_ANALYSIS,
PROMPT_TASK_BREAKDOWN,
)
from app.config.ai_prompts_catalog import __all__ as PROMPT_CATALOG_EXPORTS from app.config.ai_prompts_catalog import __all__ as PROMPT_CATALOG_EXPORTS
@ -18,6 +25,10 @@ def test_prompt_registry_covers_all_catalog_exports() -> None:
entry = get_prompt_registry_entry(key) entry = get_prompt_registry_entry(key)
assert entry is not None assert entry is not None
assert entry["prompt_key"] == key assert entry["prompt_key"] == key
assert entry["prompt_family"] in PROMPT_FAMILY_ALLOWED
assert entry["responsibility_scope"]
assert entry["priority"] in {"high", "medium", "low"}
assert entry["audience"]
def test_prompt_registry_returns_detached_copies() -> None: def test_prompt_registry_returns_detached_copies() -> None:
@ -30,18 +41,22 @@ def test_prompt_registry_returns_detached_copies() -> None:
def test_high_frequency_registry_entries_have_complete_metadata() -> None: def test_high_frequency_registry_entries_have_complete_metadata() -> None:
required_keys = ( expected_families = {
"PROMPT_SIGNAL_PLANNER", "PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION": "classification",
"PROMPT_GENERATE_CHARTER", "PROMPT_FEISHU_LEVEL_CLASSIFICATION": "classification",
"PROMPT_DECOMPOSE_TASKS", "PROMPT_FEISHU_SIGNAL_EXTRACTION": "extraction",
"PROMPT_PROJECT_STATUS_QA", "PROMPT_FEISHU_TASK_ORCHESTRATION": "planning",
"PROMPT_MEMBER_WORKLOAD_QA", "PROMPT_SIGNAL_PLANNER": "planning",
"PROMPT_AI_PMO_QA", "PROMPT_GENERATE_CHARTER": "planning",
"PROMPT_EXTERNAL_TREND_QA", "PROMPT_DECOMPOSE_TASKS": "planning",
"PROMPT_INDUSTRY_TREND_QA", "PROMPT_PROJECT_STATUS_QA": "response",
) "PROMPT_MEMBER_WORKLOAD_QA": "response",
"PROMPT_AI_PMO_QA": "response",
"PROMPT_EXTERNAL_TREND_QA": "response",
"PROMPT_INDUSTRY_TREND_QA": "response",
}
for prompt_key in required_keys: for prompt_key, family in expected_families.items():
entry = get_prompt_registry_entry(prompt_key) entry = get_prompt_registry_entry(prompt_key)
assert entry is not None assert entry is not None
assert entry["module"] assert entry["module"]
@ -50,9 +65,21 @@ def test_high_frequency_registry_entries_have_complete_metadata() -> None:
assert entry["output_contract"] assert entry["output_contract"]
assert isinstance(entry["call_sites"], tuple) assert isinstance(entry["call_sites"], tuple)
assert entry["call_sites"], prompt_key assert entry["call_sites"], prompt_key
assert entry["prompt_family"] == family
assert entry["responsibility_scope"]
signal_planner_entry = get_prompt_registry_entry("PROMPT_SIGNAL_PLANNER") signal_planner_entry = get_prompt_registry_entry("PROMPT_SIGNAL_PLANNER")
assert signal_planner_entry is not None assert signal_planner_entry is not None
assert "charter_json" in signal_planner_entry["input_contract"] assert "charter_json" in signal_planner_entry["input_contract"]
assert any("mixin_prompt.py" in site for site in signal_planner_entry["call_sites"]) assert any("mixin_prompt.py" in site for site in signal_planner_entry["call_sites"])
assert any("mixin_analyze.py" in site for site in signal_planner_entry["call_sites"]) assert any("mixin_analyze.py" in site for site in signal_planner_entry["call_sites"])
assert "规划" in signal_planner_entry["responsibility_scope"]
def test_deprecated_prompt_aliases_point_to_canonical_prompts() -> None:
registry_keys = set(iter_prompt_registry_keys())
assert "PROMPT_FEISHU_REQUIREMENT_ANALYSIS" not in registry_keys
assert "PROMPT_TASK_BREAKDOWN" not in registry_keys
assert PROMPT_FEISHU_REQUIREMENT_ANALYSIS == PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION
assert PROMPT_TASK_BREAKDOWN == PROMPT_DECOMPOSE_TASKS

View File

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

View File

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

View File

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

View File

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

View File

@ -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&section=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>
);
}

View File

@ -5,6 +5,7 @@ import type { User } from "@/api/users";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { FeedbackSummaryCard } from "@/components/sstc/FeedbackSummaryCard";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -26,6 +27,7 @@ interface PatchTaskStatusMutationLike {
} }
interface SSTCExecutionBoardPanelProps { interface SSTCExecutionBoardPanelProps {
projectId: string;
stageLocked: boolean; stageLocked: boolean;
projectUsers: User[]; projectUsers: User[];
optimizeFeedback: string; optimizeFeedback: string;
@ -62,6 +64,7 @@ function getOverrideFields(task: SSTCTask): string[] {
} }
export function SSTCExecutionBoardPanel({ export function SSTCExecutionBoardPanel({
projectId,
stageLocked, stageLocked,
projectUsers, projectUsers,
optimizeFeedback, optimizeFeedback,
@ -78,6 +81,10 @@ export function SSTCExecutionBoardPanel({
); );
const detailBreakdown = detailTask ? getTaskBreakdownObject(detailTask) : null; const detailBreakdown = detailTask ? getTaskBreakdownObject(detailTask) : null;
const detailOverrides = detailTask ? getOverrideFields(detailTask) : []; const detailOverrides = detailTask ? getOverrideFields(detailTask) : [];
const laneTasks = useMemo(
() => EXECUTION_LANES.flatMap((lane) => executionLanes[lane.key] || []),
[executionLanes]
);
return ( return (
<Card> <Card>
@ -121,6 +128,9 @@ export function SSTCExecutionBoardPanel({
{optimizeFeedback} {optimizeFeedback}
</p> </p>
)} )}
<div className="mb-3">
<FeedbackSummaryCard projectId={projectId} tasks={laneTasks} />
</div>
<div className="-mx-1 overflow-x-auto pb-2 [-webkit-overflow-scrolling:touch]"> <div className="-mx-1 overflow-x-auto pb-2 [-webkit-overflow-scrolling:touch]">
<div className="grid min-w-[920px] grid-cols-5 gap-3 px-1"> <div className="grid min-w-[920px] grid-cols-5 gap-3 px-1">
{EXECUTION_LANES.map((lane) => ( {EXECUTION_LANES.map((lane) => (

View File

@ -9,6 +9,7 @@ import type {
SSTCTaskRecommendation, SSTCTaskRecommendation,
} from "@/api/sstc"; } from "@/api/sstc";
import type { User } from "@/api/users"; import type { User } from "@/api/users";
import { FeedbackSummaryCard } from "@/components/sstc/FeedbackSummaryCard";
import { SSTCTaskCreateDialog } from "@/components/sstc/SSTCTaskCreateDialog"; import { SSTCTaskCreateDialog } from "@/components/sstc/SSTCTaskCreateDialog";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -247,6 +248,9 @@ export function SSTCTaskStagePanel({
{optimizeFeedback} {optimizeFeedback}
</div> </div>
)} )}
<div className="mb-3">
<FeedbackSummaryCard projectId={projectId} tasks={charterTasks} compact />
</div>
{selectedCount > 0 && ( {selectedCount > 0 && (
<div className="mb-3 flex flex-wrap items-center justify-between gap-2 rounded-xl bg-slate-50 px-3 py-2 text-xs text-slate-600"> <div className="mb-3 flex flex-wrap items-center justify-between gap-2 rounded-xl bg-slate-50 px-3 py-2 text-xs text-slate-600">
<span>已选择 {selectedCount} 项任务</span> <span>已选择 {selectedCount} 项任务</span>

View File

@ -113,6 +113,7 @@ export function SSTCWorkspace(props: SSTCWorkspaceViewProps) {
)} )}
{props.activeStage === "execution" && ( {props.activeStage === "execution" && (
<SSTCExecutionBoardPanel <SSTCExecutionBoardPanel
projectId={props.projectId}
stageLocked={orchestration.stageLocked} stageLocked={orchestration.stageLocked}
projectUsers={orchestration.projectUsers} projectUsers={orchestration.projectUsers}
optimizeFeedback={orchestration.optimizeFeedback} optimizeFeedback={orchestration.optimizeFeedback}

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { Sparkles } from "lucide-react"; import { Sparkles } from "lucide-react";
import { apiBaseURL, getAuthToken } from "@/lib/api"; import { apiBaseURL, getAuthToken } from "@/lib/api";
import { import {
@ -8,6 +8,7 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { QAHistoryPanel } from "@/components/ai-steward/QAHistoryPanel"; import { QAHistoryPanel } from "@/components/ai-steward/QAHistoryPanel";
import { parseUserRoles } from "@/api/users";
import { import {
aiStewardApi, aiStewardApi,
type LLMEngine, type LLMEngine,
@ -21,11 +22,15 @@ import type {
Blocker, Blocker,
CognitiveConflict, CognitiveConflict,
DailyBriefing, DailyBriefing,
FeedbackCenterSourceGroup,
FeedbackCenterSection,
FeedbackCenterTrendWindowDays,
EngineDataState, EngineDataState,
EngineForm, EngineForm,
EngineMessage, EngineMessage,
EngineProviderPresets, EngineProviderPresets,
ProjectFromTextResponse, ProjectFromTextResponse,
AIStewardTab,
} from "./types"; } from "./types";
import FeatureOverviewCards from "./components/FeatureOverviewCards"; import FeatureOverviewCards from "./components/FeatureOverviewCards";
import BlockersTab from "./sections/BlockersTab"; import BlockersTab from "./sections/BlockersTab";
@ -33,12 +38,54 @@ import BriefingTab from "./sections/BriefingTab";
import ConflictsTab from "./sections/ConflictsTab"; import ConflictsTab from "./sections/ConflictsTab";
import CreateProjectTab from "./sections/CreateProjectTab"; import CreateProjectTab from "./sections/CreateProjectTab";
import EngineTab from "./sections/EngineTab"; import EngineTab from "./sections/EngineTab";
import FeedbackCenterTab from "./sections/FeedbackCenterTab";
import OverviewTab from "./sections/OverviewTab"; import OverviewTab from "./sections/OverviewTab";
import SignalsTab from "./sections/SignalsTab"; import SignalsTab from "./sections/SignalsTab";
const AIS_STEWARD_TABS: AIStewardTab[] = [
"overview",
"create",
"signals",
"conflicts",
"blockers",
"briefing",
"qa",
"feedback-center",
"engine",
];
const FEEDBACK_SECTIONS: FeedbackCenterSection[] = ["overview", "metrics", "trend", "task", "qa", "signal"];
function normalizeTab(value: string | null): AIStewardTab {
if (value && AIS_STEWARD_TABS.includes(value as AIStewardTab)) {
return value as AIStewardTab;
}
return "overview";
}
function normalizeSection(value: string | null): FeedbackCenterSection {
if (value && FEEDBACK_SECTIONS.includes(value as FeedbackCenterSection)) {
return value as FeedbackCenterSection;
}
return "overview";
}
function normalizeTrendWindow(value: string | null): FeedbackCenterTrendWindowDays {
return value === "30" ? 30 : 7;
}
function normalizeTrendSourceGroup(value: string | null): FeedbackCenterSourceGroup {
if (value === "task" || value === "qa" || value === "signal") {
return value;
}
return "all";
}
export default function AIStewardPage() { export default function AIStewardPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const canViewFeedbackCenter = parseUserRoles(user?.role).some((role) => role === "ADMIN" || role === "BOSS");
// Project creation state // Project creation state
const [input, setInput] = useState(""); const [input, setInput] = useState("");
@ -47,7 +94,16 @@ export default function AIStewardPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// AI Steward features state // AI Steward features state
const [activeTab, setActiveTab] = useState("overview"); const [activeTab, setActiveTab] = useState<AIStewardTab>(() => normalizeTab(searchParams.get("tab")));
const [feedbackSection, setFeedbackSection] = useState<FeedbackCenterSection>(() =>
normalizeSection(searchParams.get("section"))
);
const [trendWindowDays, setTrendWindowDays] = useState<FeedbackCenterTrendWindowDays>(() =>
normalizeTrendWindow(searchParams.get("window"))
);
const [trendSourceGroup, setTrendSourceGroup] = useState<FeedbackCenterSourceGroup>(() =>
normalizeTrendSourceGroup(searchParams.get("scope"))
);
const [conflicts, setConflicts] = useState<CognitiveConflict[]>([]); const [conflicts, setConflicts] = useState<CognitiveConflict[]>([]);
const [blockers, setBlockers] = useState<Blocker[]>([]); const [blockers, setBlockers] = useState<Blocker[]>([]);
const [briefing, setBriefing] = useState<DailyBriefing | null>(null); const [briefing, setBriefing] = useState<DailyBriefing | null>(null);
@ -422,8 +478,68 @@ export default function AIStewardPage() {
} }
}; };
const syncUrlParams = (
tab: AIStewardTab,
section: FeedbackCenterSection,
nextWindowDays: FeedbackCenterTrendWindowDays = trendWindowDays,
nextSourceGroup: FeedbackCenterSourceGroup = trendSourceGroup
) => {
const next = new URLSearchParams(searchParams);
next.set("tab", tab);
next.set("section", section);
next.set("window", String(nextWindowDays));
next.set("scope", nextSourceGroup);
setSearchParams(next, { replace: true });
};
const handleTabChange = (value: string) => {
const nextTab = normalizeTab(value);
if (nextTab === "feedback-center" && !canViewFeedbackCenter) {
setActiveTab("overview");
syncUrlParams("overview", "overview");
return;
}
setActiveTab(nextTab);
syncUrlParams(nextTab, feedbackSection);
};
const handleFeedbackSectionChange = (nextSection: FeedbackCenterSection) => {
setFeedbackSection(nextSection);
syncUrlParams(activeTab, nextSection);
};
const handleTrendWindowDaysChange = (nextWindowDays: FeedbackCenterTrendWindowDays) => {
setTrendWindowDays(nextWindowDays);
syncUrlParams(activeTab, feedbackSection, nextWindowDays, trendSourceGroup);
};
const handleTrendSourceGroupChange = (nextSourceGroup: FeedbackCenterSourceGroup) => {
setTrendSourceGroup(nextSourceGroup);
syncUrlParams(activeTab, feedbackSection, trendWindowDays, nextSourceGroup);
};
// ==================== Initial Load ==================== // ==================== Initial Load ====================
useEffect(() => {
const urlTab = normalizeTab(searchParams.get("tab"));
const urlSection = normalizeSection(searchParams.get("section"));
const safeTab = urlTab === "feedback-center" && !canViewFeedbackCenter ? "overview" : urlTab;
if (safeTab !== activeTab) {
setActiveTab(safeTab);
}
if (urlSection !== feedbackSection) {
setFeedbackSection(urlSection);
}
const urlWindow = normalizeTrendWindow(searchParams.get("window"));
if (urlWindow !== trendWindowDays) {
setTrendWindowDays(urlWindow);
}
const urlSourceGroup = normalizeTrendSourceGroup(searchParams.get("scope"));
if (urlSourceGroup !== trendSourceGroup) {
setTrendSourceGroup(urlSourceGroup);
}
}, [searchParams, activeTab, feedbackSection, trendWindowDays, trendSourceGroup, canViewFeedbackCenter]);
useEffect(() => { useEffect(() => {
if (activeTab === "conflicts") loadConflicts(); if (activeTab === "conflicts") loadConflicts();
if (activeTab === "blockers") loadBlockers(); if (activeTab === "blockers") loadBlockers();
@ -452,12 +568,12 @@ export default function AIStewardPage() {
</p> </p>
</div> </div>
<FeatureOverviewCards onSelectTab={(tab) => setActiveTab(tab)} /> <FeatureOverviewCards onSelectTab={(tab) => handleTabChange(tab)} />
{/* Main Content */} {/* Main Content */}
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
<Tabs value={activeTab} onValueChange={setActiveTab}> <Tabs value={activeTab} onValueChange={handleTabChange}>
<TabsList className="flex w-full min-w-0 flex-nowrap gap-1 overflow-x-auto rounded-lg p-1 [-webkit-overflow-scrolling:touch]"> <TabsList className="flex w-full min-w-0 flex-nowrap gap-1 overflow-x-auto rounded-lg p-1 [-webkit-overflow-scrolling:touch]">
<TabsTrigger value="overview" className="shrink-0 whitespace-nowrap">总览</TabsTrigger> <TabsTrigger value="overview" className="shrink-0 whitespace-nowrap">总览</TabsTrigger>
<TabsTrigger value="create" className="shrink-0 whitespace-nowrap">零配置创建</TabsTrigger> <TabsTrigger value="create" className="shrink-0 whitespace-nowrap">零配置创建</TabsTrigger>
@ -466,6 +582,9 @@ export default function AIStewardPage() {
<TabsTrigger value="blockers" className="shrink-0 whitespace-nowrap">阻塞检测</TabsTrigger> <TabsTrigger value="blockers" className="shrink-0 whitespace-nowrap">阻塞检测</TabsTrigger>
<TabsTrigger value="briefing" className="shrink-0 whitespace-nowrap">每日简报</TabsTrigger> <TabsTrigger value="briefing" className="shrink-0 whitespace-nowrap">每日简报</TabsTrigger>
<TabsTrigger value="qa" className="shrink-0 whitespace-nowrap">问答记录</TabsTrigger> <TabsTrigger value="qa" className="shrink-0 whitespace-nowrap">问答记录</TabsTrigger>
{canViewFeedbackCenter && (
<TabsTrigger value="feedback-center" className="shrink-0 whitespace-nowrap">反馈中心</TabsTrigger>
)}
<TabsTrigger value="engine" className="shrink-0 whitespace-nowrap">引擎配置</TabsTrigger> <TabsTrigger value="engine" className="shrink-0 whitespace-nowrap">引擎配置</TabsTrigger>
</TabsList> </TabsList>
@ -511,6 +630,18 @@ export default function AIStewardPage() {
<QAHistoryPanel /> <QAHistoryPanel />
</TabsContent> </TabsContent>
{canViewFeedbackCenter && (
<FeedbackCenterTab
section={feedbackSection}
projectId={Number(searchParams.get("project_id") || "0") || null}
onSectionChange={handleFeedbackSectionChange}
trendWindowDays={trendWindowDays}
trendSourceGroup={trendSourceGroup}
onTrendWindowDaysChange={handleTrendWindowDaysChange}
onTrendSourceGroupChange={handleTrendSourceGroupChange}
/>
)}
<EngineTab <EngineTab
featureLoading={featureLoading} featureLoading={featureLoading}
engineData={engineData} engineData={engineData}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,17 @@
import type { LLMEngine, LLMEngineListResponse } from "@/api/aiSteward"; import type { LLMEngine, LLMEngineListResponse } from "@/api/aiSteward";
export type { FeedbackCenterTrendWindowDays, FeedbackCenterSourceGroup } from "@/api/feedbackCenter";
export type FeedbackCenterSection = "overview" | "metrics" | "trend" | "task" | "qa" | "signal";
export type AIStewardTab =
| "overview"
| "create"
| "signals"
| "conflicts"
| "blockers"
| "briefing"
| "qa"
| "feedback-center"
| "engine";
export interface ProjectFromTextResponse { export interface ProjectFromTextResponse {
project: { id: string; name: string; title: string; description: string; status: string }; project: { id: string; name: string; title: string; description: string; status: string };

File diff suppressed because one or more lines are too long