961 lines
34 KiB
Python
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}
|