PMFlow/backend/tests/test_feedback_center_servic...

961 lines
34 KiB
Python

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_exposes_decision_insights_for_trend_page(db_session, monkeypatch) -> None:
base_time = datetime(2025, 1, 20, 10, 0, 0)
db_session.add_all(
[
FeedbackCenterRecord(
tenant_id=1,
project_id=11,
source_type="task_breakdown",
source_id=701,
object_type="task",
object_id=1001,
field_name="task_breakdown",
review_status="accepted",
auto_assigned=False,
created_at=base_time,
reviewed_at=base_time + timedelta(minutes=90),
),
FeedbackCenterRecord(
tenant_id=1,
project_id=11,
source_type="task_override",
source_id=702,
object_type="task",
object_id=1001,
field_name="priority",
review_status="reviewed",
created_at=base_time + timedelta(minutes=2),
reviewed_at=base_time + timedelta(minutes=95),
),
FeedbackCenterRecord(
tenant_id=1,
project_id=11,
source_type="task_breakdown",
source_id=703,
object_type="task",
object_id=1002,
field_name="task_breakdown",
review_status="accepted",
auto_assigned=True,
created_at=base_time + timedelta(minutes=4),
reviewed_at=base_time + timedelta(minutes=20),
),
FeedbackCenterRecord(
tenant_id=1,
project_id=11,
source_type="task_override",
source_id=704,
object_type="task",
object_id=1002,
field_name="estimated_hours",
review_status="pending",
created_at=base_time + timedelta(minutes=6),
),
]
)
await db_session.commit()
fixed_now = datetime(2025, 1, 20, 12, 0, 0)
monkeypatch.setattr(feedback_center_service_module, "now_cn_naive", lambda: fixed_now)
summary = await FeedbackCenterService(db_session).summary(
tenant_id=1,
project_id=11,
window_days=7,
source_group="task",
)
insights = summary["insights"]
assert len(insights) >= 2
insight_codes = {item["code"] for item in insights}
assert "manual_override_pressure" in insight_codes
assert "auto_assignment_gap" in insight_codes
manual_override_insight = next(item for item in insights if item["code"] == "manual_override_pressure")
assert manual_override_insight["target_section"] == "task"
assert manual_override_insight["target_source_type"] == "task_override"
assert manual_override_insight["action_label"] == "查看任务覆盖样本"
auto_assignment_insight = next(item for item in insights if item["code"] == "auto_assignment_gap")
assert auto_assignment_insight["target_source_type"] == "task_breakdown"
assert auto_assignment_insight["severity"] in {"warning", "critical"}
@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_buckets_reviewed_activity_by_review_time(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(
FeedbackCenterRecord(
tenant_id=1,
project_id=11,
source_type="task_override",
source_id=705,
object_type="task",
object_id=801,
field_name="priority",
review_status="accepted",
reason_code="priority_misalignment",
created_at=datetime(2025, 1, 8, 9, 0, 0),
reviewed_at=datetime(2025, 1, 15, 9, 15, 0),
)
)
await db_session.commit()
summary = await FeedbackCenterService(db_session).summary(
tenant_id=1,
project_id=11,
window_days=7,
source_group="task",
)
trend = summary["trend"]
assert trend["current"]["total_records"] == 1
assert trend["previous"]["total_records"] == 0
assert any(item["date"] == "2025-01-15" and item["total_records"] == 1 for item in trend["daily"])
@pytest.mark.asyncio
async def test_summary_insights_follow_current_window_reason_distribution(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)
old_records = [
FeedbackCenterRecord(
tenant_id=1,
project_id=11,
source_type="task_breakdown",
source_id=801 + index,
object_type="task",
object_id=900 + index,
field_name="task_breakdown",
review_status="accepted",
reason_code="owner_mismatch",
created_at=datetime(2025, 1, 3, 9, 0, 0),
reviewed_at=datetime(2025, 1, 3, 9, 20, 0),
)
for index in range(5)
]
current_record = FeedbackCenterRecord(
tenant_id=1,
project_id=11,
source_type="task_breakdown",
source_id=900,
object_type="task",
object_id=1000,
field_name="task_breakdown",
review_status="accepted",
reason_code="skill_gap",
created_at=datetime(2025, 1, 15, 9, 0, 0),
reviewed_at=datetime(2025, 1, 15, 9, 25, 0),
)
db_session.add_all(old_records + [current_record])
await db_session.commit()
summary = await FeedbackCenterService(db_session).summary(
tenant_id=1,
project_id=11,
window_days=7,
source_group="task",
)
trend = summary["trend"]
assert trend["current"]["top_reasons"][0]["code"] == "skill_gap"
insights = summary["insights"]
assert any(
item["code"] == "reason_concentration" and item["target_reason_code"] == "skill_gap"
for item in insights
)
@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}