PMFlow/backend/alembic/versions/20260326_create_feedback_ce...

543 lines
21 KiB
Python

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