521 lines
20 KiB
Python
521 lines
20 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 _feedback_center_records_table(bind):
|
|
json_type = _json_type(bind)
|
|
return sa.table(
|
|
"feedback_center_records",
|
|
sa.column("tenant_id", sa.BigInteger()),
|
|
sa.column("project_id", sa.BigInteger()),
|
|
sa.column("source_type", sa.VARCHAR(length=32)),
|
|
sa.column("source_id", sa.BigInteger()),
|
|
sa.column("object_type", sa.VARCHAR(length=32)),
|
|
sa.column("object_id", sa.BigInteger()),
|
|
sa.column("field_name", sa.VARCHAR(length=128)),
|
|
sa.column("before_value", json_type),
|
|
sa.column("after_value", json_type),
|
|
sa.column("ai_snapshot", json_type),
|
|
sa.column("ai_confidence", sa.Float()),
|
|
sa.column("reason_candidates", json_type),
|
|
sa.column("reason_code", sa.VARCHAR(length=64)),
|
|
sa.column("reason_text", sa.Text()),
|
|
sa.column("review_status", sa.VARCHAR(length=16)),
|
|
sa.column("reviewed_by", sa.BigInteger()),
|
|
sa.column("reviewed_at", sa.TIMESTAMP()),
|
|
sa.column("source_title", sa.VARCHAR(length=500)),
|
|
sa.column("object_title", sa.VARCHAR(length=500)),
|
|
sa.column("auto_assigned", sa.Boolean()),
|
|
)
|
|
|
|
|
|
def _insert_record(bind, payload: dict[str, Any]) -> None:
|
|
bind.execute(sa.insert(_feedback_center_records_table(bind)).values(**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")
|