diff --git a/CLAUDE.md b/CLAUDE.md
index db330c0a..a4905d35 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -135,8 +135,18 @@ backend/
- 新增:`backend/tests/test_draft_generator_service.py`(草稿生成服务回归:PM 评审草稿包含相似项目、MVP、前景与需求清单)
- 新增:`backend/tests/test_signal_planner_contract.py`(Signal Planner 契约回归:最新宪章加载、prompt 注入、无宪章降级)
- 新增:`backend/tests/test_ai_prompt_registry_contract.py`(Prompt registry 契约回归:覆盖率与元数据完整性)
+- 新增:`backend/tests/test_prompt_profile_bundle_contract.py`(默认角色 prompt bundle 契约回归:PMFlow PMO 语气、职责边界与协作口径)
- 新增:`backend/alembic/versions/20260324_add_project_create_idempotency_indexes.py`(活动项目标题大小写无关唯一索引,收口项目创建重复写入)
- 新增:`backend/alembic/versions/20260326_add_task_breakdown_payload_fields.py`(tasks 拆解业务对象 JSONB 字段:`ai_breakdown_payload/manual_overrides`)
+- 新增:`backend/alembic/versions/20260326_create_feedback_center_tables.py`(统一反馈学习中心双表建模与 QA/Signal/Task 初始回填)
+- 新增:`backend/app/api/ai_steward_api/routes_feedback_center.py`(AI Steward 反馈中心接口:summary/list/detail/review/reason-catalog;summary 支持 `window_days` / `source_group` 趋势作用域)
+- 新增:`backend/app/models/feedback_center.py`(`FeedbackCenterRecord/FeedbackCenterRecordRevision` 统一反馈中心模型)
+- 新增:`backend/app/schemas/feedback_center.py`(反馈中心 summary/list/detail/review/reason-catalog 对应 Pydantic 模型)
+- 新增:`backend/app/services/feedback_center/`(反馈中心服务层:`service.py/mappers.py/reason_catalog.py`)
+- 新增:`backend/tests/test_feedback_center_service.py`(反馈中心服务回归:tenant/project 过滤、review 修订、映射与原因候选)
+- 新增:`backend/tests/test_feedback_center_api.py`(反馈中心 API 回归:路由、权限与三类来源回填)
+- 更新:`backend/app/services/feedback_center/service.py`(summary 输出新增 metrics 学习指标,并支持 `window_days` / `source_group` 的趋势分析作用域)
+- 更新:`backend/app/schemas/feedback_center.py`(summary 增加 metrics / trend 结构,并回传 `source_group`)
- 新增:`backend/app/services/signal_planner_service.py`(Signal Planner 异步分析服务)
- 新增:`backend/app/services/model/model_routing/service.py`(函数级模型路由配置服务)
- 新增:`backend/app/services/task_brief_service.py`(增量任务包中心服务)
@@ -346,7 +356,9 @@ frontend/
- 更新:`frontend/src/pages/AIStewardPage.tsx`(降级为轻量入口,转发到 `pages/ai-steward/AIStewardPage`)
- 新增:`frontend/src/pages/ai-steward/types.ts`、`frontend/src/pages/ai-steward/constants.ts`(AI Steward 类型与常量集中管理)
- 新增:`frontend/src/pages/ai-steward/components/FeatureOverviewCards.tsx`(AI Steward 首页功能卡片组件)
-- 新增:`frontend/src/pages/ai-steward/sections/OverviewTab.tsx`、`CreateProjectTab.tsx`、`SignalsTab.tsx`、`ConflictsTab.tsx`、`BlockersTab.tsx`、`BriefingTab.tsx`、`EngineTab.tsx`(AI Steward 分阶段 UI 拆分)
+- 新增:`frontend/src/pages/ai-steward/sections/OverviewTab.tsx`、`CreateProjectTab.tsx`、`SignalsTab.tsx`、`ConflictsTab.tsx`、`BlockersTab.tsx`、`BriefingTab.tsx`、`EngineTab.tsx`、`FeedbackCenterTab.tsx`(AI Steward 分阶段 UI 拆分)
+- 新增:`frontend/src/pages/ai-steward/components/feedback-center/FeedbackList.tsx`、`FeedbackDetail.tsx`、`ReasonPicker.tsx`、`MetricsPanel.tsx`、`TrendPanel.tsx`(反馈中心三栏工作台、指标页与趋势页组件;趋势页支持 `7/30 天` 与 `all/task/qa/signal` 局部筛选)
+- 新增:`frontend/src/api/feedbackCenter.ts`(统一反馈中心 API 封装)
- 新增:`frontend/src/pages/ai-steward/AIStewardPage.tsx`(AI Steward 页面编排层,主文件聚焦状态与事件)
- 更新:`frontend/src/pages/ProjectBoardPage.tsx`(降级为轻量入口,转发到 `pages/project-board/ProjectBoardPage`)
- 新增:`frontend/src/pages/project-board/helpers.ts`(Project Board 信号筛选状态与 PMO 工作项文本归一 helper)
@@ -377,6 +389,7 @@ frontend/
- `SSTCReleaseCreateDialog.tsx`:发布创建弹窗
- `SSTCReviewCreateDialog.tsx`:复盘创建弹窗
- `AIVisibilityPanel.tsx`:AI 可视化信息面板
+- `FeedbackSummaryCard.tsx`:反馈中心摘要卡片(任务阶段/执行看板复用)
- `SSTCGlobalHeader.tsx`:SSTC 全局头部
- `SSTCStageNav.tsx`:阶段导航
- `SSTCChainView.tsx`:链路视图
diff --git a/backend/alembic/versions/20260326_create_feedback_center_tables.py b/backend/alembic/versions/20260326_create_feedback_center_tables.py
new file mode 100644
index 00000000..d5788438
--- /dev/null
+++ b/backend/alembic/versions/20260326_create_feedback_center_tables.py
@@ -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")
diff --git a/backend/app/api/ai_steward_api/__init__.py b/backend/app/api/ai_steward_api/__init__.py
index c966b11a..7f07d692 100644
--- a/backend/app/api/ai_steward_api/__init__.py
+++ b/backend/app/api/ai_steward_api/__init__.py
@@ -6,6 +6,7 @@ from . import (
routes_brief_blockers,
routes_capture,
routes_conflicts,
+ routes_feedback_center,
routes_rag_sources,
routes_stats_config,
routes_workflow,
@@ -16,6 +17,7 @@ for _module in [
routes_rag_sources,
routes_capture,
routes_conflicts,
+ routes_feedback_center,
routes_brief_blockers,
routes_stats_config,
routes_workflow,
diff --git a/backend/app/api/ai_steward_api/routes_feedback_center.py b/backend/app/api/ai_steward_api/routes_feedback_center.py
new file mode 100644
index 00000000..53e11280
--- /dev/null
+++ b/backend/app/api/ai_steward_api/routes_feedback_center.py
@@ -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),
+ }
+ )
diff --git a/backend/app/api/feedbacks.py b/backend/app/api/feedbacks.py
index 0f87d191..af8a6b6e 100644
--- a/backend/app/api/feedbacks.py
+++ b/backend/app/api/feedbacks.py
@@ -1,57 +1,27 @@
"""反馈统计 API"""
+from __future__ import annotations
+
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy.future import select
from ..core.database import get_db
-from ..models import SignalFeedback
+from ..core.response import success_response
+from ..dependencies.auth import get_current_user
+from ..dependencies.tenant import get_tenant_id
+from ..models import User
+from ..services.feedback_center import FeedbackCenterService
router = APIRouter(prefix="/feedbacks", tags=["feedbacks"])
@router.get("/stats")
-async def get_feedback_stats(db: AsyncSession = Depends(get_db)):
+async def get_feedback_stats(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+ tenant_id: int = Depends(get_tenant_id),
+):
"""获取反馈统计"""
- result = await db.execute(select(SignalFeedback))
- all_feedbacks = result.scalars().all()
-
- total = len(all_feedbacks)
- if total == 0:
- return {
- "total_feedbacks": 0,
- "accuracy": 0.0,
- "true_positives": 0,
- "false_positives": 0,
- "true_negatives": 0,
- "false_negatives": 0,
- }
-
- correct = sum(
- 1 for f in all_feedbacks if f.ai_predicted_strategic == f.was_strategic
- )
- true_positives = sum(
- 1 for f in all_feedbacks if f.ai_predicted_strategic and f.was_strategic
- )
- false_positives = sum(
- 1 for f in all_feedbacks if f.ai_predicted_strategic and not f.was_strategic
- )
- true_negatives = sum(
- 1
- for f in all_feedbacks
- if not f.ai_predicted_strategic and not f.was_strategic
- )
- false_negatives = sum(
- 1
- for f in all_feedbacks
- if not f.ai_predicted_strategic and f.was_strategic
- )
-
- return {
- "total_feedbacks": total,
- "accuracy": correct / total,
- "true_positives": true_positives,
- "false_positives": false_positives,
- "true_negatives": true_negatives,
- "false_negatives": false_negatives,
- }
+ effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
+ data = await FeedbackCenterService(db).get_signal_feedback_stats(tenant_id=effective_tenant_id)
+ return success_response(data)
diff --git a/backend/app/api/rag_api/routes_qa.py b/backend/app/api/rag_api/routes_qa.py
index 1bae2213..19933e66 100644
--- a/backend/app/api/rag_api/routes_qa.py
+++ b/backend/app/api/rag_api/routes_qa.py
@@ -1,7 +1,11 @@
from __future__ import annotations
+from sqlalchemy import and_, select
+
from .shared import *
from .helpers import _resolve_tenant_id
+from ...models import QAFeedback, QARecord
+from ...services.feedback_center import FeedbackCenterService
@router.post("/qa-records/{qa_id}/feedback")
async def submit_qa_feedback(
@@ -23,6 +27,38 @@ async def submit_qa_feedback(
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
+ try:
+ feedback_row = (
+ await db.execute(
+ select(QAFeedback)
+ .where(
+ and_(
+ QAFeedback.tenant_id == _resolve_tenant_id(current_user, tenant_id),
+ QAFeedback.qa_id == qa_id,
+ QAFeedback.submitted_by == int(current_user.id),
+ )
+ )
+ .order_by(QAFeedback.created_at.desc(), QAFeedback.id.desc())
+ )
+ ).scalars().first()
+ qa_row = (
+ await db.execute(
+ select(QARecord).where(
+ and_(
+ QARecord.tenant_id == _resolve_tenant_id(current_user, tenant_id),
+ QARecord.id == qa_id,
+ )
+ )
+ )
+ ).scalar_one_or_none()
+ if feedback_row and qa_row:
+ await FeedbackCenterService(db).ingest_qa_feedback(
+ qa_feedback=feedback_row,
+ qa_record=qa_row,
+ commit=True,
+ )
+ except Exception:
+ await db.rollback()
return success_response(result)
@router.get("/qa-records")
@@ -195,4 +231,3 @@ async def export_qa_records_jsonl(
date_to=query.date_to,
)
return Response(content=payload, media_type="application/x-ndjson")
-
diff --git a/backend/app/api/signals_api/feedback.py b/backend/app/api/signals_api/feedback.py
index 00d1d29e..cdedad22 100644
--- a/backend/app/api/signals_api/feedback.py
+++ b/backend/app/api/signals_api/feedback.py
@@ -9,21 +9,33 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
+from ...core.response import success_response
from ...core.database import get_db
-from ...models import Signal, SignalFeedback
+from ...dependencies.auth import get_current_user
+from ...dependencies.tenant import get_tenant_id
+from ...models import Signal, SignalFeedback, User
from ...schemas import SignalFeedbackCreate, SignalFeedbackResponse
+from ...services.feedback_center import FeedbackCenterService
router = APIRouter()
-@router.post("/{signal_id}/feedback", response_model=SignalFeedbackResponse)
+@router.post("/{signal_id}/feedback")
async def submit_signal_feedback(
signal_id: int,
feedback: SignalFeedbackCreate,
+ current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
+ tenant_id: int = Depends(get_tenant_id),
):
"""提交信号反馈"""
- result = await db.execute(select(Signal).where(Signal.id == signal_id))
+ effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
+ result = await db.execute(
+ select(Signal).where(
+ Signal.id == signal_id,
+ Signal.tenant_id == effective_tenant_id,
+ )
+ )
signal = result.scalar_one_or_none()
if not signal:
raise HTTPException(status_code=404, detail="Signal not found")
@@ -39,10 +51,11 @@ async def submit_signal_feedback(
ai_predicted_strategic = signal.impact_score >= 0.9
new_feedback = SignalFeedback(
+ tenant_id=effective_tenant_id,
signal_id=signal_id,
was_strategic=feedback.was_strategic,
reason=feedback.reason,
- user_id=feedback.user_id,
+ user_id=str(getattr(current_user, "id", feedback.user_id) or feedback.user_id or ""),
ai_predicted_strategic=ai_predicted_strategic,
ai_confidence=ai_confidence,
)
@@ -50,32 +63,48 @@ async def submit_signal_feedback(
await db.commit()
await db.refresh(new_feedback)
+ try:
+ await FeedbackCenterService(db).ingest_signal_feedback(
+ signal_feedback=new_feedback,
+ signal=signal,
+ commit=True,
+ )
+ except Exception:
+ await db.rollback()
+
is_correct = ai_predicted_strategic == feedback.was_strategic
- return SignalFeedbackResponse(
- id=new_feedback.id,
- signal_id=new_feedback.signal_id,
- was_strategic=new_feedback.was_strategic,
- ai_predicted_strategic=new_feedback.ai_predicted_strategic,
- ai_confidence=new_feedback.ai_confidence,
- is_correct=is_correct,
+ return success_response(
+ SignalFeedbackResponse(
+ id=new_feedback.id,
+ signal_id=new_feedback.signal_id,
+ was_strategic=new_feedback.was_strategic,
+ ai_predicted_strategic=new_feedback.ai_predicted_strategic,
+ ai_confidence=new_feedback.ai_confidence or 0.0,
+ is_correct=is_correct,
+ ).model_dump()
)
-
-@router.get("/{signal_id}/feedbacks", response_model=List[SignalFeedbackResponse])
+@router.get("/{signal_id}/feedbacks")
async def get_signal_feedbacks(
signal_id: int,
+ current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
+ tenant_id: int = Depends(get_tenant_id),
):
"""获取某信号的全部反馈"""
+ effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
result = await db.execute(
select(SignalFeedback)
- .where(SignalFeedback.signal_id == signal_id)
+ .where(
+ SignalFeedback.signal_id == signal_id,
+ SignalFeedback.tenant_id == effective_tenant_id,
+ )
.order_by(SignalFeedback.created_at.desc())
)
feedbacks = result.scalars().all()
- return [
+ items = [
SignalFeedbackResponse(
id=feedback.id,
signal_id=feedback.signal_id,
@@ -83,6 +112,7 @@ async def get_signal_feedbacks(
ai_predicted_strategic=feedback.ai_predicted_strategic,
ai_confidence=feedback.ai_confidence or 0.0,
is_correct=feedback.ai_predicted_strategic == feedback.was_strategic,
- )
+ ).model_dump()
for feedback in feedbacks
]
+ return success_response({"items": items})
diff --git a/backend/app/api/sstc_api/tasks_ai_breakdown.py b/backend/app/api/sstc_api/tasks_ai_breakdown.py
index 76fd5391..0bdcb424 100644
--- a/backend/app/api/sstc_api/tasks_ai_breakdown.py
+++ b/backend/app/api/sstc_api/tasks_ai_breakdown.py
@@ -14,6 +14,7 @@ from ...services.sstc.entity_lookup import _ensure_charter, _ensure_project
from ...services.sstc.governance import _assert_charter_aligned, _require_manager
from ...services.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async
from ...services.context_cache_service import context_cache_service
+from ...services.feedback_center import FeedbackCenterService, map_task_breakdown_to_record_payload
from ...services.task_breakdown import (
build_task_breakdown_ai_payload,
normalize_task_breakdown_payload,
@@ -263,6 +264,23 @@ async def ai_breakdown_tasks(
user_id=user_id,
)
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
+ try:
+ payloads = [
+ map_task_breakdown_to_record_payload(
+ task=t,
+ project_title=project.title,
+ source_context=ai_breakdown_payload["source_context"],
+ )
+ for t in created
+ ]
+ if payloads:
+ await FeedbackCenterService(db).ingest_payloads(payloads=payloads, commit=True)
+ except Exception:
+ if hasattr(db, "rollback"):
+ try:
+ await db.rollback()
+ except Exception:
+ pass
return success_response(
{
"created_count": len(created),
diff --git a/backend/app/api/sstc_api/tasks_update_delete.py b/backend/app/api/sstc_api/tasks_update_delete.py
index dbdd824b..58c85b59 100644
--- a/backend/app/api/sstc_api/tasks_update_delete.py
+++ b/backend/app/api/sstc_api/tasks_update_delete.py
@@ -3,6 +3,7 @@ from __future__ import annotations
from .shared import *
from ...services.context_cache_service import context_cache_service
+from ...services.feedback_center import FeedbackCenterService
from ...services.sstc.entity_lookup import _ensure_project, _ensure_task, _parse_due_date
from ...services.sstc.governance import _require_manager
from ...services.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async
@@ -118,6 +119,14 @@ async def patch_task(
title=task.title,
content=task.description or "",
)
+ try:
+ await FeedbackCenterService(db).ingest_task_overrides(task=task, commit=True)
+ except Exception:
+ if hasattr(db, "rollback"):
+ try:
+ await db.rollback()
+ except Exception:
+ pass
return success_response({"id": str(task.id), "status": task.status})
diff --git a/backend/app/config/ai_prompt_registry.py b/backend/app/config/ai_prompt_registry.py
index 76202a37..a67abc79 100644
--- a/backend/app/config/ai_prompt_registry.py
+++ b/backend/app/config/ai_prompt_registry.py
@@ -4,6 +4,109 @@ from typing import Any
from .ai_prompts_catalog import __all__ as PROMPT_CATALOG_EXPORTS
+PROMPT_FAMILY_CLASSIFICATION = "classification"
+PROMPT_FAMILY_EXTRACTION = "extraction"
+PROMPT_FAMILY_PLANNING = "planning"
+PROMPT_FAMILY_RESPONSE = "response"
+PROMPT_FAMILY_GOVERNANCE = "governance"
+
+PROMPT_FAMILY_ALLOWED = {
+ PROMPT_FAMILY_CLASSIFICATION,
+ PROMPT_FAMILY_EXTRACTION,
+ PROMPT_FAMILY_PLANNING,
+ PROMPT_FAMILY_RESPONSE,
+ PROMPT_FAMILY_GOVERNANCE,
+}
+
+PROMPT_FAMILY_KEYWORD_RULES: tuple[tuple[str, str], ...] = (
+ ("CLASSIFICATION", PROMPT_FAMILY_CLASSIFICATION),
+ ("EXTRACTION", PROMPT_FAMILY_EXTRACTION),
+ ("PREFILL", PROMPT_FAMILY_EXTRACTION),
+ ("INTENT", PROMPT_FAMILY_CLASSIFICATION),
+ ("TASK_ORCHESTRATION", PROMPT_FAMILY_PLANNING),
+ ("PLANNER", PROMPT_FAMILY_PLANNING),
+ ("GENERATE", PROMPT_FAMILY_PLANNING),
+ ("DECOMPOSE", PROMPT_FAMILY_PLANNING),
+ ("RECOMMEND", PROMPT_FAMILY_PLANNING),
+ ("DRAFT", PROMPT_FAMILY_PLANNING),
+ ("TASK_BREAKDOWN", PROMPT_FAMILY_PLANNING),
+ ("QA", PROMPT_FAMILY_RESPONSE),
+ ("REPLY", PROMPT_FAMILY_RESPONSE),
+)
+
+PROMPT_FAMILY_SCENARIO_RULES: tuple[tuple[str, str], ...] = (
+ ("分类", PROMPT_FAMILY_CLASSIFICATION),
+ ("抽取", PROMPT_FAMILY_EXTRACTION),
+ ("预填", PROMPT_FAMILY_EXTRACTION),
+ ("规划", PROMPT_FAMILY_PLANNING),
+ ("编排", PROMPT_FAMILY_PLANNING),
+ ("草案", PROMPT_FAMILY_PLANNING),
+ ("拆解", PROMPT_FAMILY_PLANNING),
+ ("问答", PROMPT_FAMILY_RESPONSE),
+ ("回复", PROMPT_FAMILY_RESPONSE),
+)
+
+RESPONSIBILITY_SCOPE_BY_FAMILY: dict[str, str] = {
+ PROMPT_FAMILY_CLASSIFICATION: "分类层:只负责识别请求类型、归属和优先级,不生成执行方案。",
+ PROMPT_FAMILY_EXTRACTION: "抽取层:只负责结构化提取实体、槽位和证据,不做规划决策。",
+ PROMPT_FAMILY_PLANNING: "规划层:只负责生成可执行规划、任务和负责人建议,不承担最终对话呈现。",
+ PROMPT_FAMILY_RESPONSE: "呈现层:只负责面向用户输出结论与建议,不直接做写操作决策。",
+ PROMPT_FAMILY_GOVERNANCE: "治理层:只负责约束、评估、同步与风控,不承担主对话生成。",
+}
+
+PROMPT_PRIORITY_BY_FAMILY: dict[str, str] = {
+ PROMPT_FAMILY_CLASSIFICATION: "high",
+ PROMPT_FAMILY_EXTRACTION: "high",
+ PROMPT_FAMILY_PLANNING: "high",
+ PROMPT_FAMILY_RESPONSE: "high",
+ PROMPT_FAMILY_GOVERNANCE: "medium",
+}
+
+PROMPT_AUDIENCE_BY_FAMILY: dict[str, str] = {
+ PROMPT_FAMILY_CLASSIFICATION: "signal_router",
+ PROMPT_FAMILY_EXTRACTION: "signal_ingestion",
+ PROMPT_FAMILY_PLANNING: "planning_orchestrator",
+ PROMPT_FAMILY_RESPONSE: "assistant_response",
+ PROMPT_FAMILY_GOVERNANCE: "risk_budget_guardrail",
+}
+
+
+def _normalize_family(prompt_family: str) -> str:
+ value = str(prompt_family or "").strip().lower()
+ if value in PROMPT_FAMILY_ALLOWED:
+ return value
+ return PROMPT_FAMILY_GOVERNANCE
+
+
+def _derive_prompt_family(prompt_key: str, business_scenario: str) -> str:
+ key_text = str(prompt_key or "").strip().upper()
+ scenario_text = str(business_scenario or "").strip()
+
+ for keyword, family in PROMPT_FAMILY_KEYWORD_RULES:
+ if keyword in key_text:
+ return family
+
+ for keyword, family in PROMPT_FAMILY_SCENARIO_RULES:
+ if keyword in scenario_text:
+ return family
+
+ return PROMPT_FAMILY_GOVERNANCE
+
+
+def _derive_responsibility_scope(prompt_family: str) -> str:
+ family = _normalize_family(prompt_family)
+ return RESPONSIBILITY_SCOPE_BY_FAMILY[family]
+
+
+def _derive_priority(prompt_family: str) -> str:
+ family = _normalize_family(prompt_family)
+ return PROMPT_PRIORITY_BY_FAMILY[family]
+
+
+def _derive_audience(prompt_family: str) -> str:
+ family = _normalize_family(prompt_family)
+ return PROMPT_AUDIENCE_BY_FAMILY[family]
+
def _entry(
prompt_key: str,
@@ -13,7 +116,16 @@ def _entry(
input_contract: str,
output_contract: str,
call_sites: tuple[str, ...] = (),
+ prompt_family: str | None = None,
+ responsibility_scope: str | None = None,
+ priority: str | None = None,
+ audience: str | None = None,
) -> dict[str, Any]:
+ resolved_family = _normalize_family(prompt_family or _derive_prompt_family(prompt_key, business_scenario))
+ resolved_scope = str(responsibility_scope or "").strip() or _derive_responsibility_scope(resolved_family)
+ resolved_priority = str(priority or "").strip().lower() or _derive_priority(resolved_family)
+ resolved_audience = str(audience or "").strip() or _derive_audience(resolved_family)
+
return {
"prompt_key": prompt_key,
"module": module,
@@ -21,6 +133,10 @@ def _entry(
"call_sites": tuple(str(site) for site in call_sites if str(site or "").strip()),
"input_contract": input_contract,
"output_contract": output_contract,
+ "prompt_family": resolved_family,
+ "responsibility_scope": resolved_scope,
+ "priority": resolved_priority,
+ "audience": resolved_audience,
}
@@ -64,13 +180,6 @@ def _part_01_entries() -> tuple[dict[str, Any], ...]:
input_contract=JSON_INPUT,
output_contract="输出 JSON 对象,包含影响结论、风险、建议。",
),
- _entry(
- "PROMPT_TASK_BREAKDOWN",
- module="part_01",
- business_scenario="任务拆解基础片段",
- input_contract=FRAGMENT_INPUT,
- output_contract=FRAGMENT_OUTPUT,
- ),
_entry(
"PROMPT_STRATEGIC_ANALYSIS_HEADER",
module="part_01",
@@ -106,14 +215,6 @@ def _part_01_entries() -> tuple[dict[str, Any], ...]:
input_contract=JSON_INPUT,
output_contract="输出 JSON 对象,包含关键洞察与下一步建议。",
),
- _entry(
- "PROMPT_FEISHU_REQUIREMENT_ANALYSIS",
- module="part_01",
- business_scenario="飞书需求解析",
- input_contract=JSON_INPUT,
- output_contract="输出 JSON 对象,包含需求抽取、风险与建议。",
- call_sites=FEISHU_EXTRACT_CALL_SITES,
- ),
_entry(
"PROMPT_FEISHU_HUMANIZED_REPLY",
module="part_01",
diff --git a/backend/app/config/ai_prompts.py b/backend/app/config/ai_prompts.py
index a67226c1..94925d8e 100644
--- a/backend/app/config/ai_prompts.py
+++ b/backend/app/config/ai_prompts.py
@@ -9,13 +9,11 @@ AI Prompt 集中管理
from .ai_prompts_catalog import (
PROMPT_IMPACT_ANALYSIS,
- PROMPT_TASK_BREAKDOWN,
PROMPT_STRATEGIC_ANALYSIS_HEADER,
PROMPT_RAG_CONTEXT_HEADER,
PROMPT_RAG_CONTEXT_ITEM,
PROMPT_CODE_AUDIT,
PROMPT_CONTEXT_INSIGHTS,
- PROMPT_FEISHU_REQUIREMENT_ANALYSIS,
PROMPT_FEISHU_HUMANIZED_REPLY,
PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION,
PROMPT_FEISHU_LEVEL_CLASSIFICATION,
@@ -62,6 +60,12 @@ from .ai_prompt_registry import (
iter_prompt_registry_keys,
)
+
+# Deprecated compatibility aliases kept outside the registry surface.
+PROMPT_FEISHU_REQUIREMENT_ANALYSIS = PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION
+PROMPT_TASK_BREAKDOWN = PROMPT_DECOMPOSE_TASKS
+
+
def build_default_role_prompt_bundle() -> dict:
"""返回默认角色 Prompt 配置(每次调用返回新对象)。"""
return {
@@ -74,26 +78,25 @@ def build_default_role_prompt_bundle() -> dict:
"role_description": "负责查重、评审、起草立项建议并给出确认前的产品方案",
"enabled": True,
"system_prompt": """
-你是 PMFlow 的产品经理助手。
+你是 PMFlow 的 PMO 协作中枢中的立项评审角色(pm_dispatcher)。
-职责定位:
-1. 先判断是否已有同名或相似项目,再给出是否复用、合并或新建的建议。
-2. 先输出产品评审结论,再展开项目定位、MVP、前景、需求清单、风险与负责人建议。
-3. 回复风格要专业、清晰、像真实产品经理评审,允许给出取舍与备选方案。
-4. 只有在用户明确确认后,才进入写入或继续执行流程。
+职责边界(只做评审与起草,不直接执行写操作):
+1. 聚焦查重、复用/合并、立项评审,优先判断是否已有同名或相似项目。
+2. 输出面向业务决策的结论:是否新建、复用、合并或暂缓。
+3. 基于项目上下文产出可讨论草案,覆盖项目定位、MVP、需求清单、风险与负责人建议。
+4. 若信息不足,提出关键澄清问题(最多 3 个),不凭空补齐。
-输出要求:
-1. 先用 1-2 句话复述需求并判断立项方向。
-2. 再给出是否有同名/相似项目的判断。
-3. 再展开项目定位、MVP、前景、需求清单、风险与待补充信息。
-4. 如果信息不足,只提关键澄清问题(最多 3 个),不要臆测。
-5. 如果涉及风险、依赖、排期冲突或重复建设,要明确提醒。
+输出框架:
+1. 先结论:1-2 句复述需求并给出立项方向。
+2. 再查重:明确同名或相似项目判断,给出差异点与复用价值。
+3. 再方案:给出 MVP、关键需求、主要风险、负责人建议、后续动作。
+4. 全程专业、克制、结论先行,避免空泛口号。
禁止项:
-- 不要输出与项目无关的泛化建议。
-- 不要只给机械化模板,不要把评审写成简单入库文案。
-- 不要使用命令式生硬语气。
-- 不要编造不存在的数据或结论。
+- 不要编造不存在的数据、进展或执行结果。
+- 不要把评审写成机械化模板。
+- 不要使用命令式、生硬语气。
+- 不要输出与 PMFlow 业务无关的泛化建议。
""".strip(),
},
{
@@ -102,16 +105,21 @@ def build_default_role_prompt_bundle() -> dict:
"role_description": "负责技术方案约束、风险评估与实现边界",
"enabled": True,
"system_prompt": """
-你是 PMFlow 的技术架构师。
+你是 PMFlow 的技术架构角色(tech_architect)。
+
+职责边界:
+1. 专注可行性、依赖、风险边界,不代替产品评审或交付推进。
+2. 输出技术结论与约束,帮助团队判断是否能安全落地。
-你需要基于需求识别实现边界、关键风险和技术依赖,给出可执行的技术建议。
输出要点:
-1. 先给结论(可行/有风险/需拆分)。
-2. 给出技术风险与约束(性能、安全、兼容、成本)。
-3. 提供建议的实现路径与最小可行步骤。
-4. 结尾给出是否建议立即进入开发。
+1. 先给结论:可行 / 有风险 / 需拆分。
+2. 再给依据:关键依赖、性能风险、安全风险、兼容风险、成本约束。
+3. 再给路径:最小可行步骤、建议拆分方式、必要前置条件。
+4. 结尾明确:是否建议立即进入开发。
-风格:简洁、严谨、可执行,不讲空话。
+风格要求:
+- 简洁、严谨、可执行。
+- 不讲空话,不编造验证结果。
""".strip(),
},
{
@@ -120,15 +128,20 @@ def build_default_role_prompt_bundle() -> dict:
"role_description": "负责推进节奏、协调分工与交付闭环",
"enabled": True,
"system_prompt": """
-你是 PMFlow 的交付推进官。
+你是 PMFlow 的交付推进角色(delivery_coach)。
+
+职责边界:
+1. 聚焦节奏、排期、负责人、推进动作。
+2. 不代替技术评审,不输出与推进无关的实现细节。
-你要把需求转成可执行的交付动作,强调优先级、负责人和时间边界。
输出格式:
-1. 当前状态判断(一句话)。
-2. 建议动作(不超过 3 条,动词开头)。
-3. 风险提示(如有)。
+1. 当前状态判断(一句话,先结论)。
+2. 建议动作(不超过 3 条,动词开头,强调优先级与负责人)。
+3. 风险提示(如有,突出阻塞与依赖)。
-风格:明确、友好、推动协作,避免过度技术细节。
+风格要求:
+- 明确、友好、推动协作。
+- 结论先行,动作可执行,避免空泛管理话术。
""".strip(),
},
],
diff --git a/backend/app/config/ai_prompts_catalog/__init__.py b/backend/app/config/ai_prompts_catalog/__init__.py
index 3686b8a6..f70819ea 100644
--- a/backend/app/config/ai_prompts_catalog/__init__.py
+++ b/backend/app/config/ai_prompts_catalog/__init__.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from .part_01 import PROMPT_IMPACT_ANALYSIS, PROMPT_TASK_BREAKDOWN, PROMPT_STRATEGIC_ANALYSIS_HEADER, PROMPT_RAG_CONTEXT_HEADER, PROMPT_RAG_CONTEXT_ITEM, PROMPT_CODE_AUDIT, PROMPT_CONTEXT_INSIGHTS, PROMPT_FEISHU_REQUIREMENT_ANALYSIS, PROMPT_FEISHU_HUMANIZED_REPLY, PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, PROMPT_FEISHU_LEVEL_CLASSIFICATION
+from .part_01 import PROMPT_IMPACT_ANALYSIS, PROMPT_STRATEGIC_ANALYSIS_HEADER, PROMPT_RAG_CONTEXT_HEADER, PROMPT_RAG_CONTEXT_ITEM, PROMPT_CODE_AUDIT, PROMPT_CONTEXT_INSIGHTS, PROMPT_FEISHU_HUMANIZED_REPLY, PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, PROMPT_FEISHU_LEVEL_CLASSIFICATION
from .part_02 import PROMPT_FEISHU_SIGNAL_EXTRACTION, PROMPT_FEISHU_PROJECT_PREFILL, PROMPT_FEISHU_TASK_ORCHESTRATION, PROMPT_SIGNAL_SOURCE_ONBOARDING, PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION, PROMPT_AI_PMO_INTENT_CLASSIFICATION, PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS, PROMPT_DRAFT_GENERATOR, PROMPT_RAG_INTENT_CLASSIFICATION
from .part_03 import PROMPT_RAG_ANSWER, PROMPT_DECISION_QA, PROMPT_RAG_DB_PROJECT_QA, PROMPT_GENERATE_CHARTER, PROMPT_DECOMPOSE_TASKS, PROMPT_RECOMMEND_ASSIGNEES, PROMPT_ALIGNMENT_SCORE, PROMPT_COGNITIVE_CONFLICTS, PROMPT_EVALUATE_MODEL_REQUIREMENT, PROMPT_ANALYZE_CAPABILITY_GAP, PROMPT_ANALYZE_COST_ROI
from .part_04 import PROMPT_ANALYZE_COST_OPTIMIZATION, PROMPT_ASSESS_MODEL_UPDATE_IMPACT, PROMPT_EXPERIMENT_SYNC_DECISION, PROMPT_SKILL_SCORE_UPDATE, PROMPT_WORKLOAD_OPTIMIZATION, PROMPT_ANALYZE_FOOD_CALORIES, PROMPT_SIGNAL_PLANNER
@@ -13,13 +13,11 @@ from .part_scenes_06 import PROMPT_EXTERNAL_TREND_QA, PROMPT_INDUSTRY_TREND_QA
__all__ = [
"PROMPT_IMPACT_ANALYSIS",
- "PROMPT_TASK_BREAKDOWN",
"PROMPT_STRATEGIC_ANALYSIS_HEADER",
"PROMPT_RAG_CONTEXT_HEADER",
"PROMPT_RAG_CONTEXT_ITEM",
"PROMPT_CODE_AUDIT",
"PROMPT_CONTEXT_INSIGHTS",
- "PROMPT_FEISHU_REQUIREMENT_ANALYSIS",
"PROMPT_FEISHU_HUMANIZED_REPLY",
"PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION",
"PROMPT_FEISHU_LEVEL_CLASSIFICATION",
diff --git a/backend/app/config/ai_prompts_catalog/part_01.py b/backend/app/config/ai_prompts_catalog/part_01.py
index 956dc32c..d3947640 100644
--- a/backend/app/config/ai_prompts_catalog/part_01.py
+++ b/backend/app/config/ai_prompts_catalog/part_01.py
@@ -1,116 +1,110 @@
from __future__ import annotations
+def _compose_prompt(*parts: str) -> str:
+ return "\n\n".join(part.strip() for part in parts if part and part.strip())
+
+PMFLOW_PMO_STYLE_BASE = """
+你是 PMFlow 的 AI PMO 协作中枢成员。
+风格要求:
+1. 专业、克制、结论先行。
+2. 先给判断,再给依据,再给下一步。
+3. 优先引用可验证事实,不写空泛套话。
+""".strip()
+
+JSON_OUTPUT_GUARDRAIL = """
+统一输出约束:
+1. 只输出 JSON,不要输出 Markdown、解释文本、思考过程或代码块。
+2. 字段不确定时返回 null、空字符串或空数组,不要猜测。
+3. 不要编造执行结果、外部数据或系统内部状态。
+""".strip()
+
+TEXT_OUTPUT_GUARDRAIL = """
+统一输出约束:
+1. 只输出最终回复正文,不要输出 JSON、Markdown、思考过程。
+2. 若信息不足,明确写出缺失字段或缺失信息,不要编造。
+3. 禁止宣称未在上下文明确完成的动作。
+""".strip()
+
PROMPT_IMPACT_ANALYSIS = """
-Analyze the following news/signal for an AI engineering team.
-Determine its "Impact Score" (0.0 to 1.0) based on how critical it is for our RAG/LLM development.
+你是 PMFlow 的技术信号影响评估助手。
+请评估以下技术信号对 AI 开发团队(RAG/LLM)的影响分值(0.0-1.0)。
Signal: "{signal_content}"
-Rules:
-- 0.9-1.0: Major SOTA model release (e.g., GPT-5, DeepSeek-V4), critical vulnerability, or massive breakthrough.
-- 0.7-0.8: Important update, new useful library, or relevant research paper.
-- 0.4-0.6: Interesting but not critical news.
-- 0.0-0.3: Irrelevant noise.
+评分参考:
+- 0.9-1.0:SOTA 级模型发布、关键安全事件、行业级突破。
+- 0.7-0.8:重要版本更新、关键库更新、强相关研究发布。
+- 0.4-0.6:有参考价值但非关键。
+- 0.0-0.3:噪声或相关性低。
-Output ONLY the numeric score (e.g., 0.85). Do not output any text.
-"""
-
-PROMPT_TASK_BREAKDOWN = """
-You are an expert AI Technical Project Manager.
-Your goal is to break down a software feature request into specific, actionable technical tasks.
-
-Feature Request: "{description}"
-
-Required Output Format: JSON Object only.
-{
- "tasks": [
- {
- "task_key": "task-1",
- "title": "Concise task title",
- "description": "What this task delivers",
- "type": "frontend|backend|database|ai|devops",
- "estimated_hours": 2.0,
- "required_skill_names": ["Skill Name"],
- "required_skill_tag_ids": [1],
- "depends_on_task_keys": [],
- "risk_level": "low|medium|high|critical",
- "risk_notes": "Risk summary",
- "confidence": 0.0,
- "evidence_refs": ["charter.objectives.0"],
- "priority": "high|medium|low"
- }
- ],
- "detected_skills": ["Skill Name"]
-}
-
-Ensure the breakdown is comprehensive and strictly follows the JSON format.
-Do not include any markdown formatting or explanation outside the JSON.
+只输出数字(例如 0.85),不要输出其他文本。
"""
PROMPT_STRATEGIC_ANALYSIS_HEADER = """
-You are a Chief Technology Officer (CTO) AI Assistant for our team.
+你是 PMFlow 的技术战略分析助手。请评估外部信号对当前产品竞争力的影响并给出策略建议。
-=== Our Product/Team ===
+=== 团队业务上下文 ===
{business_context}
{rag_context}
-=== External Technology Signal ===
+
+=== 外部技术信号 ===
Signal: "{signal_content}"
-=== Your Task ===
-1. Analyze: How does this signal impact OUR product's competitiveness?
-2. Decide: Is it necessary to follow up immediately? (is_strategic)
-3. Proposal: If YES, generate a "Strategic Decision Proposal".
-4. Competitiveness Radar: Evaluate current capabilities vs potential capabilities after adopting this technology.
- - Dimensions: "Efficiency" (Speed), "Cost" (Savings), "UX" (Experience), "Innovation" (Tech Edge), "Market" (Share).
- - Score 1-10.
-5. Detected Tech Stacks: Identify technologies mentioned in the signal (frameworks, models, APIs, tools).
- - Each stack should have: name, category (framework/model/tool/api), relevance (high/medium/low).
-6. Related Knowledge Nodes: Suggest relevant knowledge domains for context tracking.
- - Examples: "Civitai LoRA Integration", "Sora API Adoption", "Video Generation Pipeline".
-7. Plan: Break down the strategy into specific technical tasks.
+任务:
+1. 判断是否需要立即跟进(is_strategic)。
+2. 给出战略建议(即使不跟进,也要说明理由)。
+3. 输出竞争力雷达评估(current/projected)。
+4. 识别信号涉及的技术栈与关联知识节点。
+5. 若需要跟进,输出执行任务建议。
-Required Output Format (JSON Only):
+输出 JSON:
{{
- "is_strategic": boolean,
- "confidence": float,
- "decision_proposal": {{
- "situation_analysis": "...",
- "recommendation": "...",
- "expected_benefits": "..."
- }},
- "competitiveness_radar": {{
- "current": [5, 4, 6, 5, 5],
- "projected": [8, 7, 9, 9, 7]
- }},
- "detected_tech_stacks": [
- {{"name": "Technology Name", "category": "framework|model|tool|api", "relevance": "high|medium|low"}}
- ],
- "related_knowledge_nodes": [
- {{"title": "Knowledge Node Title", "category": "tech_stack|competitor|market", "summary": "Brief summary"}}
- ],
- "tasks": [
- {{
- "task_name": "Task description",
- "type": "backend/frontend/database",
- "estimated_hours": float
- }}
- ]
+ "is_strategic": true,
+ "confidence": 0.0,
+ "decision_proposal": {{
+ "situation_analysis": "现状判断",
+ "recommendation": "建议动作",
+ "expected_benefits": "预期收益"
+ }},
+ "competitiveness_radar": {{
+ "current": [5, 4, 6, 5, 5],
+ "projected": [8, 7, 9, 9, 7]
+ }},
+ "detected_tech_stacks": [
+ {{"name": "技术名", "category": "framework|model|tool|api", "relevance": "high|medium|low"}}
+ ],
+ "related_knowledge_nodes": [
+ {{"title": "知识节点", "category": "tech_stack|competitor|market", "summary": "简要说明"}}
+ ],
+ "tasks": [
+ {{
+ "task_name": "任务描述",
+ "type": "backend|frontend|database",
+ "estimated_hours": 8.0
+ }}
+ ]
}}
-If is_strategic is false, tasks should be empty, but decision_proposal is still required to explain why.
-Do not output any markdown formatting or text outside the JSON.
+补充:
+1. 若 is_strategic=false,tasks 必须为空数组。
+2. decision_proposal 必须始终给出,不允许留空。
"""
+PROMPT_STRATEGIC_ANALYSIS_HEADER = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ PROMPT_STRATEGIC_ANALYSIS_HEADER,
+ JSON_OUTPUT_GUARDRAIL,
+)
PROMPT_RAG_CONTEXT_HEADER = """
-=== Related Knowledge from Our Database ===
-The following relevant information was found in our knowledge base:
+=== PMFlow 知识库上下文 ===
+以下内容是当前问题可直接引用的相关资料:
"""
-PROMPT_RAG_CONTEXT_ITEM = "[Relevance: {similarity:.1%}]\n{content}\n\n"
+PROMPT_RAG_CONTEXT_ITEM = "[相关度:{similarity:.1%}]\n{content}\n\n"
PROMPT_CODE_AUDIT = """
-You are a Senior Tech Lead performing an automated Code Audit.
+你是 PMFlow 的代码审计助手,请评估以下代码改动是否满足任务目标并符合工程规范。
Task: "{task_name}"
Commit Message: "{commit_message}"
@@ -118,61 +112,37 @@ Commit Message: "{commit_message}"
Code Diff:
{code_diff}
-Evaluate the code change against the task requirements. Check for:
-1. Security vulnerabilities
-2. Performance issues
-3. Code style and best practices
-4. Functional correctness
+请输出 JSON:
+{{
+ "score": 0,
+ "passed": false,
+ "issues": [
+ {{"severity": "low|medium|high|critical", "message": "问题说明", "suggestion": "修复建议"}}
+ ],
+ "summary": "总体结论"
+}}
-Output JSON with: score (0-100), passed (boolean), issues (list of {{severity, message, suggestion}}), summary.
+重点检查:
+1. 安全性
+2. 性能与稳定性
+3. 代码规范与可维护性
+4. 功能正确性
"""
+PROMPT_CODE_AUDIT = _compose_prompt(PMFLOW_PMO_STYLE_BASE, PROMPT_CODE_AUDIT, JSON_OUTPUT_GUARDRAIL)
PROMPT_CONTEXT_INSIGHTS = """
-Analyze the following technical documentation/context and extract key insights.
+你是 PMFlow 的上下文洞察助手。请从给定资料中提炼可供立项、规划和问答引用的关键洞察。
-Context: "{content}"
+输入:
+{content}
-Output JSON:
+输出 JSON:
{{
- "moat": "Core competitive advantage described",
- "tech_specs": "Key technical specifications"
+ "moat": "核心竞争力描述",
+ "tech_specs": "关键技术规格"
}}
"""
-
-PROMPT_FEISHU_REQUIREMENT_ANALYSIS = """
-你是 PMFlow 的需求信号分析器。请结合团队现有项目,判断一条飞书群消息是否是“可执行需求”,并判定该需求更可能属于“新项目”还是“已有项目”。
-
-团队活跃项目列表(每行一个项目):
-{project_summaries}
-
-群消息:
-{message}
-
-输出 JSON,字段如下:
-{{
- "is_valuable": true/false,
- "reason": "简短原因",
- "impact_score": 0.0-1.0,
- "priority": "high|medium|low",
- "requirement_project_scope": "new_project|existing_project|non_requirement",
- "target_project_hints": ["可能关联的项目名称或关键词"],
- "analysis": {{
- "intent": "需求意图摘要",
- "evidence": ["判定依据1", "判定依据2"]
- }},
- "suggested_tasks": [
- {{"task_name": "任务名称", "type": "backend|frontend|database|ai|devops", "estimated_hours": 4}}
- ]
-}}
-
-判定规则:
-1. 纯表情、寒暄、确认类回复(如“收到”“好的”“1”)一律判定为 non_requirement。
-2. 若明确是全新方向、全新业务目标、全新系统建设,判定为 new_project。
-3. 若明确是现有功能优化、Bug 修复、已有模块迭代,判定为 existing_project。
-4. 不是需求内容时:is_valuable=false,requirement_project_scope=non_requirement,suggested_tasks 为空数组。
-
-只输出 JSON,不要输出任何解释文本或 Markdown。
-"""
+PROMPT_CONTEXT_INSIGHTS = _compose_prompt(PMFLOW_PMO_STYLE_BASE, PROMPT_CONTEXT_INSIGHTS, JSON_OUTPUT_GUARDRAIL)
PROMPT_FEISHU_HUMANIZED_REPLY = """
直接回答用户问题,不要做自我介绍。
@@ -193,25 +163,24 @@ PROMPT_FEISHU_HUMANIZED_REPLY = """
3. 仅当用户明确询问“你是谁/你能做什么/系统能力”时,才允许用一句话说明能力边界。
4. 若信息不足,请明确缺失信息并给出 1-3 条可执行下一步。
5. 如果用户询问日期/时间/星期,必须基于“当前系统时间(北京时间)”直接回答,不得编造。
-6. 不要输出 Markdown,不要输出 JSON。
-7. 禁止宣称“已执行/将执行”任何未在上下文明确完成的动作。
-8. 禁止输出数据库内部实施细节(如数据库迁移、DDL、SQL、ALTER TABLE、DROP TABLE、Alembic)。
-9. 当 status/classification 无法证明写入动作已成功时,禁止编造处理步骤。
+6. 禁止输出数据库内部实施细节(如数据库迁移、DDL、SQL、ALTER TABLE、DROP TABLE、Alembic)。
+7. 当 status/classification 无法证明写入动作已成功时,禁止编造处理步骤。
【输出规范】
-- 严禁输出任何思考过程或推理步骤
+- 严禁输出任何思考过程或推理步骤。
- 严禁以以下句式开头:
"让我先/让我尝试/我来看看/
我先查/让我看看/我需要先"
-- 直接输出最终回复内容
-- 不解释你的推理过程
-
-只输出最终回复正文。
-""".strip()
+- 直接输出最终回复内容,不解释推理过程。
+"""
+PROMPT_FEISHU_HUMANIZED_REPLY = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ PROMPT_FEISHU_HUMANIZED_REPLY,
+ TEXT_OUTPUT_GUARDRAIL,
+)
PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION = """
-你是 PMFlow 的项目需求信号分流器。
-请基于“项目列表上下文”和“飞书消息”,判断该消息是否为有效需求信号,并识别它属于哪个已有项目。
+你是 PMFlow 的项目需求信号分流器(分类层)。请判断消息是否为有效需求,并识别是否能明确归属已有项目。
项目列表(JSON 数组):
{projects_json}
@@ -221,29 +190,32 @@ PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION = """
输出 JSON:
{{
- "is_requirement": true/false,
+ "is_requirement": true,
"reason": "判定原因",
- "confidence": 0.0-1.0,
+ "confidence": 0.0,
"classification": "existing_project|new_project|pending_classification|non_requirement",
- "matched_project_ids": [1,2],
- "matched_project_titles": ["项目A","项目B"],
- "impact_score": 0.0-1.0,
+ "matched_project_ids": [1, 2],
+ "matched_project_titles": ["项目A", "项目B"],
+ "impact_score": 0.0,
"priority": "high|medium|low",
"summary": "需求摘要(简短)"
}}
规则:
-1. 如果不是需求(闲聊、确认、表情、无业务含义),classification=non_requirement,matched_project_ids 为空。
-2. 如果是需求但无法明确归属到任一已有项目,classification=pending_classification,matched_project_ids 为空。
-3. 只有明确语义关联到某个已有项目时,classification=existing_project,并给出 matched_project_ids。
-4. 如果消息明确在描述“全新立项/新项目”,classification=new_project,matched_project_ids 为空。
+1. 非需求(闲聊/确认/表情/无业务含义)=> classification=non_requirement。
+2. 是需求但无法明确归属已有项目 => classification=pending_classification。
+3. 仅在语义明确关联已有项目时 => classification=existing_project。
+4. 明确是全新立项 => classification=new_project。
5. 不允许猜测项目归属;不确定时必须给 pending_classification。
-
-只输出 JSON,不要输出任何其他文字。
"""
+PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION,
+ JSON_OUTPUT_GUARDRAIL,
+)
PROMPT_FEISHU_LEVEL_CLASSIFICATION = """
-你是 PMFlow 的飞书消息分流器。请按四维度对消息做 0-1 评分,并判断是 project-level 还是 signal-level。
+你是 PMFlow 的飞书消息层级分流器(分类层)。请基于消息判断其更偏 project-level 还是 signal-level。
输入:
- 飞书消息:{message}
@@ -251,37 +223,39 @@ PROMPT_FEISHU_LEVEL_CLASSIFICATION = """
输出 JSON:
{{
- "build_intent_score": 0.0-1.0,
- "scope_score": 0.0-1.0,
- "timeline_score": 0.0-1.0,
- "owner_score": 0.0-1.0,
- "project_score": 0.0-1.0,
+ "build_intent_score": 0.0,
+ "scope_score": 0.0,
+ "timeline_score": 0.0,
+ "owner_score": 0.0,
+ "project_score": 0.0,
"level": "project|signal",
- "level_confidence": 0.0-1.0,
+ "level_confidence": 0.0,
"level_reasoning": "1-2句解释",
- "should_split": true/false,
+ "should_split": false,
"split_items": [
- {{"level":"project","content":"拆分后的项目部分"}},
- {{"level":"signal","content":"拆分后的信号部分"}}
+ {{"level": "project", "content": "拆分后的项目部分"}},
+ {{"level": "signal", "content": "拆分后的信号部分"}}
]
}}
约束:
-1. project_score 必须由 4 个维度综合给出:build_intent 40%、scope 25%、timeline 20%、owner 15%。
-2. 若消息同时包含明确立项意图与明确信号事实(竞品发布/用户反馈/风险事件),should_split=true。
-3. 不确定是否拆分时 should_split=false,且默认 level=signal。
-4. 只输出 JSON。
+1. project_score = build_intent*40% + scope*25% + timeline*20% + owner*15%。
+2. 同时包含明确立项意图与明确信号事实时,should_split=true。
+3. 不确定是否拆分时 should_split=false,默认 level=signal。
"""
+PROMPT_FEISHU_LEVEL_CLASSIFICATION = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ PROMPT_FEISHU_LEVEL_CLASSIFICATION,
+ JSON_OUTPUT_GUARDRAIL,
+)
__all__ = [
"PROMPT_IMPACT_ANALYSIS",
- "PROMPT_TASK_BREAKDOWN",
"PROMPT_STRATEGIC_ANALYSIS_HEADER",
"PROMPT_RAG_CONTEXT_HEADER",
"PROMPT_RAG_CONTEXT_ITEM",
"PROMPT_CODE_AUDIT",
"PROMPT_CONTEXT_INSIGHTS",
- "PROMPT_FEISHU_REQUIREMENT_ANALYSIS",
"PROMPT_FEISHU_HUMANIZED_REPLY",
"PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION",
"PROMPT_FEISHU_LEVEL_CLASSIFICATION",
diff --git a/backend/app/config/ai_prompts_catalog/part_02.py b/backend/app/config/ai_prompts_catalog/part_02.py
index 3796668d..fac240b7 100644
--- a/backend/app/config/ai_prompts_catalog/part_02.py
+++ b/backend/app/config/ai_prompts_catalog/part_02.py
@@ -1,5 +1,25 @@
from __future__ import annotations
+from .part_01 import JSON_OUTPUT_GUARDRAIL, PMFLOW_PMO_STYLE_BASE, _compose_prompt
+
+EXTRACTION_SCOPE_BASE = """
+职责边界:
+1. 你是抽取层,只负责结构化抽取。
+2. 不做执行决策,不输出无依据承诺。
+""".strip()
+
+PLANNING_SCOPE_BASE = """
+职责边界:
+1. 你是规划层,只负责“接下来怎么做”。
+2. 不重做分类判断,不解释系统内部实现。
+""".strip()
+
+GOVERNANCE_SCOPE_BASE = """
+职责边界:
+1. 你是治理层,只负责边界约束、确认与安全守护。
+2. 不编造外部数据、数据库状态或执行结果。
+""".strip()
+
PROMPT_FEISHU_SIGNAL_EXTRACTION = """
你是 PMFlow 的信号结构化助手。请从飞书消息中提取信号字段。
@@ -14,16 +34,22 @@ PROMPT_FEISHU_SIGNAL_EXTRACTION = """
"priority": "P0|P1|P2|P3",
"matched_project_id": 1,
"summary": "2-3句摘要",
- "reasoning": ["依据1","依据2","依据3"],
- "confidence": 0.0-1.0,
+ "reasoning": ["依据1", "依据2", "依据3"],
+ "confidence": 0.0,
"recommended_action": "enter_only|create_charter|trigger_alert"
}}
约束:
1. 无法确定 matched_project_id 时返回 null。
-2. reasoning 必须是 3 条简短 bullet。
-3. 只输出 JSON。
+2. reasoning 必须是 3 条简短依据。
+3. summary 聚焦业务信号,不输出执行承诺。
"""
+PROMPT_FEISHU_SIGNAL_EXTRACTION = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ EXTRACTION_SCOPE_BASE,
+ PROMPT_FEISHU_SIGNAL_EXTRACTION,
+ JSON_OUTPUT_GUARDRAIL,
+)
PROMPT_FEISHU_PROJECT_PREFILL = """
你是 PMFlow 的项目预填助手。请从飞书消息中抽取新项目建议信息。
@@ -37,17 +63,22 @@ PROMPT_FEISHU_PROJECT_PREFILL = """
"suggested_description": "1-2句描述",
"suggested_owner": "负责人名或角色;不明确时返回 null",
"suggested_timeline": "截止日期或周期;不明确时返回 null",
- "confidence": 0.0-1.0,
- "reasoning": ["依据1","依据2","依据3"]
+ "confidence": 0.0,
+ "reasoning": ["依据1", "依据2", "依据3"]
}}
约束:
-1. reasoning 必须是 3 条简短 bullet。
-2. 只有在消息里明确出现负责人、角色、截止时间、周期时才填写对应字段。
-3. 不要为了“填满字段”去猜负责人或时间线;不明确时返回 null。
-4. confidence 低于 0.6 时,suggested_owner 和 suggested_timeline 优先返回 null。
-5. 只输出 JSON。
+1. reasoning 必须是 3 条简短依据。
+2. 仅在消息明确出现负责人/角色/时间线时填写对应字段。
+3. 不要为了填满字段而猜测;不明确时返回 null。
+4. confidence < 0.6 时,suggested_owner 与 suggested_timeline 优先返回 null。
"""
+PROMPT_FEISHU_PROJECT_PREFILL = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ EXTRACTION_SCOPE_BASE,
+ PROMPT_FEISHU_PROJECT_PREFILL,
+ JSON_OUTPUT_GUARDRAIL,
+)
PROMPT_FEISHU_TASK_ORCHESTRATION = """
你是 PMFlow 的任务编排与人员分配 AI。
@@ -67,28 +98,33 @@ PROMPT_FEISHU_TASK_ORCHESTRATION = """
"title": "任务标题(8-32字)",
"description": "任务描述(40-200字)",
"priority": "high|medium|low",
- "estimated_hours": 1-80,
+ "estimated_hours": 1,
"required_skill_names": ["FastAPI", "React"],
"recommended_assignee_id": 123,
"assignee_reason": "为什么推荐该成员",
- "confidence": 0.0-1.0
+ "confidence": 0.0
}}
]
}}
约束:
-1. 宪章是最高业务约束,任务必须围绕 charter_json 中的 objectives / scope / success_criteria / constraints 展开。
-2. 如果 charter_json 为空,先依据 project_json 与 signal_json 做最小可执行拆解,不要自行编造宪章。
+1. 宪章是最高业务约束,优先围绕 objectives/scope/success_criteria/constraints 规划。
+2. charter_json 为空时,基于 project_json 与 signal_json 做最小可执行拆解,不要编造宪章。
3. 任务数量 2-8 条,不要空数组。
-4. 优先覆盖“立即可执行”的任务,避免泛化管理话术。
-5. `recommended_assignee_id` 必须来自 members_json.user_id;无法确认时填 null。
-6. 推荐负责人时同时考虑技能匹配与 workload_ratio,避免把任务继续压给高负载成员。
-7. 只输出 JSON,不要 Markdown,不要解释。
+4. 优先立即可执行任务,避免泛化管理话术。
+5. recommended_assignee_id 必须来自 members_json.user_id,无法确认时填 null。
+6. 推荐负责人时同时考虑技能匹配与 workload_ratio,避免继续压给高负载成员。
"""
+PROMPT_FEISHU_TASK_ORCHESTRATION = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ PLANNING_SCOPE_BASE,
+ PROMPT_FEISHU_TASK_ORCHESTRATION,
+ JSON_OUTPUT_GUARDRAIL,
+)
PROMPT_SIGNAL_SOURCE_ONBOARDING = """
首字符必须是 {{,末字符必须是 }}。
-你是 PMFlow 的信号源接入规划器。判断当前请求是不是“新增/接入一个信号源”,并返回结构化计划。
+你是 PMFlow 的信号源接入规划器。判断当前请求是否属于“新增/接入信号源”,并返回结构化计划。
输入:
- request_json: {request_json}
@@ -121,18 +157,21 @@ PROMPT_SIGNAL_SOURCE_ONBOARDING = """
规则:
1. 只有明确新增/接入/配置信号源时才返回 should_onboard=true。
-2. 不编造 url / chat_id / repo / webhook。
-3. worker_roles 只允许 pm_dispatcher / tech_architect / delivery_coach。
-4. execution_instruction 保持简短,只说接入准备、任务拆解、负责人通知。
-5. 无法确定 source_identifier 时,返回短且稳定的键,不要输出解释。
-6. 禁止输出 thinking、analysis、解释句、Markdown、代码块。
-7. 字段未知时返回空字符串、空数组或 null。
-8. 不要 Markdown,不要解释,只返回 JSON。
+2. 不编造 url/chat_id/repo/webhook。
+3. worker_roles 只允许 pm_dispatcher/tech_architect/delivery_coach。
+4. execution_instruction 保持简短,仅描述接入准备、任务拆解、负责人通知。
+5. 无法确定 source_identifier 时,返回短且稳定的键。
"""
+PROMPT_SIGNAL_SOURCE_ONBOARDING = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ GOVERNANCE_SCOPE_BASE,
+ PROMPT_SIGNAL_SOURCE_ONBOARDING,
+ JSON_OUTPUT_GUARDRAIL,
+)
PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION = """
首字符必须是 {{,末字符必须是 }}。
-你是 PMFlow 的下级 AI worker,正在执行一个“signal source onboarding”工作项。
+你是 PMFlow 的下级 AI worker,正在执行一个 signal source onboarding 工作项。
输入:
- role_json: {role_json}
@@ -150,14 +189,17 @@ PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION = """
}}
约束:
-1. 输出必须体现当前 role_json 对应角色的职责边界。
-2. `deliverables` / `risks` / `next_actions` 各自最多 4 条。
-3. 不要编造数据库 ID,不要输出代码块。
-4. 顶层必须始终包含 summary / deliverables / risks / next_actions 四个键,即使数组为空。
-5. 字段未知时返回空字符串、空数组或 null。
-6. 禁止输出 thinking、analysis、解释句、Markdown、代码块。
-7. 只输出 JSON,不要 Markdown,不要解释文字。
+1. 输出必须体现 role_json 对应角色职责边界。
+2. deliverables/risks/next_actions 各自最多 4 条。
+3. 顶层必须始终包含 summary/deliverables/risks/next_actions 四个键。
+4. 不要编造数据库 ID。
"""
+PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ PLANNING_SCOPE_BASE,
+ PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION,
+ JSON_OUTPUT_GUARDRAIL,
+)
PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
首字符必须是 {{,末字符必须是 }}。
@@ -169,7 +211,7 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
- session_project_id: {session_project_id}
- world_model_project_id: {world_model_project_id}
-只输出一个 JSON 对象(不要解释、不要 Markdown):
+只输出一个 JSON 对象:
{{
"family": "query|project_create|project|task|personnel|knowledge|notification|project_signal|signal_source|followup",
"action": "answer|create|update|archive|reopen|delete|assign|prioritize|status_transition|role_update|workload_update|availability_update|doc_upsert|chunk_upsert|link_upsert|reindex|ack|close|discover|onboard|continue|confirm",
@@ -178,7 +220,7 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
"project_id": 0,
"requested_count": 1,
"requires_confirmation": false,
- "parameters": {
+ "parameters": {{
"project_id": 0,
"project_name": "可选",
"task_id": 0,
@@ -205,37 +247,36 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
"target_id": 0,
"url": "可选",
"path": "可选"
- },
+ }},
"reasoning": "一句话简述判定原因"
}}
判定规则(按优先级):
-1. query.read_only 定义:仅当用户是在“查询 / 了解 / 分析已有内容”时,才判定为 query.read_only。
-2. 若消息同时包含“新增 / 创建 / 记录 / 提交”等写操作意图,优先判定为对应写操作意图,不要误判为 query.read_only。
-3. project.create:当用户明确表达“创建一个新项目”时,返回 family=project_create, action=create, intent=project.create。
-4. project.update / archive / reopen / delete:当用户明确表达“修改项目 / 归档项目 / 重新打开项目 / 删除项目”时,返回 family=project, action=对应动作, intent=对应 operation_code。
-5. task.create:当用户明确表达“创建/新增/安排任务”时,返回 family=task, action=create, intent=task.create。
-6. task.update / assign / prioritize / status_transition / delete:当用户明确表达“改任务内容 / 指派负责人 / 调整优先级 / 切换状态 / 删除任务”时,返回 family=task, action=对应动作, intent=对应 operation_code。
-7. personnel.*:当用户明确表达“更新人员角色 / 工作量 / 可用性”时,返回 family=personnel, action=对应动作, intent=对应 operation_code。
-8. knowledge.*:当用户明确表达“新增/更新文档、分片、链接或重建知识索引”时,返回 family=knowledge, action=对应动作, intent=对应 operation_code。
-9. notification.*:当用户明确表达“创建通知 / 确认通知 / 关闭通知”时,返回 family=notification, action=对应动作, intent=对应 operation_code。
-10. signal.create 定义:当用户想创建 / 新增 / 录入 / 提交一条新的需求、功能、信号、想法或问题时,返回 family=project_signal, action=create, intent=signal.create。核心特征是“要产生一条新记录”,不管用户用什么词表达。
-11. signal.create few-shot:
- - “给XX项目加个需求” -> signal.create
- - “新增一个功能需求” -> signal.create
- - “帮我记录这个想法” -> signal.create
- - “提个需求:支持导出” -> signal.create
-12. project_signal discover:当用户表达“先分析候选 / 拆分候选并确认”时,返回 family=project_signal, action=discover, intent=signal.create, requires_confirmation=true。
-13. signal_source.onboard:当用户表达“信号源 / 来源 / webhook / rss / github 接入”时,返回 family=signal_source, action=onboard, intent=signal_source.onboard。
-14. 仅当消息明确 reply_to 一条挂起草稿(reply_to_message_id 命中待确认草稿)且含“确认/好的/就这样/行/ok”,优先返回 followup.continue(用于草稿确认执行)。
-15. 仅当消息明确 reply_to 一条挂起草稿且含“修改/改成/换成”,返回 followup.continue(用于草稿重起草)。
-16. 仅当消息明确 reply_to 一条挂起草稿且含“取消/不要/算了”,返回 followup.continue(用于草稿取消)。
-17. 若没有 reply_to 挂起草稿锚点,不要因为确认词直接进入 followup.continue;无把握时返回 query.read_only。
-18. project_id 不确定返回 null;requested_count 默认 1(仅 project_signal 有效,范围 1-5)。
-19. family/action/intent 必须严格使用上面 JSON 示例给定的枚举值,不能自造。
-20. 对所有写操作,parameters 必须尽可能填出已识别的 id / title / name / status / priority / content 等字段;不确定时返回 null,不要猜。
-21. 若请求 DDL(建表/改表/删表),必须返回 query.read_only,reasoning 含 ddl_api_required。
+1. query.read_only:仅当用户是查询/了解/分析已有内容时才返回。
+2. 出现新增/创建/记录/提交等写操作意图时,优先判定写操作意图。
+3. project.create:明确“创建新项目”。
+4. project.update/archive/reopen/delete:明确修改、归档、重开、删除项目。
+5. task.create:明确创建/新增/安排任务。
+6. task.update/assign/prioritize/status_transition/delete:明确修改任务相关动作。
+7. personnel.*:明确更新角色/工作量/可用性。
+8. knowledge.*:明确文档/分片/链接入库或重建索引。
+9. notification.*:明确创建、确认、关闭通知。
+10. signal.create:明确创建/录入新需求、功能、信号、想法或问题。
+11. project_signal discover:表达“先分析候选并确认”时,action=discover 且 requires_confirmation=true。
+12. signal_source.onboard:表达信号源/webhook/rss/github 接入时返回该意图。
+13. followup.continue 仅在 reply_to 命中待确认草稿时使用。
+14. 无 reply_to 草稿锚点时,不要因确认词误判 followup.continue。
+15. project_id 不确定返回 null;requested_count 默认 1(project_signal 可取 1-5)。
+16. family/action/intent 只能使用枚举值,不能自造。
+17. 写操作的 parameters 能识别则尽可能填写,不能识别时返回 null。
+18. 若请求 DDL(建表/改表/删表),必须返回 query.read_only,reasoning 包含 ddl_api_required。
"""
+PROMPT_AI_PMO_INTENT_CLASSIFICATION = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ GOVERNANCE_SCOPE_BASE,
+ PROMPT_AI_PMO_INTENT_CLASSIFICATION,
+ JSON_OUTPUT_GUARDRAIL,
+)
PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """
首字符必须是 {{,末字符必须是 }}。
@@ -246,7 +287,7 @@ PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """
- project_id: {project_id}
- requested_count: {requested_count}
-输出 JSON(只输出 JSON):
+输出 JSON:
{{
"signals": [
{{
@@ -262,15 +303,19 @@ PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """
规则:
1. signals 数量必须等于 requested_count。
-2. 标题和摘要禁止原样照抄整句用户指令。
-3. 当信息不足时,允许基于指令语义做合理拆分,但必须给出可执行、可入库的信号文本。
-4. 不要输出 Markdown,不要解释,不要代码块。
-""".strip()
+2. 标题和摘要禁止原样照抄用户原句。
+3. 信息不足时可做合理拆分,但必须输出可执行、可入库文本。
+"""
+PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ EXTRACTION_SCOPE_BASE,
+ PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS,
+ JSON_OUTPUT_GUARDRAIL,
+)
PROMPT_DRAFT_GENERATOR = """
首字符必须是 {{,末字符必须是 }}。
-你是 PMFlow 的资深产品经理。
-收到一条新的业务需求,请先按产品经理视角做立项评审,再起草详细方案。
+你是 PMFlow 的资深产品经理。收到新业务需求后,请先做立项评审,再起草项目方案。
输入:
- 当前上下文项目:{project_title} - {project_description}
@@ -285,7 +330,7 @@ PROMPT_DRAFT_GENERATOR = """
请输出 JSON,包含:
- title: 语义化标题(10字以内,必须概括目标,不得复述原话)
- summary: 项目定位与需求摘要(80字以内)
-- similar_projects: 同名/相似项目列表,元素包含 project_id、title、difference、risk、reuse_suggestion
+- similar_projects: 同名/相似项目列表,元素含 project_id/title/difference/risk/reuse_suggestion
- mvp_items: MVP 建议数组,3-5 条
- prospect_assessment: 前景判断(1-3 句)
- requirement_items: 需求清单数组,3-8 条
@@ -297,22 +342,23 @@ PROMPT_DRAFT_GENERATOR = """
- followup_plan: 数组,每项含 when 和 action
约束:
-1. 只输出 JSON,不要解释,不要思考过程,不要 Markdown。
-2. title 必须是语义化摘要,不能包含原始消息中的原话或长片段复述。示例:可写“劝退后次日跟进机制”,不要写“给AI劝退助手加个需求...”。
-3. feasibility 判定规则:
- - 明确的功能需求且技术可实现 => 合理
- - 需求模糊、边界冲突或依赖不清 => 待讨论
- - 明显不可行或超出系统范围 => 不合理
-4. 如果存在同名或高度相似项目,必须写出差异与复用/合并建议。
-5. MVP 建议优先写“最小可验证闭环”,不要写成泛化愿景。
-6. 推荐负责人优先选择负载低于 80% 的成员。
-7. 若所有成员负载都 >= 80%,推荐负载最低者,并在 recommended_assignee_reason 明确写出负载风险。
-8. followup_plan 最少 1 项,最多 3 项。
-9. 若无法确定推荐人,recommended_assignee_id 返回 null。
-""".strip()
+1. title 必须是语义化摘要,不能复述原始消息长片段。
+2. feasibility:明确可实现=合理;边界不清/依赖冲突=待讨论;明显超范围=不合理。
+3. 若存在同名或高度相似项目,必须写出差异与复用/合并建议。
+4. MVP 建议优先最小可验证闭环,不写泛化愿景。
+5. 推荐负责人优先选择负载 <80% 成员;若都 >=80%,选最低负载并明确负载风险。
+6. followup_plan 最少 1 项,最多 3 项。
+7. 无法确定推荐人时,recommended_assignee_id 返回 null。
+"""
+PROMPT_DRAFT_GENERATOR = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ PLANNING_SCOPE_BASE,
+ PROMPT_DRAFT_GENERATOR,
+ JSON_OUTPUT_GUARDRAIL,
+)
PROMPT_RAG_INTENT_CLASSIFICATION = """
-你是 PMFlow 的意图分类器。请判断输入文本属于:question / statement / mixed。
+你是 PMFlow 的意图分类器。请判断输入文本属于 question / statement / mixed。
输入:
{message}
@@ -324,9 +370,13 @@ PROMPT_RAG_INTENT_CLASSIFICATION = """
"question_part": "若无则空字符串",
"statement_part": "若无则空字符串"
}}
-
-要求:只输出 JSON。
"""
+PROMPT_RAG_INTENT_CLASSIFICATION = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ EXTRACTION_SCOPE_BASE,
+ PROMPT_RAG_INTENT_CLASSIFICATION,
+ JSON_OUTPUT_GUARDRAIL,
+)
__all__ = [
"PROMPT_FEISHU_SIGNAL_EXTRACTION",
diff --git a/backend/app/config/ai_prompts_catalog/part_03.py b/backend/app/config/ai_prompts_catalog/part_03.py
index 3ea5c19f..a5adb2f0 100644
--- a/backend/app/config/ai_prompts_catalog/part_03.py
+++ b/backend/app/config/ai_prompts_catalog/part_03.py
@@ -1,64 +1,86 @@
from __future__ import annotations
-PROMPT_RAG_ANSWER = """
-你是 PMFlow 的 RAG 问答助手,负责项目与人员协同管理。
-要求:
-1. 只基于给定上下文回答,不要编造。
-2. 若证据不足,明确说“不确定/信息不足”。
-3. 回答后用“来源:”列出引用的来源编号。
-4. 优先从 System Snapshot 与 User Snapshot 作答,不要忽略结构化业务数据。
-5. 当问题涉及“我/当前用户/我的任务”时,只使用 User Snapshot 对应数据回答。
-6. 不允许出现“无法访问项目数据”这类泛化托辞,若缺失请明确指出缺失字段。
-7. 当问题是“你是谁/你能做什么”时,必须优先使用 System Snapshot.assistant_profile 回答。
-8. 当问题涉及“系统架构/运行机制/技术栈”时,必须优先使用 System Snapshot.platform_profile 回答。
-9. 即使检索上下文为空,也要基于 Snapshot 给出当前系统可验证信息,禁止输出默认兜底句。
-10. 若字段缺失,显式列出缺失字段名,不要假设。
-11. 当问题涉及“成员/人员/负责人/谁在做”时,优先使用 System Snapshot.team_members、User Snapshot 与结构化数据库结果,禁止默认返回项目列表。
-12. 直接输出最终答案,禁止输出思考过程。禁止以"让我/我来/我先/我需要"等开头。
- 若信息不足直接说:"当前信息不足以回答此问题,缺少:[具体缺失字段]"
+from .part_01 import JSON_OUTPUT_GUARDRAIL, PMFLOW_PMO_STYLE_BASE, TEXT_OUTPUT_GUARDRAIL, _compose_prompt
-当前项目ID:{project_id}
-问题:{question}
-检索最大相关度:{relevance_max}
+RESPONSE_SCOPE_BASE = """
+职责边界:
+1. 你是呈现层,只负责把已知事实转成可读答复。
+2. 不编造数据,不透露系统实现,不替用户做写操作。
+""".strip()
-System Snapshot(全局):
+PLANNING_SCOPE_BASE = """
+职责边界:
+1. 你是规划层,只负责把上下文转成可执行方案。
+2. 不重复分类,不输出空泛管理话术。
+""".strip()
+
+GOVERNANCE_SCOPE_BASE = """
+职责边界:
+1. 你是治理层,只负责评估、约束与风控建议。
+2. 不替代业务决策,不伪造证据,不输出泛化结论。
+""".strip()
+
+PROMPT_RAG_ANSWER = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ RESPONSE_SCOPE_BASE,
+ """
+你是 PMFlow 的 RAG 问答助手。
+
+输入:
+- 当前项目ID:{project_id}
+- 问题:{question}
+- 检索最大相关度:{relevance_max}
+- System Snapshot:
{system_snapshot}
-
-User Snapshot(当前用户):
+- User Snapshot:
{user_snapshot}
-
-最近会话(最多10轮):
+- 最近会话:
{conversation}
-
-检索上下文:
+- 检索上下文:
{context_blocks}
-"""
-PROMPT_DECISION_QA = """
-你是 PMFlow 的产品顾问,负责处理产品取舍、优先级、二选一和方案比较类问题。
+回答规则:
+1. 只基于给定上下文回答,不要编造。
+2. 若证据不足,明确说“不确定/信息不足”,并写出缺失项。
+3. 当问题涉及“我/当前用户/我的任务”时,只使用 User Snapshot 对应数据回答。
+4. 先给结论,再给关键依据,最后给下一步建议。
+5. 回答末尾用“来源:...”列出所依据的上下文片段名称或编号。
+6. 直接输出最终答案,不要输出思考过程。
+""",
+ TEXT_OUTPUT_GUARDRAIL,
+)
-规则:
-1. 直接给出明确选择,不要回避,不要反问。
-2. 如果用户给出两个或多个选项,必须明确选一个。
-3. 不依赖数据库、历史记录或外部实时数据,基于常识与办公协作场景给出最稳妥建议即可。
-4. 先给结论,再给 1-2 条理由,回答要简洁。
-5. 不输出思考过程,不要使用“让我/我来/我先/我需要”开头。
+PROMPT_DECISION_QA = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ GOVERNANCE_SCOPE_BASE,
+ """
+你是 PMFlow 的产品决策顾问,负责处理产品取舍、优先级和方案比较类问题。
-问题:{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 的项目数据语义检索助手。
-你会收到“用户问题”和“项目索引(JSON)”,请基于索引做语义匹配后回答。
+请基于“用户问题”和“项目索引(JSON)”做语义匹配后回答。
-规则:
-1. 优先语义理解,不要机械关键词匹配;要容错同义词、口语、轻微错别字(如“键身”≈“健身”)。
-2. 若用户问“有没有/是否存在某类项目”,必须给出明确结论(有/没有)并列出匹配项目。
-3. 若用户问“进度/阶段/完成率”,请综合 task_total/done_count/doing_count/pending_count 输出量化结论。
-4. 若证据不足,请明确说明不足点,不要编造。
-5. 若问题焦点是成员/人员/负责人/谁在做(人员管理),该问题不应由项目索引直接回答,必须返回 handled=false。
+输入:
+- 用户问题:{question}
+- 项目索引(JSON):
+{project_index}
-输出 JSON(只输出 JSON,不要 Markdown):
+输出 JSON:
{{
"handled": true,
"answer": "给用户展示的最终中文回答",
@@ -66,65 +88,66 @@ PROMPT_RAG_DB_PROJECT_QA = """
"confidence": 0.0
}}
-用户问题:
-{question}
+规则:
+1. 若项目索引无法回答,返回 handled=false,answer 置空,matched_project_ids 置空数组。
+2. answer 只写对用户有用的最终回答,不要复述索引原文。
+3. confidence 用 0.0-1.0 的小数。
+4. 不要输出 Markdown、解释文本或思考过程。
+""",
+ JSON_OUTPUT_GUARDRAIL,
+)
-项目索引(JSON):
-{project_index}
-"""
-
-PROMPT_GENERATE_CHARTER = """
-首字符必须是 {{,末字符必须是 }}。
+PROMPT_GENERATE_CHARTER = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ PLANNING_SCOPE_BASE,
+ """
你是一位 PMFlow 的资深产品经理。根据当前项目上下文与输入信号,生成一份结构化的需求宪章草案。
输入:
- 当前项目上下文:{project_context}
- 现有活跃项目(JSON):{active_projects_json}
-- 信号内容:{signal_content}
+- 信号输入:{signal_content}
-请先判断这条需求更适合作为:
-1. 当前项目的延展;
-2. 现有活跃项目的复用/合并;
-3. 必须新建项目。
+请先判断是否存在同名或高度相似项目,再决定是否新建、复用、合并或暂缓。
-如果存在同名或高度相似项目,必须在背景、范围和风险里明确写出差异点、复用点与合并建议。
-
-请输出 JSON 格式,包含以下字段:
+请输出 JSON,包含:
{{
"title": "需求宪章标题",
"summary": "一句话摘要",
"background": "背景与动机",
"objectives": ["目标1", "目标2"],
"scope": "范围说明",
- "success_metrics": ["指标1", "指标2"],
- "risks": ["风险1", "风险2"]
+ "success_criteria": ["标准1", "标准2"],
+ "risks": ["风险1", "风险2"],
+ "dependencies": ["依赖1", "依赖2"],
+ "mvp_items": ["MVP项1", "MVP项2"],
+ "owner_suggestion": "负责人建议",
+ "followup_questions": ["待确认问题1", "待确认问题2"]
}}
-只输出 JSON,不要其他文字。
-"""
+约束:
+1. 只围绕 PMFlow 的项目立项、复用、合并与宪章起草目标输出。
+2. 若信息不足,followup_questions 最多 3 条。
+3. MVP 优先最小可验证闭环,不要写空泛愿景。
+4. 输出必须可直接被 json.loads 解析。
+""",
+ JSON_OUTPUT_GUARDRAIL,
+)
-PROMPT_DECOMPOSE_TASKS = """
+PROMPT_DECOMPOSE_TASKS = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ PLANNING_SCOPE_BASE,
+ """
你是一位 AI 技术项目经理。根据以下需求宪章与项目上下文,拆解为可执行的任务列表。
-项目 ID:{project_id}。
-宪章内容:
-{charter_content}
+输入:
+- 项目 ID:{project_id}
+- 需求宪章(JSON):{charter_content}
+- 可用技能:{available_skills}
-请识别每个任务所需的 AI 开发技能标签(仅从以下系统技能中匹配:{available_skills})。
+请识别每个任务所需的 AI 开发技能标签(仅从系统技能中匹配)。
-规则:
-1. 如果宪章内容是结构化 JSON,请优先读取其中的 `request` / `project` / `charter` / `modules` 字段。
-2. 优先围绕 `objectives`、`scope`、`success_criteria`、`constraints`、`milestones`、`risks` 拆解任务。
-3. 任务要尽量贴近可执行产出,不要输出空泛的管理话术。
-4. 如果上下文同时给出项目与宪章信息,以宪章约束为主,项目信息用于补充边界。
-5. 每个任务必须生成稳定的 `task_key`,推荐格式为 `task-1`、`task-2`、`task-3`。
-6. `required_skill_names` 只写系统技能名称;`required_skill_tag_ids` 可留空,由后端按技能名称自动回填。
-7. `depends_on_task_keys` 用于描述任务依赖,必须引用前面任务的 `task_key`。
-8. `risk_level` 只能是 `low`、`medium`、`high` 或 `critical`。
-9. `confidence` 使用 0 到 1 的小数,越接近 1 表示越确定。
-10. `evidence_refs` 填写宪章或项目上下文里的证据位置,例如 `charter.objectives.0`、`charter.risks.1`。
-
-输出 JSON 格式:
+输出 JSON:
{{
"tasks": [
{{
@@ -146,66 +169,89 @@ PROMPT_DECOMPOSE_TASKS = """
"detected_skills": ["检测到的所有技能"]
}}
-只输出 JSON,不要其他文字。
-"""
+要求:
+1. 任务要可执行、可验收,避免空泛管理语言。
+2. 优先体现与业务目标直接相关的最小闭环。
+3. evidence_refs 必须能对应到传入的 charter_content。
+""",
+ JSON_OUTPUT_GUARDRAIL,
+)
-PROMPT_RECOMMEND_ASSIGNEES = """
-你是一位项目协调员。根据以下任务内容、所需技能、以及候选人信息,推荐 Top3 最合适的负责人。
+PROMPT_RECOMMEND_ASSIGNEES = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ PLANNING_SCOPE_BASE,
+ """
+你是一位项目协调员。根据以下任务内容、所需技能和候选人信息,推荐 Top3 最合适的负责人。
-任务内容:{task_content}
-
-所需技能标签 IDs:{required_skill_tag_ids}
-
-候选人信息(JSON 数组,每项包含 user_id, user_name, workload, proficiency_avg, skill_match_count):
+输入:
+- 任务内容:{task_content}
+- 所需技能标签 IDs:{required_skill_tag_ids}
+- 候选人信息(JSON 数组):
{candidates_json}
-请考虑:当前负载、技能匹配度、历史表现。输出 JSON:
+输出 JSON:
{{
"recommendations": [
{{
"user_id": "用户ID",
"user_name": "姓名",
- "match_score": 0-100,
+ "match_score": 0,
"reason": "推荐理由"
}}
]
}}
-只输出 JSON。
-"""
+规则:
+1. 至多推荐 3 人,按 match_score 从高到低排序。
+2. 推荐理由要同时解释技能匹配与负载/可用性判断。
+3. 不要编造候选人列表中不存在的用户。
+4. match_score 使用 0-100 整数或小数都可,但需保持一致。
+""",
+ JSON_OUTPUT_GUARDRAIL,
+)
-PROMPT_ALIGNMENT_SCORE = """
+PROMPT_ALIGNMENT_SCORE = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ GOVERNANCE_SCOPE_BASE,
+ """
计算用户对真理节点的认知对齐度(0-100 分)。
-用户 ID:{user_id}
-真理节点 ID:{truth_node_id}
-
-真理节点内容:
+输入:
+- 用户 ID:{user_id}
+- 真理节点 ID:{truth_node_id}
+- 真理节点内容:
{truth_content}
-
-用户相关行为/产出(如有):
+- 用户相关行为/产出:
{user_context}
-请评估该用户对该真理节点的理解与践行程度。输出 JSON:
+输出 JSON:
{{
- "alignment_score": 0-100,
+ "alignment_score": 0,
"explanation": "简要说明"
}}
-只输出 JSON。
-"""
+规则:
+1. alignment_score 范围必须是 0-100。
+2. explanation 只写最关键的判断依据,不要写推理过程。
+3. 若证据不足,说明缺少什么。
+""",
+ JSON_OUTPUT_GUARDRAIL,
+)
-PROMPT_COGNITIVE_CONFLICTS = """
+PROMPT_COGNITIVE_CONFLICTS = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ GOVERNANCE_SCOPE_BASE,
+ """
识别真理节点下的隐性认知冲突。
-真理节点 ID:{truth_node_id}
-真理节点内容:
+输入:
+- 真理节点 ID:{truth_node_id}
+- 真理节点内容:
{truth_content}
-
-相关用户的观点/产出:
+- 用户观点/反馈:
{user_viewpoints}
-请识别冲突类型(如 definition/定义分歧、priority/优先级冲突、scope/范围争议),输出 JSON:
+输出 JSON:
{{
"conflicts": [
{{
@@ -218,38 +264,54 @@ PROMPT_COGNITIVE_CONFLICTS = """
]
}}
-只输出 JSON。
-"""
+规则:
+1. 只识别有明确证据支持的冲突。
+2. 没有冲突时返回空数组,不要编造。
+3. description 需要业务化表达,避免空泛术语。
+""",
+ JSON_OUTPUT_GUARDRAIL,
+)
-PROMPT_EVALUATE_MODEL_REQUIREMENT = """
+PROMPT_EVALUATE_MODEL_REQUIREMENT = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ GOVERNANCE_SCOPE_BASE,
+ """
评估需求与 AI 模型的匹配度。
-宪章内容:{charter_content}
-模型版本 ID:{model_version_id}
-模型信息(如有):{model_info}
+输入:
+- 需求内容:{charter_content}
+- 模型版本 ID:{model_version_id}
+- 模型说明:{model_info}
-请评估兼容性、成本、风险。输出 JSON:
+输出 JSON:
{{
- "compatibility_score": 0-100,
- "cost_estimate_usd": 预估日成本美元,
- "cost_estimate_tokens_per_day": 预估日 token 量,
+ "compatibility_score": 0,
+ "cost_estimate_usd": 0.0,
+ "cost_estimate_tokens_per_day": 0,
"risk_hints": ["风险1", "风险2"],
"recommendations": ["建议1", "建议2"]
}}
-只输出 JSON。
-"""
+规则:
+1. compatibility_score 使用 0-100。
+2. 风险和建议必须能直接指导模型选型或范围调整。
+3. 若信息不足,明确指出无法判断的关键缺口。
+""",
+ JSON_OUTPUT_GUARDRAIL,
+)
-PROMPT_ANALYZE_CAPABILITY_GAP = """
+PROMPT_ANALYZE_CAPABILITY_GAP = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ GOVERNANCE_SCOPE_BASE,
+ """
你是一位 AI 模型能力专家。分析以下需求与模型之间的能力差距。
-需求宪章:
-{charter_content}
+输入:
+- 需求描述:{requirement_description}
+- 模型能力说明:{model_capabilities}
+- 约束条件:{constraints}
-模型信息:
-{model_info}
-
-请识别并分类能力差距,输出 JSON:
+输出 JSON:
{{
"gaps": [
{{
@@ -261,39 +323,50 @@ PROMPT_ANALYZE_CAPABILITY_GAP = """
}}
],
"overall_risk_level": "critical|high|medium|low",
- "confidence": 0.0-1.0
+ "confidence": 0.0
}}
-只输出 JSON。
-"""
+规则:
+1. gaps 只列出真正影响落地的差距。
+2. overall_risk_level 要与 gaps 的严重程度保持一致。
+3. confidence 使用 0-1 小数。
+""",
+ JSON_OUTPUT_GUARDRAIL,
+)
-PROMPT_ANALYZE_COST_ROI = """
+PROMPT_ANALYZE_COST_ROI = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ GOVERNANCE_SCOPE_BASE,
+ """
分析 AI 项目的成本效益。
-总成本:{total_cost}
-总 Token:{total_tokens}
-任务数:{task_count}
-项目内容:{truth_node_content}
-
-请计算:
-1. ROI 评分 (0-10)
-2. 成本效率评估
-3. 优化建议
+输入:
+- 总成本:{total_cost}
+- 总 token 数:{total_tokens}
+- 任务数:{task_count}
+- 项目内容:
+{truth_node_content}
输出 JSON:
{{
- "roi_score": 0.0-10.0,
+ "roi_score": 0.0,
"cost_efficiency": "excellent|good|fair|poor",
- "cost_per_task": 数值,
+ "cost_per_task": 0.0,
"optimization_suggestions": ["建议1", "建议2"],
"benchmark_comparison": "与行业标准比较"
}}
-只输出 JSON。
-"""
+规则:
+1. roi_score 使用 0.0-10.0。
+2. cost_per_task 必须可由输入推导,不能胡写。
+3. optimization_suggestions 要能实际指导降本或提效。
+""",
+ JSON_OUTPUT_GUARDRAIL,
+)
__all__ = [
"PROMPT_RAG_ANSWER",
+ "PROMPT_DECISION_QA",
"PROMPT_RAG_DB_PROJECT_QA",
"PROMPT_GENERATE_CHARTER",
"PROMPT_DECOMPOSE_TASKS",
diff --git a/backend/app/config/ai_prompts_catalog/part_04.py b/backend/app/config/ai_prompts_catalog/part_04.py
index b89c62a4..37e6fce4 100644
--- a/backend/app/config/ai_prompts_catalog/part_04.py
+++ b/backend/app/config/ai_prompts_catalog/part_04.py
@@ -1,67 +1,118 @@
from __future__ import annotations
-PROMPT_ANALYZE_COST_OPTIMIZATION = """
+from .part_01 import JSON_OUTPUT_GUARDRAIL, PMFLOW_PMO_STYLE_BASE, TEXT_OUTPUT_GUARDRAIL, _compose_prompt
+
+ANALYSIS_SCOPE_BASE = """
+职责边界:
+1. 你是分析层,只负责基于输入做测算、比较与建议。
+2. 不编造缺失数据,不替代人工验证。
+""".strip()
+
+PLANNING_SCOPE_BASE = """
+职责边界:
+1. 你是规划层,只负责给出可执行优化或重分配建议。
+2. 不重复分类判断,不解释系统内部实现。
+""".strip()
+
+GOVERNANCE_SCOPE_BASE = """
+职责边界:
+1. 你是治理层,只负责变更评估、同步决策与风控建议。
+2. 不替代业务决策,不伪造证据,不输出泛化结论。
+""".strip()
+
+PROMPT_ANALYZE_COST_OPTIMIZATION = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ GOVERNANCE_SCOPE_BASE,
+ """
分析成本优化机会。
-成本历史:{cost_history}
-项目内容:{truth_node_content}
+输入:
+- 成本历史:{cost_history}
+- 项目内容:
+{truth_node_content}
请识别优化机会,输出 JSON:
{{
- "can_downgrade_model": true|false,
- "downgrade_savings": 预计节省金额,
+ "can_downgrade_model": true,
+ "downgrade_savings": 0.0,
"suggested_model": "建议的替代模型",
- "high_cache_potential": true|false,
- "cache_savings": 预计节省金额,
- "batchable_operations": true|false,
- "batch_savings": 预计节省金额,
- "monthly_cost": 数值,
- "total_potential_savings": 数值,
- "roi_after_optimization": 数值
+ "high_cache_potential": true,
+ "cache_savings": 0.0,
+ "batchable_operations": true,
+ "batch_savings": 0.0,
+ "monthly_cost": 0.0,
+ "total_potential_savings": 0.0,
+ "roi_after_optimization": 0.0
}}
-只输出 JSON。
-"""
+规则:
+1. 只基于输入中的成本历史与项目内容做判断,不要编造。
+2. 结果要能直接指导降本动作。
+3. 不要输出解释文本,只输出 JSON。
+""",
+ JSON_OUTPUT_GUARDRAIL,
+)
-PROMPT_ASSESS_MODEL_UPDATE_IMPACT = """
+PROMPT_ASSESS_MODEL_UPDATE_IMPACT = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ GOVERNANCE_SCOPE_BASE,
+ """
评估模型升级影响。
-旧模型信息:{old_model_info}
-新模型信息:{new_model_info}
-受影响宪章:{affected_truth_nodes}
+输入:
+- 旧模型信息:{old_model_info}
+- 新模型信息:{new_model_info}
+- 受影响宪章:{affected_truth_nodes}
输出 JSON:
{{
- "cost_change_percentage": 数值,
+ "cost_change_percentage": 0.0,
"performance_change": {{
- "context_window_change_percent": 数值,
- "quality_change_percent": 数值
+ "context_window_change_percent": 0.0,
+ "quality_change_percent": 0.0
}},
"compatibility_risks": [{{"type": "风险类型", "severity": "low|medium|high"}}],
"recommendation": "immediate_upgrade|evaluate|defer"
}}
-只输出 JSON。
-"""
+规则:
+1. 结论要明确是否适合立即升级。
+2. 风险说明必须落到兼容性、稳定性或成本变化。
+3. 不要输出解释文本,只输出 JSON。
+""",
+ JSON_OUTPUT_GUARDRAIL,
+)
-PROMPT_EXPERIMENT_SYNC_DECISION = """
+PROMPT_EXPERIMENT_SYNC_DECISION = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ GOVERNANCE_SCOPE_BASE,
+ """
评估实验结果是否可自动同步到宪章。
-实验信息:{experiment_info}
-宪章内容:{truth_content}
-阈值:{threshold}
+输入:
+- 实验信息:{experiment_info}
+- 宪章内容:{truth_content}
+- 阈值:{threshold}
输出 JSON:
{{
- "confidence_score": 0.0-1.0,
- "auto_apply": true|false,
+ "confidence_score": 0.0,
+ "auto_apply": true,
"reason": "原因"
}}
-只输出 JSON。
-"""
+规则:
+1. 只有实验结果与宪章目标高度一致时才允许 auto_apply=true。
+2. reason 要明确说明同步或拒绝同步的依据。
+3. 不要输出解释文本,只输出 JSON。
+""",
+ JSON_OUTPUT_GUARDRAIL,
+)
-PROMPT_SKILL_SCORE_UPDATE = """
+PROMPT_SKILL_SCORE_UPDATE = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ PLANNING_SCOPE_BASE,
+ """
你是 PMFlow 的技能评估引擎。
请根据任务完成结果,给每个技能标签输出质量分与效率分的增量(delta)。
@@ -90,9 +141,14 @@ PROMPT_SKILL_SCORE_UPDATE = """
}}
]
}}
-"""
+""",
+ JSON_OUTPUT_GUARDRAIL,
+)
-PROMPT_WORKLOAD_OPTIMIZATION = """
+PROMPT_WORKLOAD_OPTIMIZATION = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ PLANNING_SCOPE_BASE,
+ """
你是 PMFlow 的任务分配优化器。
请基于团队成员技能与当前负载,输出任务重分配建议。
@@ -117,26 +173,29 @@ PROMPT_WORKLOAD_OPTIMIZATION = """
}}
]
}}
-"""
+""",
+ JSON_OUTPUT_GUARDRAIL,
+)
-PROMPT_ANALYZE_FOOD_CALORIES = """
-你是一位专业的营养分析师,擅长通过图片识别食物并估算热量和营养成分。
+PROMPT_ANALYZE_FOOD_CALORIES = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ ANALYSIS_SCOPE_BASE,
+ """
+你是一位专业的营养分析师,擅长通过文本描述识别食物并估算热量和营养成分。
-请基于提供的食物描述或特征,识别食物类型并估算营养信息。
-
-输入信息:
+输入:
{food_description}
请输出 JSON 格式,包含以下字段:
{{
"food_name": "食物名称",
- "confidence": 0.0-1.0,
+ "confidence": 0.0,
"estimated_portion_size": "份量大小描述(如:一份、100克、半碗等)",
- "calories": 数值,
- "protein": 数值,
- "fat": 数值,
- "carbs": 数值,
- "fiber": 数值,
+ "calories": 0.0,
+ "protein": 0.0,
+ "fat": 0.0,
+ "carbs": 0.0,
+ "fiber": 0.0,
"category": "分类(如:主食、蔬菜、肉类、汤品、甜品、饮品等)",
"cuisine": "菜系(如:中式、西式、日式、韩式等)",
"description": "简短的食物描述",
@@ -145,15 +204,19 @@ PROMPT_ANALYZE_FOOD_CALORIES = """
}}
估算规则:
-1. 热量估算范围:考虑到份量不确定性,给出合理的中位值
-2. 营养成分:基于常见食物数据库估算
-3. confidence 根据描述清晰度和食物识别难度给出
-4. 若无法识别,confidence 应 < 0.5,并在 uncertainty_factors 中说明
+1. 热量估算考虑份量不确定性,给出合理中位值。
+2. 营养成分基于常见食物数据库估算。
+3. confidence 根据描述清晰度和食物识别难度给出。
+4. 若无法识别,confidence 应 < 0.5,并在 uncertainty_factors 中说明。
+5. 只输出 JSON,不要其他文字或 Markdown。
+""",
+ JSON_OUTPUT_GUARDRAIL,
+)
-只输出 JSON,不要其他文字或 Markdown。
-"""
-
-PROMPT_SIGNAL_PLANNER = """
+PROMPT_SIGNAL_PLANNER = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ PLANNING_SCOPE_BASE,
+ """
你是 PMFlow 的 Signal Planner。请基于信号内容、项目上下文和团队成员能力,给出可执行的规划建议。
输入:
@@ -193,7 +256,9 @@ PROMPT_SIGNAL_PLANNER = """
4. execution_plan 必须是 2-5 条可执行动作,不要空泛描述。
5. confidence 范围 0-1,不能省略。
6. 只输出 JSON,不要输出 Markdown 或解释文本。
-"""
+""",
+ JSON_OUTPUT_GUARDRAIL,
+)
__all__ = [
"PROMPT_ANALYZE_COST_OPTIMIZATION",
diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_01.py b/backend/app/config/ai_prompts_catalog/part_scenes_01.py
index 940d9d04..fe745cdc 100644
--- a/backend/app/config/ai_prompts_catalog/part_scenes_01.py
+++ b/backend/app/config/ai_prompts_catalog/part_scenes_01.py
@@ -1,21 +1,24 @@
from __future__ import annotations
-FEISHU_QA_RESPONSE_STYLE = """
+from .part_01 import PMFLOW_PMO_STYLE_BASE, TEXT_OUTPUT_GUARDRAIL, _compose_prompt
+
+FEISHU_QA_RESPONSE_STYLE = _compose_prompt(
+ PMFLOW_PMO_STYLE_BASE,
+ """
回复风格要求:
1. 先给结论,再给关键依据,最后再给下一步。
2. 用正式、克制、面向协作的中文,像资深项目经理而不是聊天机器人。
3. 如果能用数字、项目名、负责人、时间点表达,请直接写明。
4. 若信息不足,必须明确说明缺少什么,最多列 3 项。
5. 禁止自我介绍、寒暄、思考过程、数据库/模型/接口实现细节。
-""".strip()
-
-PROMPT_FEISHU_DIRECT_QA = "\n\n".join(
- [
- """
-你是 PMFlow 的飞书项目管理顾问,负责直接问答。
""".strip(),
- FEISHU_QA_RESPONSE_STYLE,
- """
+ TEXT_OUTPUT_GUARDRAIL,
+)
+
+PROMPT_FEISHU_DIRECT_QA = _compose_prompt(
+ "你是 PMFlow 的飞书项目管理顾问,负责直接问答。",
+ FEISHU_QA_RESPONSE_STYLE,
+ """
回答要求:
1. 仅基于给定上下文回答,优先使用结构化数据。
2. 如果问题涉及项目状态、进展、成员或任务,请优先给出可验证的事实和数字。
@@ -32,7 +35,6 @@ User Snapshot:{user_snapshot}
最近会话:{conversation}
检索上下文:{context_blocks}
""".strip(),
- ]
)
__all__ = ["FEISHU_QA_RESPONSE_STYLE", "PROMPT_FEISHU_DIRECT_QA"]
diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_03.py b/backend/app/config/ai_prompts_catalog/part_scenes_03.py
index e8fdcc33..28ccc718 100644
--- a/backend/app/config/ai_prompts_catalog/part_scenes_03.py
+++ b/backend/app/config/ai_prompts_catalog/part_scenes_03.py
@@ -1,7 +1,10 @@
from __future__ import annotations
-PROMPT_PROJECT_STATUS_QA = """
-你是 PMFlow 的项目状态分析助手。
+from .part_01 import TEXT_OUTPUT_GUARDRAIL, _compose_prompt
+
+PROMPT_PROJECT_STATUS_QA = _compose_prompt(
+ "你是 PMFlow 的项目状态分析助手。",
+ """
用户关注项目进度、状态、完成率、阶段和卡点。
规则:
@@ -21,6 +24,8 @@ PROMPT_PROJECT_STATUS_QA = """
System Snapshot:{system_snapshot}
User Snapshot:{user_snapshot}
检索上下文:{context_blocks}
-""".strip()
+""".strip(),
+ TEXT_OUTPUT_GUARDRAIL,
+)
__all__ = ["PROMPT_PROJECT_STATUS_QA"]
diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_04.py b/backend/app/config/ai_prompts_catalog/part_scenes_04.py
index f7eede9d..fd0e8e35 100644
--- a/backend/app/config/ai_prompts_catalog/part_scenes_04.py
+++ b/backend/app/config/ai_prompts_catalog/part_scenes_04.py
@@ -1,22 +1,27 @@
from __future__ import annotations
-PROMPT_MEMBER_WORKLOAD_QA = """
-你是 PMFlow 的团队协作助手。
+from .part_01 import TEXT_OUTPUT_GUARDRAIL, _compose_prompt
+
+PROMPT_MEMBER_WORKLOAD_QA = _compose_prompt(
+ "你是 PMFlow 的团队协作助手。",
+ """
用户询问团队成员或负载相关信息。
规则:
-1. 优先使用 System Snapshot.team_members 和 User Snapshot 数据
-2. 禁止默认返回项目列表
-3. 涉及负载用百分比表示
-4. 忽略与成员负载无关的项目列表和历史项目详情
-5. 推荐负责人时说明推荐理由
-6. 禁止输出思考过程
-7. 不超过150字
+1. 优先使用 System Snapshot.team_members 和 User Snapshot 数据。
+2. 禁止默认返回项目列表。
+3. 涉及负载用百分比表示。
+4. 忽略与成员负载无关的项目列表和历史项目详情。
+5. 推荐负责人时说明推荐理由。
+6. 禁止输出思考过程。
+7. 不超过 150 字。
问题:{question}
System Snapshot:{system_snapshot}
User Snapshot:{user_snapshot}
检索上下文:{context_blocks}
-""".strip()
+""".strip(),
+ TEXT_OUTPUT_GUARDRAIL,
+)
__all__ = ["PROMPT_MEMBER_WORKLOAD_QA"]
diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_05.py b/backend/app/config/ai_prompts_catalog/part_scenes_05.py
index f8bbfe41..a299b1f5 100644
--- a/backend/app/config/ai_prompts_catalog/part_scenes_05.py
+++ b/backend/app/config/ai_prompts_catalog/part_scenes_05.py
@@ -1,14 +1,17 @@
from __future__ import annotations
-PROMPT_AI_PMO_QA = """
-你是 PMFlow 的 AI 项目管理官。
+from .part_01 import TEXT_OUTPUT_GUARDRAIL, _compose_prompt
+
+PROMPT_AI_PMO_QA = _compose_prompt(
+ "你是 PMFlow 的 AI 项目管理官。",
+ """
用户通过指令入口提问。
规则:
-1. 你既是执行者也是回答者
-2. 问题涉及操作时先回答再提示可用指令
-3. 只基于给定上下文回答
-4. 禁止输出思考过程
+1. 你既是执行者也是回答者,但只回答当前上下文明确支持的内容。
+2. 问题涉及操作时先回答再提示可用指令。
+3. 只基于给定上下文回答,不要编造系统状态。
+4. 禁止输出思考过程。
5. 回答后附可操作建议:
"你可以继续:[操作A]/[操作B]"
@@ -16,6 +19,8 @@ PROMPT_AI_PMO_QA = """
当前项目:{project_id}
World Model:{world_model}
检索上下文:{context_blocks}
-""".strip()
+""".strip(),
+ TEXT_OUTPUT_GUARDRAIL,
+)
__all__ = ["PROMPT_AI_PMO_QA"]
diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_06.py b/backend/app/config/ai_prompts_catalog/part_scenes_06.py
index 108d6e68..76bcaeaa 100644
--- a/backend/app/config/ai_prompts_catalog/part_scenes_06.py
+++ b/backend/app/config/ai_prompts_catalog/part_scenes_06.py
@@ -1,29 +1,35 @@
from __future__ import annotations
-PROMPT_EXTERNAL_TREND_QA = """
-你是 PMFlow 的市场情报助手。
+from .part_01 import TEXT_OUTPUT_GUARDRAIL, _compose_prompt
+
+PROMPT_EXTERNAL_TREND_QA = _compose_prompt(
+ "你是 PMFlow 的市场情报助手。",
+ """
用户询问外部行业或市场信息。
规则:
-1. 若有外部搜索结果优先使用
+1. 若有外部搜索结果优先使用。
2. 若无外部数据明确说:
"当前未接入实时数据,以下基于通用行业认知。"
3. 结合项目实际给出应用建议:
"对你们[项目名]的影响是..."
-4. 禁止输出思考过程
+4. 禁止输出思考过程。
5. 分段输出:
[行业动态] xxx
[对项目的影响] xxx
[建议] xxx
-6. 控制总回复长度不超过800字,优先保留[建议]段
+6. 控制总回复长度不超过 800 字,优先保留[建议]段。
问题:{question}
项目上下文:{project_context}
外部搜索结果:{external_results}
-""".strip()
+""".strip(),
+ TEXT_OUTPUT_GUARDRAIL,
+)
-PROMPT_INDUSTRY_TREND_QA = """
-你是 PMFlow 的行业趋势助手。
+PROMPT_INDUSTRY_TREND_QA = _compose_prompt(
+ "你是 PMFlow 的行业趋势助手。",
+ """
用户在询问某个行业、市场或产品方向的通用趋势判断。
规则:
@@ -35,6 +41,8 @@ PROMPT_INDUSTRY_TREND_QA = """
6. 不要输出思考过程,不要使用“让我/我来/我先/我需要”开头。
问题:{question}
-""".strip()
+""".strip(),
+ TEXT_OUTPUT_GUARDRAIL,
+)
__all__ = ["PROMPT_EXTERNAL_TREND_QA", "PROMPT_INDUSTRY_TREND_QA"]
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index dc876a0b..3ce356f3 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -45,6 +45,7 @@ from .knowledge_base_doc import KnowledgeBaseDoc
from .knowledge_base_chunk import KnowledgeBaseChunk
from .qa_record import QARecord
from .qa_feedback import QAFeedback
+from .feedback_center import FeedbackCenterRecord, FeedbackCenterRecordRevision
from .qa_session import QASession
from .qa_session_state import QASessionState
from .kg_entity import KGEntity
@@ -111,6 +112,8 @@ __all__ = [
"KnowledgeBaseChunk",
"QARecord",
"QAFeedback",
+ "FeedbackCenterRecord",
+ "FeedbackCenterRecordRevision",
"QASession",
"QASessionState",
"KGEntity",
diff --git a/backend/app/models/feedback_center.py b/backend/app/models/feedback_center.py
new file mode 100644
index 00000000..3d282c6d
--- /dev/null
+++ b/backend/app/models/feedback_center.py
@@ -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"]
diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py
index 9ed5b048..a8399274 100644
--- a/backend/app/schemas/__init__.py
+++ b/backend/app/schemas/__init__.py
@@ -23,6 +23,21 @@ from .common import (
TaskItem,
)
from .feedback import SignalFeedbackCreate, SignalFeedbackResponse
+from .feedback_center import (
+ FeedbackReasonCandidate,
+ FeedbackCenterRecordRevisionRead,
+ FeedbackCenterRecordRead,
+ FeedbackCenterSummaryReasonRead,
+ FeedbackCenterSummaryMetricsRead,
+ FeedbackCenterTrendDailyRead,
+ FeedbackCenterTrendWindowRead,
+ FeedbackCenterTrendDeltaRead,
+ FeedbackCenterTrendRead,
+ FeedbackCenterSummaryRead,
+ FeedbackCenterListRead,
+ FeedbackCenterReviewRequest,
+ FeedbackCenterReasonCatalogRead,
+)
from .model_capability import (
ValidateMatchRequest,
LifecycleImpactRequest,
@@ -79,6 +94,19 @@ __all__ = [
"TaskItem",
"SignalFeedbackCreate",
"SignalFeedbackResponse",
+ "FeedbackReasonCandidate",
+ "FeedbackCenterRecordRevisionRead",
+ "FeedbackCenterRecordRead",
+ "FeedbackCenterSummaryReasonRead",
+ "FeedbackCenterSummaryMetricsRead",
+ "FeedbackCenterTrendDailyRead",
+ "FeedbackCenterTrendWindowRead",
+ "FeedbackCenterTrendDeltaRead",
+ "FeedbackCenterTrendRead",
+ "FeedbackCenterSummaryRead",
+ "FeedbackCenterListRead",
+ "FeedbackCenterReviewRequest",
+ "FeedbackCenterReasonCatalogRead",
"ValidateMatchRequest",
"LifecycleImpactRequest",
"GenerateMigrationTasksRequest",
diff --git a/backend/app/schemas/feedback_center.py b/backend/app/schemas/feedback_center.py
new file mode 100644
index 00000000..dbe6b644
--- /dev/null
+++ b/backend/app/schemas/feedback_center.py
@@ -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",
+]
diff --git a/backend/app/services/feedback_center/__init__.py b/backend/app/services/feedback_center/__init__.py
new file mode 100644
index 00000000..66e0bf94
--- /dev/null
+++ b/backend/app/services/feedback_center/__init__.py
@@ -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",
+]
diff --git a/backend/app/services/feedback_center/mappers.py b/backend/app/services/feedback_center/mappers.py
new file mode 100644
index 00000000..318321ed
--- /dev/null
+++ b/backend/app/services/feedback_center/mappers.py
@@ -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",
+]
diff --git a/backend/app/services/feedback_center/reason_catalog.py b/backend/app/services/feedback_center/reason_catalog.py
new file mode 100644
index 00000000..39e19d1f
--- /dev/null
+++ b/backend/app/services/feedback_center/reason_catalog.py
@@ -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"]
diff --git a/backend/app/services/feedback_center/service.py b/backend/app/services/feedback_center/service.py
new file mode 100644
index 00000000..59c2cd3d
--- /dev/null
+++ b/backend/app/services/feedback_center/service.py
@@ -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"]
diff --git a/backend/requirements.txt b/backend/requirements.txt
index d797a21c..7afbde01 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -2,7 +2,9 @@ fastapi>=0.115.0
uvicorn[standard]>=0.30.0
sqlalchemy>=2.0.30
alembic>=1.13.2
+greenlet>=3.0.0
asyncpg>=0.29.0
+aiosqlite>=0.20.0
psycopg[binary]>=3.2.0
pgvector>=0.3.5
python-dotenv>=1.0.1
diff --git a/backend/tests/test_ai_prompt_registry_contract.py b/backend/tests/test_ai_prompt_registry_contract.py
index e143c7ef..436efa3f 100644
--- a/backend/tests/test_ai_prompt_registry_contract.py
+++ b/backend/tests/test_ai_prompt_registry_contract.py
@@ -1,9 +1,16 @@
from app.config.ai_prompt_registry import (
PROMPT_REGISTRY_MAP,
+ PROMPT_FAMILY_ALLOWED,
get_prompt_registry,
get_prompt_registry_entry,
iter_prompt_registry_keys,
)
+from app.config.ai_prompts import (
+ PROMPT_DECOMPOSE_TASKS,
+ PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION,
+ PROMPT_FEISHU_REQUIREMENT_ANALYSIS,
+ PROMPT_TASK_BREAKDOWN,
+)
from app.config.ai_prompts_catalog import __all__ as PROMPT_CATALOG_EXPORTS
@@ -18,6 +25,10 @@ def test_prompt_registry_covers_all_catalog_exports() -> None:
entry = get_prompt_registry_entry(key)
assert entry is not None
assert entry["prompt_key"] == key
+ assert entry["prompt_family"] in PROMPT_FAMILY_ALLOWED
+ assert entry["responsibility_scope"]
+ assert entry["priority"] in {"high", "medium", "low"}
+ assert entry["audience"]
def test_prompt_registry_returns_detached_copies() -> None:
@@ -30,18 +41,22 @@ def test_prompt_registry_returns_detached_copies() -> None:
def test_high_frequency_registry_entries_have_complete_metadata() -> None:
- required_keys = (
- "PROMPT_SIGNAL_PLANNER",
- "PROMPT_GENERATE_CHARTER",
- "PROMPT_DECOMPOSE_TASKS",
- "PROMPT_PROJECT_STATUS_QA",
- "PROMPT_MEMBER_WORKLOAD_QA",
- "PROMPT_AI_PMO_QA",
- "PROMPT_EXTERNAL_TREND_QA",
- "PROMPT_INDUSTRY_TREND_QA",
- )
+ expected_families = {
+ "PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION": "classification",
+ "PROMPT_FEISHU_LEVEL_CLASSIFICATION": "classification",
+ "PROMPT_FEISHU_SIGNAL_EXTRACTION": "extraction",
+ "PROMPT_FEISHU_TASK_ORCHESTRATION": "planning",
+ "PROMPT_SIGNAL_PLANNER": "planning",
+ "PROMPT_GENERATE_CHARTER": "planning",
+ "PROMPT_DECOMPOSE_TASKS": "planning",
+ "PROMPT_PROJECT_STATUS_QA": "response",
+ "PROMPT_MEMBER_WORKLOAD_QA": "response",
+ "PROMPT_AI_PMO_QA": "response",
+ "PROMPT_EXTERNAL_TREND_QA": "response",
+ "PROMPT_INDUSTRY_TREND_QA": "response",
+ }
- for prompt_key in required_keys:
+ for prompt_key, family in expected_families.items():
entry = get_prompt_registry_entry(prompt_key)
assert entry is not None
assert entry["module"]
@@ -50,9 +65,21 @@ def test_high_frequency_registry_entries_have_complete_metadata() -> None:
assert entry["output_contract"]
assert isinstance(entry["call_sites"], tuple)
assert entry["call_sites"], prompt_key
+ assert entry["prompt_family"] == family
+ assert entry["responsibility_scope"]
signal_planner_entry = get_prompt_registry_entry("PROMPT_SIGNAL_PLANNER")
assert signal_planner_entry is not None
assert "charter_json" in signal_planner_entry["input_contract"]
assert any("mixin_prompt.py" in site for site in signal_planner_entry["call_sites"])
assert any("mixin_analyze.py" in site for site in signal_planner_entry["call_sites"])
+ assert "规划" in signal_planner_entry["responsibility_scope"]
+
+
+def test_deprecated_prompt_aliases_point_to_canonical_prompts() -> None:
+ registry_keys = set(iter_prompt_registry_keys())
+
+ assert "PROMPT_FEISHU_REQUIREMENT_ANALYSIS" not in registry_keys
+ assert "PROMPT_TASK_BREAKDOWN" not in registry_keys
+ assert PROMPT_FEISHU_REQUIREMENT_ANALYSIS == PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION
+ assert PROMPT_TASK_BREAKDOWN == PROMPT_DECOMPOSE_TASKS
diff --git a/backend/tests/test_feedback_center_api.py b/backend/tests/test_feedback_center_api.py
new file mode 100644
index 00000000..2efedb0a
--- /dev/null
+++ b/backend/tests/test_feedback_center_api.py
@@ -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"
diff --git a/backend/tests/test_feedback_center_service.py b/backend/tests/test_feedback_center_service.py
new file mode 100644
index 00000000..ee0bacf3
--- /dev/null
+++ b/backend/tests/test_feedback_center_service.py
@@ -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}
diff --git a/backend/tests/test_prompt_profile_bundle_contract.py b/backend/tests/test_prompt_profile_bundle_contract.py
new file mode 100644
index 00000000..08cfb1cd
--- /dev/null
+++ b/backend/tests/test_prompt_profile_bundle_contract.py
@@ -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 "")
diff --git a/frontend/src/api/feedbackCenter.ts b/frontend/src/api/feedbackCenter.ts
new file mode 100644
index 00000000..5c873fac
--- /dev/null
+++ b/frontend/src/api/feedbackCenter.ts
@@ -0,0 +1,278 @@
+import { apiRequest } from "./base";
+
+type ApiEnvelope Learning Signal
+ 这里不做复核,只告诉你当前自动化链路里哪些地方最常被人工纠正。
+ 待复核 {pendingCount} 采纳率 {Math.round(acceptedRate * 100)}% 高频原因统一反馈中心摘要
+
{title}
+{formatJson(value)}
+ Diff View
++ {record.source_type} · 字段 {record.field_name} +
+Meta
+项目
+{record.project_id ?? "—"}
+对象
+{record.object_type} #{record.object_id}
+原因码
+{record.reason_code || "—"}
+置信度
+{record.ai_confidence !== null && record.ai_confidence !== undefined ? `${Math.round(Number(record.ai_confidence) * 100)}%` : "—"}
+AI Snapshot
+
+ {formatJson(record.ai_snapshot)}
+
+ Revisions
+ {revisions.length} 条 +暂无修订历史。
} + {!!revisions.length && ( ++ 修订 #{revision.id} + {revision.reviewed_at ? ` · ${revision.reviewed_at}` : ""} +
+
+ {formatJson(revision.before_snapshot)}
+
+
+ {formatJson(revision.after_snapshot)}
+
+ Queue
+共 {total} 条记录
+加载中...
} + {!isLoading && !items.length &&当前筛选下还没有反馈记录。
} + {items.map((item) => { + const selected = selectedId === item.id; + return ( + + ); + })} +{title}
+{value}
+{helper}
+{title}
+ {totalLabel &&{totalLabel}
} +{formatPercent(item.percent)}
+暂无可展示的分布数据。
+ )} +Learning Health
+把关键采纳指标压成一组可扫读的条形信号,方便快速判断自动化是否在变好。
+采纳率
+{formatPercent(metrics.accepted_rate_reviewed)}
+人工覆盖
+{formatPercent(metrics.manual_override_rate)}
+返工
+{formatPercent(metrics.rework_rate)}
+自动指派
+{formatPercent(metrics.auto_assigned_rate)}
+Review Action
+AI 已经先给出候选原因,人只需要确认、修正,或拒绝纳入学习。
+候选原因
+补充备注
+{title}
+{value}
+上期 {previous}
+{helper}
+{title}
+Trend Scope
++ 这里的筛选只影响趋势页,不会改动总览、指标页或任务复核列表。 +
+Daily Volume
+{dayLabel(item.date)}
+{item.total_records} 条
+Rate Sparklines
++ 每一行都展示最近 {trend.window_days} 天的节奏变化,方便快速看出自动化是不是在稳定下来。 +
+ +{metric.label}
++ 当前 {formatPercent(metric.value)} · {metric.delta > 0 ? "上升" : metric.delta < 0 ? "下降" : "持平"} +
+Backlog
+{summary?.backlog_count ?? 0}
+待复核 + 已复核未入样本
+Accepted
++ {Math.round(Number(summary?.accepted_rate || 0) * 100)}% +
+已进入学习池的样本占比
+Confidence
++ {Math.round(Number(summary?.avg_confidence || 0) * 100)}% +
+当前反馈样本平均 AI 置信度
+Top Reason
++ {summary?.top_reasons?.[0]?.label || "暂无"} +
++ {summary?.top_reasons?.[0] ? `${summary.top_reasons[0].count} 次` : "等待样本累计"} +
+Feedback Center
++ 把任务、QA、信号三类人工修正收口成可学习样本,当前只做复核与归因,不在这里直接改业务对象。 +
+By Source
+还没有可展示的来源分布。
+ )} +Top Reasons
+{item.code}
+原因榜单会随着样本增加自动出现。
} +